Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
4350 lines
143 KiB
Go
4350 lines
143 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder {
|
|
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
|
|
}
|
|
|
|
// adminRequestCtx wraps a request context with the admin role so handlers that
|
|
// run a defense-in-depth isAdminRequest check (S-1) work when called directly
|
|
// (bypassing the mw.RequireAdmin middleware that normally injects the role).
|
|
func adminRequestCtx(r *http.Request) *http.Request {
|
|
reqCtx := context.WithValue(r.Context(), mw.UserRoleKey, "admin")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
|
|
return r.WithContext(reqCtx)
|
|
}
|
|
|
|
func TestValidateCardInfo(t *testing.T) {
|
|
empty := ""
|
|
cardID := "card_123"
|
|
token := "cnon:test"
|
|
|
|
tests := []struct {
|
|
name string
|
|
cardID *string
|
|
newToken *string
|
|
wantError bool
|
|
}{
|
|
{"both set rejected", &cardID, &token, true},
|
|
{"card_id only ok", &cardID, nil, false},
|
|
{"token only ok", nil, &token, false},
|
|
{"neither set rejected", nil, nil, true},
|
|
{"empty card_id rejected", &empty, nil, true},
|
|
{"empty token rejected", nil, &empty, true},
|
|
{"both empty rejected", &empty, &empty, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateCardInfo(tt.cardID, tt.newToken)
|
|
if tt.wantError {
|
|
assert.Error(t, err)
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, path, nil)
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
if id, paramName := extractPaymentIDFromPath(path); id != "" {
|
|
rctx.URLParams.Add(paramName, id)
|
|
}
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
|
|
var userID, userRole string
|
|
if userIDOverride != "" {
|
|
userID = userIDOverride
|
|
userRole = "verified_email"
|
|
} else if token != "" {
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
userID = info.userID
|
|
userRole = info.role
|
|
}
|
|
}
|
|
|
|
if userID != "" {
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, userRole)
|
|
}
|
|
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler(w, req)
|
|
return w
|
|
}
|
|
|
|
type paymentUserInfo struct {
|
|
userID string
|
|
role string
|
|
}
|
|
|
|
func extractUserFromTestJWT(token string) *paymentUserInfo {
|
|
parts := splitToken(token)
|
|
if len(parts) != 3 {
|
|
return nil
|
|
}
|
|
|
|
decoded, err := base64URLDecode(parts[1])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
return nil
|
|
}
|
|
|
|
userID, _ := claims["user_id"].(string)
|
|
role, _ := claims["role"].(string)
|
|
|
|
if userID == "" {
|
|
return nil
|
|
}
|
|
|
|
return &paymentUserInfo{userID: userID, role: role}
|
|
}
|
|
|
|
func splitToken(token string) []string {
|
|
var result []string
|
|
var current []byte
|
|
for _, c := range token {
|
|
if c == '.' {
|
|
result = append(result, string(current))
|
|
current = nil
|
|
} else {
|
|
current = append(current, byte(c))
|
|
}
|
|
}
|
|
if len(current) > 0 {
|
|
result = append(result, string(current))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func base64URLDecode(s string) ([]byte, error) {
|
|
return base64.RawURLEncoding.DecodeString(s)
|
|
}
|
|
|
|
func extractPaymentIDFromPath(path string) (string, string) {
|
|
patterns := []struct {
|
|
prefix string
|
|
paramName string
|
|
}{
|
|
{"/api/admin/payments/", "payment_id"},
|
|
{"/api/admin/bookings/", "id"},
|
|
{"/api/bookings/", "id"},
|
|
{"/api/user/payment-methods/", "id"},
|
|
}
|
|
for _, p := range patterns {
|
|
if idx := findPaymentLastSegment(path, p.prefix); idx >= 0 {
|
|
endIdx := len(path)
|
|
for i := idx; i < len(path); i++ {
|
|
if path[i] == '/' {
|
|
endIdx = i
|
|
break
|
|
}
|
|
}
|
|
return path[idx:endIdx], p.paramName
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func findPaymentLastSegment(path, prefix string) int {
|
|
for i := len(path) - 1; i >= len(prefix); i-- {
|
|
if len(path) > i && path[i-len(prefix):i] == prefix {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
|
|
return json.Unmarshal(w.Body.Bytes(), dest)
|
|
}
|
|
|
|
func TestTerminalPayment_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
TipEnabled: true,
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp CheckoutResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.CheckoutID == "" {
|
|
t.Error("expected checkout ID to be set")
|
|
}
|
|
|
|
if resp.Status != "PENDING" {
|
|
t.Errorf("expected status PENDING, got %s", resp.Status)
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected 0 payments (created on completion), got %d", count)
|
|
}
|
|
}
|
|
|
|
func setupTestData(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
|
|
return setupTestDataAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
}
|
|
|
|
// setupTestDataPast creates a booking with start_time in the past (1 hour ago)
|
|
// to prevent payment-split logic from triggering. Used by tests that verify
|
|
// payment sequencing or idempotency rather than deposit allocation.
|
|
func setupTestDataPast(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
|
|
return setupTestDataAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour))
|
|
}
|
|
|
|
func setupTestDataAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string, string) {
|
|
userID, err := fixtures.CreateTestUser(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
|
|
_, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update booking status: %v", err)
|
|
}
|
|
|
|
return userID, bookingID, serviceID
|
|
}
|
|
|
|
func TestTerminalPayment_PriceOverride(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
overrideAmount := int64(3000)
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
OverrideAmount: &overrideAmount,
|
|
TipEnabled: false,
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp CheckoutResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTerminalPayment_BookingNotInProgress(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
_ = serviceID
|
|
}
|
|
|
|
func TestTerminalPayment_BookingNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestOnlinePayment_NewCard_Deposit(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "deposit-key-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.ID == "" {
|
|
t.Error("expected payment ID to be set")
|
|
}
|
|
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected status completed, got %s", resp.Status)
|
|
}
|
|
|
|
if resp.Amount != 2500 {
|
|
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 payment, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestOnlinePayment_SavedCard(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "saved-card-key-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected status completed, got %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestOnlinePayment_BookingNotOwned(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "not-owned-key-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetUserPaymentMethods_HasCards(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_1", "VISA", "1111")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method 1: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_2", "MASTERCARD", "2222")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method 2: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
handler := GetUserPaymentMethods
|
|
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cards []SavedCard
|
|
if err := parsePaymentResponseBody(w, &cards); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if len(cards) != 2 {
|
|
t.Errorf("expected 2 cards, got %d", len(cards))
|
|
}
|
|
}
|
|
|
|
func TestDeletePaymentMethod(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_delete", "VISA", "9999")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
handler := DeletePaymentMethod
|
|
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp["status"] != "deleted" {
|
|
t.Errorf("expected status deleted, got %s", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestRefund_FullRefund(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
squarePaymentID := "sqp_test_123"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 5000,
|
|
Reason: "customer request",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp RefundResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.Amount != 5000 {
|
|
t.Errorf("expected amount 5000, got %d", resp.Amount)
|
|
}
|
|
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected status completed, got %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestRefund_SameKeyRetry_Dedups(t *testing.T) {
|
|
// A retry of the same refund (network timeout, double-click) must not
|
|
// create a second Square refund or a second DB row. Retry dedup runs on
|
|
// the CLIENT-supplied idempotency key — a UUID generated per refund
|
|
// attempt and REUSED on retry (hashed+truncated into the stored key), so
|
|
// the same key dedups. A NO-key refund now carries a fresh random fallback
|
|
// key per attempt and never dedups against a prior no-key refund — that is
|
|
// deliberate (see TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide).
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_dedup' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 5000,
|
|
Reason: "customer request",
|
|
IdempotencyKey: "refund-same-key-retry-uuid",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
// First refund — completes.
|
|
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Same-key retry (identical client key, amount, reason) — must dedup, not
|
|
// double-refund.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("retry refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Exactly one refund row for this payment.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund row (deduped), got %d", refundCount)
|
|
}
|
|
}
|
|
|
|
// TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide verifies the P2
|
|
// security fix for the DEFAULT (no client key) path: the fallback idempotency
|
|
// key is now unique per refund attempt, so two DISTINCT partial refunds of the
|
|
// SAME amount against the SAME payment each get their own key and both
|
|
// complete. With the old amount-derived default key (paymentID + "-refund-" +
|
|
// amount) the second would collide with the first and be silently swallowed by
|
|
// the dedup lookup.
|
|
func TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_no_key_equal_refunds' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
handler := RefundPayment
|
|
// Two distinct £20 partial refunds of the same £50 payment, NO client
|
|
// idempotency key on either — the exact case that collided on the
|
|
// amount-derived default key before the fix.
|
|
refund1 := RefundRequest{Amount: 2000, Reason: "partial one"}
|
|
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
refund2 := RefundRequest{Amount: 2000, Reason: "partial two"}
|
|
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Both refunds must exist as separate completed rows with DIFFERENT
|
|
// fallback keys — the second was NOT swallowed by the first's dedup lookup.
|
|
var completedCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if completedCount != 2 {
|
|
t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount)
|
|
}
|
|
|
|
var distinctKeys int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(DISTINCT idempotency_key) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&distinctKeys)
|
|
if err != nil {
|
|
t.Fatalf("failed to count distinct refund keys: %v", err)
|
|
}
|
|
if distinctKeys != 2 {
|
|
t.Errorf("expected 2 DISTINCT fallback keys for the two same-amount refunds, got %d", distinctKeys)
|
|
}
|
|
}
|
|
|
|
// TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates verifies the P2 fix:
|
|
// two DISTINCT partial refunds of the same amount against the same payment
|
|
// must both complete. With only the amount-derived key (paymentID-amount) the
|
|
// second would collide with the first and be silently swallowed as a dedup.
|
|
// A client-supplied idempotency key per attempt disambiguates them, while a
|
|
// same-key retry still dedups.
|
|
func TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_equal_refunds' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
handler := RefundPayment
|
|
// Two distinct £20 partial refunds of the same £50 payment — the exact
|
|
// case that collided on the amount-derived key before the fix.
|
|
refund1 := RefundRequest{Amount: 2000, Reason: "partial one", IdempotencyKey: "refund-uuid-1"}
|
|
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
refund2 := RefundRequest{Amount: 2000, Reason: "partial two", IdempotencyKey: "refund-uuid-2"}
|
|
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Both refunds must exist as separate completed rows — the second was NOT
|
|
// swallowed by the first's dedup lookup.
|
|
var completedCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if completedCount != 2 {
|
|
t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount)
|
|
}
|
|
|
|
// Same-key retry of refund1 must STILL dedup (retry semantics preserved).
|
|
w3 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
|
|
if w3.Code != http.StatusOK {
|
|
t.Fatalf("retry of first refund: expected 200, got %d. body: %s", w3.Code, w3.Body.String())
|
|
}
|
|
var totalCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&totalCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds after retry: %v", err)
|
|
}
|
|
if totalCount != 2 {
|
|
t.Errorf("expected still 2 completed refund rows after same-key retry, got %d", totalCount)
|
|
}
|
|
}
|
|
|
|
// TestRefund_PendingResume_NewKeyAfterModalReopen verifies the P2 pending-resume
|
|
// regression fix: when a refund attempt left a PENDING row (Square call failed
|
|
// ambiguously) and the admin reopens the modal — generating a NEW idempotency
|
|
// key — the retry must RESUME the pending row with its stored key, not create
|
|
// a second pending row. Without the fallback, the sweep would process both
|
|
// pending rows and move twice the intended money.
|
|
func TestRefund_PendingResume_NewKeyAfterModalReopen(t *testing.T) {
|
|
// NOT t.Parallel: it swaps the package-level SquareClient (the counting
|
|
// client below), and a concurrent parallel test reading SquareClient would
|
|
// observe the swapped instance mid-test.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
// Seed the pending row from the first (failed) attempt. Its stored key is
|
|
// the ORIGINAL attempt's UUID; the retry below uses a different one.
|
|
origKey := paymentID + "-refund-uuid-A"
|
|
var pendingRefundID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW())
|
|
RETURNING id
|
|
`, paymentID, bookingID, origKey).Scan(&pendingRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending refund: %v", err)
|
|
}
|
|
|
|
// Swap in a counting client to verify Square is called with the STORED key.
|
|
origClient := SquareClient
|
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = counting
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
handler := RefundPayment
|
|
// Retry with a fresh key (modal reopened) — must resume, not duplicate.
|
|
newKeyReq := RefundRequest{Amount: 2000, Reason: "customer request", IdempotencyKey: "refund-uuid-B"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", newKeyReq, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("retry with new key: expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Still exactly ONE refund row for the payment — no second pending row.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected exactly 1 refund row (resumed, not duplicated), got %d — double-refund path!", refundCount)
|
|
}
|
|
|
|
// The single row was resumed to completed.
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM refunds WHERE id = $1`, pendingRefundID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected the pending refund to be resumed to completed, got %q", status)
|
|
}
|
|
|
|
// Square was called with the STORED key (uuid-A), never the new key — so
|
|
// Square's key dedup returns the original refund instead of issuing a second.
|
|
calls := counting.refundCalls()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 Square refund call, got %d", len(calls))
|
|
}
|
|
if calls[0].IdempotencyKey != origKey {
|
|
t.Errorf("expected Square call to use the stored key %q, got %q (fresh key would double-refund)", origKey, calls[0].IdempotencyKey)
|
|
}
|
|
}
|
|
|
|
// TestRefund_PendingResume_DifferentAmountRejected verifies the P3 hardening:
|
|
// when a pending refund exists for the payment but the retry is for a DIFFERENT
|
|
// amount (admin changed it after reopening the modal), the request is rejected
|
|
// with 409 instead of creating a second pending row — which the sweep would
|
|
// otherwise process alongside the first, moving more money than intended.
|
|
func TestRefund_PendingResume_DifferentAmountRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_diff_amount' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
// Seed a pending £20 refund (first attempt failed ambiguously).
|
|
origKey := paymentID + "-refund-2000"
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
|
VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW())
|
|
`, paymentID, bookingID, origKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending refund: %v", err)
|
|
}
|
|
|
|
handler := RefundPayment
|
|
// Retry with a DIFFERENT amount (£30) and a fresh key — must be rejected
|
|
// with 409, never creating a second pending row.
|
|
req := RefundRequest{Amount: 3000, Reason: "customer request", IdempotencyKey: "refund-uuid-B-diff"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("different-amount retry while pending: expected 409, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Still exactly ONE pending refund row — no second row was created.
|
|
var pendingCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'pending'`, paymentID).Scan(&pendingCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count pending refunds: %v", err)
|
|
}
|
|
if pendingCount != 1 {
|
|
t.Errorf("expected exactly 1 pending refund row, got %d (second pending row would double-refund)", pendingCount)
|
|
}
|
|
}
|
|
|
|
func TestRefund_PartialRefund(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
squarePaymentID := "sqp_test_456"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 2500,
|
|
Reason: "partial refund",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp RefundResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.Amount != 2500 {
|
|
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
|
}
|
|
}
|
|
|
|
func TestRefund_OverRefundRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
squarePaymentID := "sqp_test_789"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 6000,
|
|
Reason: "over refund attempt",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRefund_PaymentNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := RefundRequest{
|
|
Amount: 1000,
|
|
Reason: "test",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRefund_PendingPaymentRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 5000,
|
|
Reason: "test",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) {
|
|
// A retry of an in-flight refund (DB row pending, Square call never
|
|
// completed) must resume rather than insert a second row or issue a second
|
|
// Square refund. The handler retries Square with the same idempotency key
|
|
// and completes the pending row.
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
// Seed a pending refund with the key the handler derives from the client
|
|
// key below (sha256-hashed + truncated to 24 hex chars). The retry sends
|
|
// the SAME client key, so the exact-key dedup resumes the row.
|
|
clientKey := "refund-pending-resume-uuid"
|
|
ikHash := sha256.Sum256([]byte(clientKey))
|
|
key := paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24]
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at)
|
|
VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW())
|
|
`, paymentID, bookingID, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending refund: %v", err)
|
|
}
|
|
|
|
req := RefundRequest{
|
|
Amount: 2500,
|
|
Reason: "customer request",
|
|
IdempotencyKey: clientKey,
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The pending row must now be completed with a Square refund ID — and no
|
|
// second refund row inserted.
|
|
var refundCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refunds: %v", err)
|
|
}
|
|
if refundCount != 1 {
|
|
t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount)
|
|
}
|
|
|
|
var status string
|
|
var squareRefundID *string
|
|
err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status completed, got %q", status)
|
|
}
|
|
if squareRefundID == nil || *squareRefundID == "" {
|
|
t.Error("expected square_refund_id to be set after resume")
|
|
}
|
|
}
|
|
|
|
func TestRefund_GuardCountsPendingRefunds(t *testing.T) {
|
|
// A pending refund (in-flight Square call) blocks any further manual refund
|
|
// of the payment: same amount resumes it, a different amount is rejected
|
|
// with 409 (in-flight guard) — so the over-refund guard can never be
|
|
// bypassed by piling a second pending row on top of an unresolved one.
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update payment: %v", err)
|
|
}
|
|
|
|
// Seed a pending refund of £70 (as if a previous attempt's Square call is in flight).
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
|
|
VALUES ($1, $2, 70, 'pending', 'in flight', NOW())
|
|
`, paymentID, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending refund: %v", err)
|
|
}
|
|
|
|
// A further £40 refund would total £110 > £100. The in-flight guard rejects
|
|
// it with 409 before the over-refund guard is even reached — the pending
|
|
// row's money state is unknown, so no new refund is issued.
|
|
req := RefundRequest{
|
|
Amount: 4000,
|
|
Reason: "over refund attempt",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409 (in-flight guard), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) {
|
|
// Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved.
|
|
// The refund row must resolve to 'completed' (NOT 'failed', which would let
|
|
// the over-refund guard re-issue money on top of it), square_refund_id
|
|
// stays NULL, and the API returns 200.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := RefundRequest{
|
|
Amount: 5000,
|
|
Reason: "customer request",
|
|
}
|
|
|
|
handler := RefundPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp RefundResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected response status 'completed', got %q", resp.Status)
|
|
}
|
|
|
|
var status string
|
|
var squareRefundID *string
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refund: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected refund status 'completed', got %q", status)
|
|
}
|
|
if squareRefundID != nil && *squareRefundID != "" {
|
|
t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID)
|
|
}
|
|
}
|
|
|
|
func TestTipPayment_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:tip-card"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.PaymentType != "tip" {
|
|
t.Errorf("expected payment type tip, got %s", resp.PaymentType)
|
|
}
|
|
|
|
if resp.Amount != 500 {
|
|
t.Errorf("expected amount 500, got %d", resp.Amount)
|
|
}
|
|
}
|
|
|
|
func TestTipPayment_NoPriorPayment(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:tip-card"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:idempotent-card"
|
|
idempotencyKey := "idempotent-same-key"
|
|
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: idempotencyKey,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
|
|
if w1.Code != http.StatusOK {
|
|
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
var resp1 PaymentResponse
|
|
if err := parsePaymentResponseBody(w1, &resp1); err != nil {
|
|
t.Errorf("failed to parse first response: %v", err)
|
|
}
|
|
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: idempotencyKey,
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var resp2 PaymentResponse
|
|
if err := parsePaymentResponseBody(w2, &resp2); err != nil {
|
|
t.Errorf("failed to parse second response: %v", err)
|
|
}
|
|
|
|
if resp1.ID != resp2.ID {
|
|
t.Errorf("expected same payment ID, got %s and %s", resp1.ID, resp2.ID)
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 payment (idempotent), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:different-key-card"
|
|
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "key-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
|
|
if w1.Code != http.StatusOK {
|
|
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "key-2",
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
|
|
// The second request is blocked because only one "full" payment is
|
|
// allowed per booking (the payment-type duplicate guard prevents the
|
|
// two-tab double-payment race even when idempotency keys differ).
|
|
if w2.Code != http.StatusConflict {
|
|
t.Errorf("second request expected status 409, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 payment (second was blocked), got %d", count)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Payment-type duplicate guard — serialization lock prevents double payments
|
|
// =============================================================================
|
|
|
|
func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
// First: a deposit payment should succeed.
|
|
cardToken := "cnon:diff-type-card"
|
|
depositReq := CreateBookingPaymentRequest{
|
|
Amount: 2000,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "diff-type-deposit-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Second: a balance payment uses a different payment_type — should also succeed.
|
|
balanceReq := CreateBookingPaymentRequest{
|
|
Amount: 3000,
|
|
PaymentType: "balance",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "diff-type-balance-" + bookingID,
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Verify at least one payment of each type exists. buildSplitRecords may
|
|
// create extra records (e.g. a 'balance' portion alongside 'deposit'), so
|
|
// we check DISTINCT types rather than a raw row count.
|
|
var distinctTypes []string
|
|
rows, err := tx.Query(ctx,
|
|
"SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type",
|
|
bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payments: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var pt string
|
|
if err := rows.Scan(&pt); err == nil {
|
|
distinctTypes = append(distinctTypes, pt)
|
|
}
|
|
}
|
|
if len(distinctTypes) < 2 {
|
|
t.Errorf("expected at least 2 distinct payment types, got %d: %v", len(distinctTypes), distinctTypes)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
cardToken := "cnon:dup-type-card"
|
|
|
|
// First 'full' payment succeeds.
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "dup-type-first-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Second 'full' payment with a different idempotency key should be blocked
|
|
// by the payment-type duplicate guard.
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 2000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "dup-type-second-" + bookingID,
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
if w2.Code != http.StatusConflict {
|
|
t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Verify only one real payment was created. buildSplitRecords converts the
|
|
// first 'full' payment into 'deposit' + 'balance', so we count deposit records
|
|
// rather than 'full' — the exact guard above confirmed the 409 rejection.
|
|
var depositCount int
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')",
|
|
bookingID).Scan(&depositCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if depositCount != 1 {
|
|
t.Errorf("expected 1 deposit record (split from first 'full' payment), got %d", depositCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
cardToken := "cnon:partial-card"
|
|
|
|
// First partial payment.
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 1000,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "partial-first-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Second partial payment (different key, same type) — allowed because
|
|
// the duplicate guard explicitly exempts 'partial'.
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 1500,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "partial-second-" + bookingID,
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Count all real payments (buildSplitRecords converts partials to deposit
|
|
// when within the 50% deposit cap). Both should have been created.
|
|
var total int
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')",
|
|
bookingID).Scan(&total)
|
|
if err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("expected 2 payments (both created), got %d", total)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// User Booking Payment Tests — deposit, full, partial, balance
|
|
// ============================================================
|
|
|
|
func setupDepositBooking(t *testing.T, ctx context.Context, q db.Querier) (string, string) {
|
|
return setupDepositBookingAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
}
|
|
|
|
// setupDepositBookingPast creates a confirmed booking with start_time in the past
|
|
// (1 hour ago). This prevents the payment-split logic from triggering, which is
|
|
// useful for tests that verify payment sequencing rather than deposit splitting.
|
|
func setupDepositBookingPast(t *testing.T, ctx context.Context, q db.Querier) (string, string) {
|
|
return setupDepositBookingAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour))
|
|
}
|
|
|
|
func setupDepositBookingAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string) {
|
|
userID, err := fixtures.CreateTestUser(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
|
|
_, err = q.Exec(ctx,
|
|
"UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1",
|
|
bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to update booking: %v", err)
|
|
}
|
|
|
|
return userID, bookingID
|
|
}
|
|
|
|
func TestBookingPayment_Deposit_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:deposit-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "deposit-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.PaymentType != "deposit" {
|
|
t.Errorf("expected payment type deposit, got %s", resp.PaymentType)
|
|
}
|
|
|
|
if resp.Amount != 2500 {
|
|
t.Errorf("expected amount 2500, got %d", resp.Amount)
|
|
}
|
|
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected status completed, got %s", resp.Status)
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 deposit payment, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_FullPayment(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:full-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "full-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.PaymentType != "full" {
|
|
t.Errorf("expected payment type full, got %s", resp.PaymentType)
|
|
}
|
|
|
|
if resp.Amount != 5000 {
|
|
t.Errorf("expected amount 5000, got %d", resp.Amount)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_PartialPayment(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:partial-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 1500,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "partial-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.PaymentType != "partial" {
|
|
t.Errorf("expected payment type partial, got %s", resp.PaymentType)
|
|
}
|
|
|
|
if resp.Amount != 1500 {
|
|
t.Errorf("expected amount 1500, got %d", resp.Amount)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_BalancePayment(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:balance-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 3500,
|
|
PaymentType: "balance",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "balance-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.PaymentType != "balance" {
|
|
t.Errorf("expected payment type balance, got %s", resp.PaymentType)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Payment-split tests — verify that a single Square charge is recorded as
|
|
// multiple payment rows when paid before the booking start time, and that
|
|
// both records share the same square_payment_id.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) {
|
|
// A full payment of £50 on a £50 booking (future-dated) should be split:
|
|
// record 1: payment_type='deposit', amount=25.00
|
|
// record 2: payment_type='balance', amount=25.00
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:split-full-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "split-full-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Should be exactly 2 payment records.
|
|
rows, err := tx.Query(ctx,
|
|
`SELECT payment_type, amount, square_payment_id
|
|
FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payments: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []struct {
|
|
ptype string
|
|
amount float64
|
|
squarePaymentID *string
|
|
}
|
|
for rows.Next() {
|
|
var r struct {
|
|
ptype string
|
|
amount float64
|
|
squarePaymentID *string
|
|
}
|
|
if err := rows.Scan(&r.ptype, &r.amount, &r.squarePaymentID); err != nil {
|
|
t.Fatalf("failed to scan row: %v", err)
|
|
}
|
|
records = append(records, r)
|
|
}
|
|
|
|
if len(records) != 2 {
|
|
t.Fatalf("expected 2 split records, got %d", len(records))
|
|
}
|
|
|
|
// First record should be the deposit portion (larger or equal — deposit is 25, balance is 25).
|
|
if records[0].ptype != "deposit" {
|
|
t.Errorf("expected first record to be 'deposit', got %q", records[0].ptype)
|
|
}
|
|
// Second record should be balance.
|
|
if records[1].ptype != "balance" {
|
|
t.Errorf("expected second record to be 'balance', got %q", records[1].ptype)
|
|
}
|
|
|
|
// Both records must share the same square_payment_id.
|
|
if records[0].squarePaymentID == nil || records[1].squarePaymentID == nil {
|
|
t.Error("both records should have a square_payment_id")
|
|
} else if *records[0].squarePaymentID != *records[1].squarePaymentID {
|
|
t.Errorf("expected same square_payment_id, got %q and %q",
|
|
*records[0].squarePaymentID, *records[1].squarePaymentID)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) {
|
|
// A full payment on a PAST booking should NOT split (single record).
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBookingPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:nosplit-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "nosplit-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 payment (no-split), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) {
|
|
// Verify that when the split-record insert fails, the entire group rolls
|
|
// back atomically. We simulate a failure by causing the second INSERT to
|
|
// violate a NOT NULL constraint (passing an invalid record).
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID := setupDepositBooking(t, ctx, tx)
|
|
|
|
// Use a nil idempotency key on the split record — this works fine for both.
|
|
// Instead we rely on the fact that the handler wraps both inserts in a
|
|
// single transaction: if either fails, neither survives.
|
|
//
|
|
// Because we can't easily inject a DB error through the handler, we verify
|
|
// the architecture at the service level instead:
|
|
|
|
innerTx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to begin tx: %v", err)
|
|
}
|
|
defer innerTx.Rollback(ctx)
|
|
|
|
svc := NewPaymentService()
|
|
|
|
now := clock.Now()
|
|
// First record — valid.
|
|
pid1, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "deposit",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: 25.00,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create first payment record: %v", err)
|
|
}
|
|
if pid1 == "" {
|
|
t.Fatal("expected non-empty payment id")
|
|
}
|
|
|
|
// Second record — also valid.
|
|
pid2, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "balance",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: 25.00,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create second payment record: %v", err)
|
|
}
|
|
if pid2 == "" {
|
|
t.Fatal("expected non-empty payment id")
|
|
}
|
|
|
|
if err := innerTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit tx: %v", err)
|
|
}
|
|
|
|
// Both records should exist.
|
|
var count int
|
|
tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count)
|
|
if count != 2 {
|
|
t.Errorf("expected 2 committed records, got %d", count)
|
|
}
|
|
|
|
// Now test rollback: start a new inner tx, insert, then rollback.
|
|
tx2, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to begin tx2: %v", err)
|
|
}
|
|
|
|
pid3, err := svc.CreatePaymentRecordTx(ctx, tx2, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "deposit",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: 10.00,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create rolled-back record: %v", err)
|
|
}
|
|
|
|
tx2.Rollback(ctx)
|
|
|
|
// Rolled-back record should NOT exist.
|
|
var rollbackCount int
|
|
tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount)
|
|
if rollbackCount != 0 {
|
|
t.Errorf("expected 0 records after rollback, got %d", rollbackCount)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildSplitRecords unit tests — pure function, no DB needed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func makeTestRecord(bookingID, ptype string, amount float64) PaymentRecord {
|
|
now := clock.Now()
|
|
key := "test-key"
|
|
return PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: ptype,
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: amount,
|
|
SquarePaymentID: strPtr("sq_test"),
|
|
IdempotencyKey: &key,
|
|
Fees: 1.50,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
func TestBuildSplitRecords_FutureBooking_FullPayment_Splits(t *testing.T) {
|
|
// £50 payment on a £50 future booking → splits into deposit £25 + balance £25
|
|
record := makeTestRecord("b1", "full", 50)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 50,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "full", info, 50)
|
|
|
|
if len(records) != 2 {
|
|
t.Fatalf("expected 2 records, got %d", len(records))
|
|
}
|
|
if records[0].PaymentType != "deposit" {
|
|
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
|
|
}
|
|
if records[0].Amount != 25 {
|
|
t.Errorf("expected first record amount 25, got %.2f", records[0].Amount)
|
|
}
|
|
if records[1].PaymentType != "balance" {
|
|
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
|
|
}
|
|
if records[1].Amount != 25 {
|
|
t.Errorf("expected second record amount 25, got %.2f", records[1].Amount)
|
|
}
|
|
// Both share the same SquarePaymentID.
|
|
if *records[0].SquarePaymentID != *records[1].SquarePaymentID {
|
|
t.Error("split records must share square_payment_id")
|
|
}
|
|
// Split record has separate idempotency key.
|
|
if *records[1].IdempotencyKey != *records[0].IdempotencyKey+"-split-1" {
|
|
t.Errorf("split key should be derived, got %q", *records[1].IdempotencyKey)
|
|
}
|
|
// Split record has zero fees (all on primary).
|
|
if records[1].Fees != 0 {
|
|
t.Errorf("expected split fees=0, got %.2f", records[1].Fees)
|
|
}
|
|
}
|
|
|
|
func TestBuildSplitRecords_PastBooking_NoSplit(t *testing.T) {
|
|
// Same amount on a PAST booking → single record
|
|
record := makeTestRecord("b2", "full", 50)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(-2 * time.Hour),
|
|
TotalAmount: 50,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "full", info, 50)
|
|
|
|
if len(records) != 1 {
|
|
t.Fatalf("expected 1 record (no split), got %d", len(records))
|
|
}
|
|
if records[0].PaymentType != "full" {
|
|
t.Errorf("expected 'full', got %q", records[0].PaymentType)
|
|
}
|
|
}
|
|
|
|
func TestBuildSplitRecords_DepositWithinCap_NoSplit(t *testing.T) {
|
|
// £20 deposit on a £50 total (40% < 50% cap) → single deposit record
|
|
record := makeTestRecord("b3", "deposit", 20)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 50,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "deposit", info, 20)
|
|
|
|
if len(records) != 1 {
|
|
t.Fatalf("expected 1 record (within cap), got %d", len(records))
|
|
}
|
|
if records[0].PaymentType != "deposit" {
|
|
t.Errorf("expected 'deposit', got %q", records[0].PaymentType)
|
|
}
|
|
}
|
|
|
|
func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) {
|
|
// £25 on a £100 total (25% < 50% cap) → single deposit record
|
|
record := makeTestRecord("b4", "deposit", 25)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 100,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "deposit", info, 25)
|
|
|
|
if len(records) != 1 {
|
|
t.Fatalf("expected 1 record (under 50%%), got %d", len(records))
|
|
}
|
|
}
|
|
|
|
func TestBuildSplitRecords_OverflowBeyondTotal_BecomesTip(t *testing.T) {
|
|
// £60 on a £50 future booking: deposit caps at £25 (50%), balance covers
|
|
// the remaining £25 owed, and the £10 overflow beyond the booking total
|
|
// becomes a tip record.
|
|
record := makeTestRecord("b5", "full", 60)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 50,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "full", info, 60)
|
|
|
|
if len(records) != 3 {
|
|
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
|
|
}
|
|
if records[0].PaymentType != "deposit" {
|
|
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
|
|
}
|
|
if records[0].Amount != 25 {
|
|
t.Errorf("expected deposit amount 25, got %.2f", records[0].Amount)
|
|
}
|
|
if records[1].PaymentType != "balance" {
|
|
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
|
|
}
|
|
if records[1].Amount != 25 {
|
|
t.Errorf("expected balance amount 25, got %.2f", records[1].Amount)
|
|
}
|
|
if records[2].PaymentType != "tip" {
|
|
t.Errorf("expected third record 'tip', got %q", records[2].PaymentType)
|
|
}
|
|
if records[2].Amount != 10 {
|
|
t.Errorf("expected tip amount 10 (overflow beyond the booking total), got %.2f", records[2].Amount)
|
|
}
|
|
// The tip is a split: zero fees and a derived idempotency key.
|
|
if records[2].Fees != 0 {
|
|
t.Errorf("expected tip fees=0, got %.2f", records[2].Fees)
|
|
}
|
|
if *records[2].IdempotencyKey != *records[0].IdempotencyKey+"-split-3" {
|
|
t.Errorf("expected tip key derived from primary, got %q", *records[2].IdempotencyKey)
|
|
}
|
|
// The splits share the Square payment id of the primary record.
|
|
if *records[2].SquarePaymentID != *records[0].SquarePaymentID {
|
|
t.Error("tip split must share square_payment_id with the primary record")
|
|
}
|
|
}
|
|
|
|
// TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge
|
|
// proves the money invariant documented on buildSplitRecords: the split
|
|
// records (deposit + balance + tip) ALWAYS partition the charged amount
|
|
// exactly, so their sum can never exceed what Square actually charged — even
|
|
// when a discount is present. A discount reduces what the customer owes but is
|
|
// booked as a SEPARATE ledger row (payment_method='discount') that
|
|
// GetBookingPaymentInfo excludes from TotalPaid, so buildSplitRecords runs
|
|
// against the full booking total and never inflates the split sum past the
|
|
// charged pence.
|
|
func TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge(t *testing.T) {
|
|
// Discounted booking: £100 total with a £10 discount (the discount row
|
|
// exists in the ledger but is excluded from TotalPaid). Tip overflow
|
|
// charges over and above the booking total.
|
|
record := makeTestRecord("b-disc-tip", "full", 120)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 100,
|
|
TotalPaid: 0, // the £10 discount payment row is excluded here
|
|
}
|
|
records := buildSplitRecords(record, "full", info, 120)
|
|
|
|
var sum float64
|
|
for _, r := range records {
|
|
sum += r.Amount
|
|
}
|
|
// Partition invariant: the sum equals the charged amount exactly — it can
|
|
// never exceed it, no matter how the tip overflows.
|
|
if sum > 120 {
|
|
t.Errorf("split records sum to %.2f, exceeding the charged amount £120", sum)
|
|
}
|
|
if math.Abs(sum-120) > 0.005 {
|
|
t.Errorf("expected split records to partition the charged £120 exactly, got sum %.2f (records: %+v)", sum, records)
|
|
}
|
|
|
|
// Same invariant with real money already paid toward the booking: the
|
|
// deposit room is consumed, but the sum still partitions the new charge.
|
|
record2 := makeTestRecord("b-disc-tip-2", "full", 70)
|
|
info2 := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 100,
|
|
TotalPaid: 60,
|
|
}
|
|
records2 := buildSplitRecords(record2, "full", info2, 70)
|
|
var sum2 float64
|
|
for _, r := range records2 {
|
|
sum2 += r.Amount
|
|
}
|
|
if sum2 > 70 {
|
|
t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum2)
|
|
}
|
|
if math.Abs(sum2-70) > 0.005 {
|
|
t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f (records: %+v)", sum2, records2)
|
|
}
|
|
}
|
|
|
|
// TestGetBookingPaymentInfo_ExcludesDiscountPayments proves the database half
|
|
// of the invariant: GetBookingPaymentInfo.TotalPaid excludes discount payment
|
|
// rows, so a discounted booking never inflates TotalPaid (which would shrink
|
|
// the tip carve-out / grow the balance allocation into an over-recorded total).
|
|
func TestGetBookingPaymentInfo_ExcludesDiscountPayments(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
require.NoError(t, err)
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
require.NoError(t, err)
|
|
|
|
// £60 of real money plus a £10 discount payment row (payment_method='discount').
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 60.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', 10.00, 'completed', $2)
|
|
`, bookingID, userID)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewPaymentService()
|
|
info, err := svc.GetBookingPaymentInfo(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
|
|
if info.TotalPaid != 60.00 {
|
|
t.Errorf("expected TotalPaid to exclude the £10 discount row, got %.2f", info.TotalPaid)
|
|
}
|
|
|
|
// A £70 tip-overflow charge on top must partition exactly into £70 of split
|
|
// records — the discount can never push the recorded sum past the charge.
|
|
record := makeTestRecord(bookingID, "full", 70)
|
|
records := buildSplitRecords(record, "full", info, 70)
|
|
var sum float64
|
|
for _, r := range records {
|
|
sum += r.Amount
|
|
}
|
|
if sum > 70 {
|
|
t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum)
|
|
}
|
|
if math.Abs(sum-70) > 0.005 {
|
|
t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f", sum)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler-level atomicity — verify the full handler succeeds with split.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:atomic-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "atomic-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp PaymentResponse
|
|
if err := parsePaymentResponseBody(w, &resp); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if resp.ID == "" {
|
|
t.Fatal("expected non-empty payment ID")
|
|
}
|
|
if resp.Amount != 5000 {
|
|
t.Errorf("expected amount 5000, got %d", resp.Amount)
|
|
}
|
|
if resp.PaymentType != "full" {
|
|
t.Errorf("expected payment type 'full' in response, got %q", resp.PaymentType)
|
|
}
|
|
|
|
// Verify both split records exist and the total paid is correct.
|
|
var recordCount int
|
|
tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount)
|
|
if recordCount != 2 {
|
|
t.Errorf("expected 2 completed payment records from split, got %d", recordCount)
|
|
}
|
|
|
|
var totalPaid float64
|
|
tx.QueryRow(ctx,
|
|
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid)
|
|
if totalPaid != 50.00 {
|
|
t.Errorf("expected total paid £50.00, got £%.2f", totalPaid)
|
|
}
|
|
|
|
// Deposit threshold should have been met — verify booking promoted from pending_release.
|
|
var status string
|
|
tx.QueryRow(ctx,
|
|
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
|
if status == "pending_release" {
|
|
t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold")
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_ZeroAmountRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:zero-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 0,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "zero-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_NegativeAmountRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:neg-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: -100,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "neg-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:invalid-type-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "invalid_type",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "invalid-type-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_NoAuthRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID := setupDepositBooking(t, ctx, tx)
|
|
|
|
cardToken := "cnon:no-auth-card"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "no-auth-test-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "", ctx)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID := setupDepositBooking(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:deposit-balance-card"
|
|
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "deposit-balance-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
|
|
if w1.Code != http.StatusOK {
|
|
t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "balance",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "deposit-balance-2",
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 payments, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestBookingPayment_PartialFollowedByBalance(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
// Past booking to avoid payment-split; we're testing sequence not deposit allocation.
|
|
userID, bookingID := setupDepositBookingPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cardToken := "cnon:partial-balance-card"
|
|
|
|
req1 := CreateBookingPaymentRequest{
|
|
Amount: 1000,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "partial-balance-1",
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
|
|
|
|
if w1.Code != http.StatusOK {
|
|
t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
req2 := CreateBookingPaymentRequest{
|
|
Amount: 4000,
|
|
PaymentType: "balance",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "partial-balance-2",
|
|
}
|
|
|
|
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query payments: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 payments, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestTipPayment_WrongOwnerRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
|
|
otherToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
cardToken := "cnon:wrong-owner-tip"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken, ctx)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTipPayment_MultipleTipsAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
cardToken := fmt.Sprintf("cnon:multi-tip-%d", i)
|
|
req := CreateTipPaymentRequest{
|
|
Amount: int64(200 + i*100),
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
var count int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query tip payments: %v", err)
|
|
}
|
|
if count != 3 {
|
|
t.Errorf("expected 3 tip payments, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestTipPayment_WithSavedCard(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
// Create a completed payment so the tip is allowed.
|
|
payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
squarePayID := "sqp_test_saved_card"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
|
|
require.NoError(t, err)
|
|
|
|
// Create a saved card for this user.
|
|
var savedCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
|
VALUES ($1, 'ccof:mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved')
|
|
RETURNING id
|
|
`, userID).Scan(&savedCardID)
|
|
require.NoError(t, err)
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 1000,
|
|
CardID: &savedCardID,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
var resp PaymentResponse
|
|
require.NoError(t, parsePaymentResponseBody(w, &resp))
|
|
assert.Equal(t, "tip", resp.PaymentType)
|
|
assert.Equal(t, int64(1000), resp.Amount)
|
|
assert.Equal(t, "completed", resp.Status)
|
|
assert.NotEmpty(t, resp.CardBrand)
|
|
assert.NotEmpty(t, resp.CardLast4)
|
|
}
|
|
|
|
func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
// Create a completed payment so the tip is allowed.
|
|
payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
squarePayID := "sqp_test_retry"
|
|
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
|
|
require.NoError(t, err)
|
|
|
|
// Create a saved card for this user.
|
|
var savedCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
|
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
|
|
RETURNING id
|
|
`, userID).Scan(&savedCardID)
|
|
require.NoError(t, err)
|
|
|
|
// Simulate a failed prior attempt: a PENDING tip record with the same
|
|
// idempotency key the frontend will send on retry. The handler must NOT
|
|
// short-circuit on this — it must re-attempt the Square charge.
|
|
key := "tip-retry-key-123"
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
|
|
VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3)
|
|
`, bookingID, key, userID)
|
|
require.NoError(t, err)
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 1000,
|
|
CardID: &savedCardID,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
var resp PaymentResponse
|
|
require.NoError(t, parsePaymentResponseBody(w, &resp))
|
|
assert.Equal(t, "completed", resp.Status, "retry of a pending record must re-attempt and complete, not return the stale pending record")
|
|
|
|
// Exactly one payment record for this key, now completed.
|
|
var count int
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate")
|
|
assert.Equal(t, "completed", status)
|
|
}
|
|
|
|
// TestTipPayment_RetryPending_NonExactAmountSucceeds verifies the pending-retry
|
|
// amount guard compares pence exactly. £1.14 is stored as the float64
|
|
// 1.1399999999999999, so a naive int64(pounds*100) truncation yields 113 and
|
|
// falsely rejects a legitimate same-amount retry of 114 pence.
|
|
func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
// Create a completed payment so the tip is allowed.
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
// Create a saved card for this user.
|
|
var savedCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
|
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
|
|
RETURNING id
|
|
`, userID).Scan(&savedCardID)
|
|
require.NoError(t, err)
|
|
|
|
// Failed prior attempt: a PENDING tip of £1.14 (114 pence) with the same key.
|
|
key := "tip-retry-key-114"
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
|
|
VALUES ($1, 'tip', 'online_square', 'pending', 1.14, $2, NOW(), NOW(), $3)
|
|
`, bookingID, key, userID)
|
|
require.NoError(t, err)
|
|
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 114,
|
|
CardID: &savedCardID,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
|
|
|
|
var resp PaymentResponse
|
|
require.NoError(t, parsePaymentResponseBody(w, &resp))
|
|
assert.Equal(t, "completed", resp.Status, "a same-amount retry of a non-exact pence value must re-attempt and complete")
|
|
assert.Equal(t, int64(114), resp.Amount)
|
|
|
|
// Exactly one payment record for this key, now completed.
|
|
var count int
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate")
|
|
assert.Equal(t, "completed", status)
|
|
}
|
|
|
|
// TestTipPayment_RetryPending_AmountMismatchRejected verifies the amount guard
|
|
// still rejects a retry that changes the amount on the same idempotency key.
|
|
func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
var savedCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
|
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
|
|
RETURNING id
|
|
`, userID).Scan(&savedCardID)
|
|
require.NoError(t, err)
|
|
|
|
// Failed prior attempt: PENDING tip of £10.00 (1000 pence).
|
|
key := "tip-retry-key-mismatch"
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
|
|
VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3)
|
|
`, bookingID, key, userID)
|
|
require.NoError(t, err)
|
|
|
|
// Retry with a DIFFERENT amount but the same key → must 400, not charge.
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 2000,
|
|
CardID: &savedCardID,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "a same-key retry with a different amount must be rejected")
|
|
|
|
// The pending record must be untouched.
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM payments WHERE idempotency_key = $1`, key).Scan(&status)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "pending", status)
|
|
}
|
|
|
|
func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
cancelCtx, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
cardToken := "cnon:tip-card"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, cancelCtx)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var completedTipCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'", bookingID).Scan(&completedTipCount)
|
|
if err != nil {
|
|
t.Errorf("failed to query completed tip payments: %v", err)
|
|
}
|
|
if completedTipCount != 0 {
|
|
t.Errorf("expected 0 completed tip payments, got %d", completedTipCount)
|
|
}
|
|
}
|
|
|
|
// TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow verifies the H2
|
|
// fix: a no-client-key tip whose Square call failed ambiguously leaves a
|
|
// pending row; a retry (still no client key) must derive the SAME deterministic
|
|
// key from the booking+amount+card+sequence and reuse that pending row — never
|
|
// mint a fresh random key, a fresh pending row, and a second Square charge.
|
|
func TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow(t *testing.T) {
|
|
// NOT t.Parallel: it swaps the package-global SquareClient (a ShouldFail
|
|
// mock) mid-test, and a concurrent parallel charge test would observe the
|
|
// swapped client and fail with a spurious 503.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
mc := square.NewDevClient().(*square.MockClient)
|
|
mc.ShouldFail = true
|
|
SquareClient = mc
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:no-key-tip-retry"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 1000,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusServiceUnavailable, w.Code, "first attempt must fail ambiguously: %s", w.Body.String())
|
|
|
|
// The pending row must exist with a deterministic key.
|
|
var pendingKey string
|
|
var pendingCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT idempotency_key, COUNT(*) OVER() FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&pendingKey, &pendingCount))
|
|
require.Equal(t, 1, pendingCount)
|
|
require.NotEmpty(t, pendingKey)
|
|
require.Contains(t, pendingKey, bookingID, "the no-client-key fallback must derive a deterministic key from the booking, not a random one")
|
|
|
|
// Retry with Square healthy: no client key, same amount/card.
|
|
mc.ShouldFail = false
|
|
w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "retry must succeed: %s", w.Body.String())
|
|
|
|
// Exactly ONE tip payment row, completed, with the SAME key.
|
|
var count int
|
|
var status string
|
|
var storedKey string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&count, &status, &storedKey))
|
|
require.Equal(t, 1, count, "a no-client-key retry must reuse the pending row, not insert a duplicate")
|
|
require.Equal(t, "completed", status)
|
|
require.Equal(t, pendingKey, storedKey, "the retry must derive the same deterministic key")
|
|
}
|
|
|
|
// TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse verifies the
|
|
// H2 fix's other half: two genuinely distinct identical no-client-key tips on
|
|
// the same booking get n=1 and n=2, so they never collapse onto one
|
|
// idempotency key (which would silently swallow the second tip).
|
|
func TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
handler := CreateTipPayment
|
|
cardToken := "cnon:no-key-tip-dup"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 1000,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, "tip %d: %s", i, w.Body.String())
|
|
}
|
|
|
|
var keys []string
|
|
rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND payment_type = 'tip' ORDER BY created_at`, bookingID)
|
|
require.NoError(t, err)
|
|
for rows.Next() {
|
|
var k string
|
|
require.NoError(t, rows.Scan(&k))
|
|
keys = append(keys, k)
|
|
}
|
|
rows.Close()
|
|
require.Equal(t, 2, len(keys), "two distinct identical tips must both be recorded")
|
|
require.NotEqual(t, keys[0], keys[1], "distinct identical tips must not collapse onto one idempotency key")
|
|
}
|
|
|
|
func TestGetUserPaymentMethods_NoCards(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
handler := GetUserPaymentMethods
|
|
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cards []SavedCard
|
|
if err := parsePaymentResponseBody(w, &cards); err != nil {
|
|
t.Errorf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if len(cards) != 0 {
|
|
t.Errorf("expected 0 cards, got %d", len(cards))
|
|
}
|
|
}
|
|
|
|
func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_wrong_owner", "VISA", "0000")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method: %v", err)
|
|
}
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
|
|
otherToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
handler := DeletePaymentMethod
|
|
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("failed to query card: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Error("expected card to still exist (not deleted by wrong owner)")
|
|
}
|
|
}
|
|
|
|
func TestValidatePartialAmount(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
amountCents int64
|
|
remainingCents int64
|
|
expectErr bool
|
|
}{
|
|
{"valid partial", 500, 1000, false},
|
|
{"exact remaining", 1000, 1000, false},
|
|
{"exceeds remaining", 1500, 1000, true},
|
|
{"zero amount", 0, 1000, true},
|
|
{"negative amount", -100, 1000, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidatePartialAmount(tt.amountCents, tt.remainingCents)
|
|
if tt.expectErr && err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !tt.expectErr && err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
service := NewPaymentService()
|
|
|
|
initialRemaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if initialRemaining <= 0 {
|
|
t.Fatalf("expected positive remaining balance, got %d", initialRemaining)
|
|
}
|
|
|
|
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "partial",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: 20.00,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
afterPartial, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if afterPartial != initialRemaining-2000 {
|
|
t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
|
|
}
|
|
|
|
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "balance",
|
|
PaymentMethod: "cash",
|
|
Status: "completed",
|
|
Amount: float64(afterPartial) / 100.0,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
afterFull, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if afterFull != 0 {
|
|
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
|
|
}
|
|
}
|
|
|
|
// TestGetBookingRemainingBalanceCents_RefundsReopenCapacity verifies the M-cap
|
|
// is refund-aware: a completed refund returns money, so it re-opens booking
|
|
// capacity — remaining = total - paid + refunded — while the cap never exceeds
|
|
// the booking total.
|
|
func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
require.NoError(t, err)
|
|
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
|
require.NoError(t, err)
|
|
|
|
var bookingTotal int64
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
|
|
|
|
service := NewPaymentService()
|
|
|
|
// Pay the full booking amount.
|
|
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
|
|
BookingID: bookingID,
|
|
PaymentType: "full",
|
|
PaymentMethod: "online_square",
|
|
Status: "completed",
|
|
Amount: float64(bookingTotal) / 100.0,
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
|
|
remaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
|
|
|
|
// Refund half the booking value — capacity must re-open by that amount.
|
|
var payRowID string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID))
|
|
|
|
refundAmount := bookingTotal / 2
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
|
|
VALUES ($1, $2, $3, 'completed', 'test refund', 'manual')
|
|
`, payRowID, bookingID, float64(refundAmount)/100.0)
|
|
require.NoError(t, err)
|
|
|
|
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount")
|
|
|
|
// The cap must never exceed the booking total even if refunds exceed payments.
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
|
|
VALUES ($1, $2, $3, 'completed', 'over-refund test', 'manual')
|
|
`, payRowID, bookingID, bookingTotal)
|
|
require.NoError(t, err)
|
|
|
|
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total")
|
|
}
|
|
|
|
func TestCreatePaymentMethod_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
handler := CreatePaymentMethod
|
|
reqBody := CreatePaymentMethodRequest{
|
|
CardToken: "cnon:visa",
|
|
}
|
|
|
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
return
|
|
}
|
|
|
|
var card SavedCard
|
|
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if card.Brand != "VISA" {
|
|
t.Errorf("expected brand VISA, got %s", card.Brand)
|
|
}
|
|
if card.Last4 != "1111" {
|
|
t.Errorf("expected last4 1111, got %s", card.Last4)
|
|
}
|
|
if !card.IsDefault {
|
|
t.Error("expected first card to be default")
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentMethod_RawPANRejected(t *testing.T) {
|
|
// PCI-DSS: raw PANs are never accepted at the API edge — the handler must
|
|
// return 400 for a card_number body, since the field no longer exists and
|
|
// card_token is required. Validates that a raw PAN never reaches the
|
|
// Square client.
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Raw PAN sent as the old field name — should be ignored and rejected.
|
|
handler := CreatePaymentMethod
|
|
reqBody := map[string]string{
|
|
"card_number": "4111111111111111",
|
|
"expiry": "12/30",
|
|
"cvc": "123",
|
|
}
|
|
|
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 (card_token required), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body CreatePaymentMethodRequest
|
|
}{
|
|
{"no card token", CreatePaymentMethodRequest{}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
handler := CreatePaymentMethod
|
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
handler := CreatePaymentMethod
|
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{
|
|
CardToken: "cnon:visa",
|
|
}, "", ctx)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
// Create first card
|
|
handler := CreatePaymentMethod
|
|
reqBody := CreatePaymentMethodRequest{
|
|
CardToken: "cnon:visa",
|
|
}
|
|
|
|
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
reqBody2 := CreatePaymentMethodRequest{
|
|
CardToken: "cnon:mastercard",
|
|
}
|
|
|
|
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var card SavedCard
|
|
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
|
|
if card.Brand != "MASTERCARD" {
|
|
t.Errorf("expected brand MASTERCARD, got %s", card.Brand)
|
|
}
|
|
if card.IsDefault {
|
|
t.Error("expected second card to NOT be default")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetCheckoutStatus — GET /api/checkout/{checkout_id}/status?booking_id=...
|
|
// =============================================================================
|
|
|
|
// GetCheckoutStatus cannot be fully tested with the dev Square mock because
|
|
// the mock generates checkout IDs (like "chk_mock_...") that don't pass the
|
|
// 12-char hex validation. These tests cover the validation guard paths.
|
|
|
|
func TestGetCheckoutStatus_MissingCheckoutID(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/checkout//status?booking_id=abc", nil)
|
|
rctx := chi.NewRouteContext()
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
req = adminRequestCtx(req)
|
|
|
|
w := httptest.NewRecorder()
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetCheckoutStatus_InvalidCheckoutID(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
// Must fail the Square-compatible checkout-ID check — the old 12-hex gate
|
|
// rejected real Square IDs (UUIDs like "08YceKh7B3ZqO"), so an injection
|
|
// attempt (path traversal) is the correct invalid case now.
|
|
badID := "../etc/passwd"
|
|
req := httptest.NewRequest("GET", "/api/checkout/"+badID+"/status?booking_id=abc", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", badID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetCheckoutStatus_RealSquareID_PassesValidation(t *testing.T) {
|
|
// A real Square checkout ID (13-char UUID, not 12-hex) must pass the
|
|
// checkout-ID gate — the C-4 fix. It then 404s at the Square client level
|
|
// (mock has no such checkout), proving the gate no longer rejects it.
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
realID := "08YceKh7B3ZqO"
|
|
req := httptest.NewRequest("GET", "/api/checkout/"+realID+"/status?booking_id=abc", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", realID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
GetCheckoutStatus(w, req)
|
|
|
|
// Not 404-from-validation: the gate accepted it. The mock returns 500 for
|
|
// an unknown checkout (it panics on a missing ID), so assert NOT a 404
|
|
// from the gate — any non-404 is proof the gate passed.
|
|
if w.Code == http.StatusNotFound {
|
|
t.Errorf("real Square checkout ID %q was rejected by the validation gate — expected it to pass validation", realID)
|
|
}
|
|
}
|
|
|
|
func TestGetCheckoutStatus_ValidCheckoutNotFound(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
svcID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/checkout/aaaaaaaaaaaa/status?booking_id="+bookingID, nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// AdminGetUserPaymentMethods — GET /api/admin/users/{id}/payment-methods
|
|
// =============================================================================
|
|
|
|
func TestAdminGetUserPaymentMethods_Success(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_admin_test", "VISA", "4321")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment method: %v", err)
|
|
}
|
|
_ = cardID
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", userID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
AdminGetUserPaymentMethods(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cards []SavedCard
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(cards) != 1 {
|
|
t.Errorf("expected 1 card, got %d", len(cards))
|
|
}
|
|
}
|
|
|
|
func TestAdminGetUserPaymentMethods_InvalidUserID(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/users/$$$/payment-methods", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "$$$")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
AdminGetUserPaymentMethods(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminGetUserPaymentMethods_NoCards(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", userID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
AdminGetUserPaymentMethods(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var cards []SavedCard
|
|
if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(cards) != 0 {
|
|
t.Errorf("expected 0 cards, got %d", len(cards))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetBookingPaymentSummary — GET /api/bookings/{id}/payment-summary
|
|
// =============================================================================
|
|
|
|
func TestGetBookingPaymentSummary_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var summary PaymentSummaryResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &summary); err != nil {
|
|
t.Fatalf("failed to parse response: %v", err)
|
|
}
|
|
if len(summary.Payments) != 1 {
|
|
t.Errorf("expected 1 payment, got %d", len(summary.Payments))
|
|
}
|
|
if summary.Payments[0].Amount != 5000 {
|
|
t.Errorf("expected amount 5000, got %d", summary.Payments[0].Amount)
|
|
}
|
|
}
|
|
|
|
func TestGetBookingPaymentSummary_NotFound(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/aaaaaaaaaaaa/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "some-user")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetBookingPaymentSummary_Unauthorized(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, otherUserID)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
|
|
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
|
|
req = req.WithContext(reqCtx)
|
|
_ = userID
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetBookingPaymentSummary_AdminAccess(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
|
|
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: GetPaymentByID
|
|
// =============================================================================
|
|
|
|
func TestGetPaymentByID_NotFound(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
_, err := svc.GetPaymentByID(ctx, "000000000001")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent payment ID")
|
|
}
|
|
}
|
|
|
|
func TestGetPaymentByID_Found(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
svc := NewPaymentService()
|
|
record, err := svc.GetPaymentByID(ctx, paymentID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if record == nil || record.ID != paymentID {
|
|
t.Errorf("expected payment ID %s, got %v", paymentID, record)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: GetBookingStatus
|
|
// =============================================================================
|
|
|
|
func TestGetBookingStatus_NotFound(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
_, err := svc.GetBookingStatus(ctx, "000000000001")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent booking ID")
|
|
}
|
|
}
|
|
|
|
func TestGetBookingStatus_Found(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
svc := NewPaymentService()
|
|
status, err := svc.GetBookingStatus(ctx, bookingID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if status == "" {
|
|
t.Error("expected non-empty status")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: GetBookingPaymentInfo
|
|
// =============================================================================
|
|
|
|
func TestGetBookingPaymentInfo_NotFound(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
_, err := svc.GetBookingPaymentInfo(ctx, "000000000001")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent booking ID")
|
|
}
|
|
}
|
|
|
|
func TestGetBookingPaymentInfo_Found(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
svc := NewPaymentService()
|
|
info, err := svc.GetBookingPaymentInfo(ctx, bookingID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if info == nil {
|
|
t.Fatal("expected non-nil info")
|
|
}
|
|
if info.TotalAmount <= 0 {
|
|
t.Errorf("expected positive total amount, got %.2f", info.TotalAmount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: GetBookingRemainingBalanceCents
|
|
// =============================================================================
|
|
|
|
func TestGetBookingRemainingBalanceCents_NotFound(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
_, err := svc.GetBookingRemainingBalanceCents(ctx, "000000000001")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent booking ID")
|
|
}
|
|
}
|
|
|
|
func TestGetBookingRemainingBalanceCents_FullBalance(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
svc := NewPaymentService()
|
|
cents, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if cents <= 0 {
|
|
t.Errorf("expected positive remaining balance for unpaid booking, got %d", cents)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: GetAlreadyRefundedAmount
|
|
// =============================================================================
|
|
|
|
func TestGetAlreadyRefundedAmount_NoRefunds(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
svc := NewPaymentService()
|
|
amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if amount != 0 {
|
|
t.Errorf("expected 0 refunded amount, got %d", amount)
|
|
}
|
|
}
|
|
|
|
func TestGetAlreadyRefundedAmount_WithRefund(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
_, err = fixtures.CreateTestRefund(tx, paymentID, bookingID, 20.00)
|
|
if err != nil {
|
|
t.Fatalf("failed to create refund: %v", err)
|
|
}
|
|
svc := NewPaymentService()
|
|
amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if amount != 2000 {
|
|
t.Errorf("expected 2000 (2000p = £20.00), got %d", amount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: HasCompletedPayment
|
|
// =============================================================================
|
|
|
|
func TestHasCompletedPayment_NoPayments(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
svc := NewPaymentService()
|
|
hasPayments, err := svc.HasCompletedPayment(ctx, bookingID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if hasPayments {
|
|
t.Error("expected false for booking with no completed payments")
|
|
}
|
|
}
|
|
|
|
func TestHasCompletedPayment_HasPayment(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
_, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
svc := NewPaymentService()
|
|
hasPayments, err := svc.HasCompletedPayment(ctx, bookingID)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if !hasPayments {
|
|
t.Error("expected true for booking with completed payment")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: SaveCardForUser
|
|
// =============================================================================
|
|
|
|
func TestSaveCardForUser_Success(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
svc := NewPaymentService()
|
|
cardID, err := svc.SaveCardForUser(ctx, userID, "cus_test_success", "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if cardID == "" {
|
|
t.Error("expected non-empty card ID")
|
|
}
|
|
}
|
|
|
|
func TestSaveCardForUser_InvalidUserID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
_, err := svc.SaveCardForUser(ctx, "000000000001", "cus_test_invalid", "cfa_test", "VISA", "4242", 12, 2030, "fp_test")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent user ID (FK violation)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Service layer: CreateRefundRecord
|
|
// =============================================================================
|
|
|
|
func TestCreateRefundRecord_InvalidPaymentID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_ = tx
|
|
svc := NewPaymentService()
|
|
now := clock.Now()
|
|
_, err := svc.CreateRefundRecord(ctx, RefundRecord{
|
|
PaymentID: "000000000001",
|
|
BookingID: "000000000002",
|
|
Amount: 10.00,
|
|
Status: "completed",
|
|
Reason: "test",
|
|
CreatedAt: now,
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for non-existent payment ID (FK violation)")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetCheckoutStatus — GET /api/admin/payments/{checkout_id}/status
|
|
// =============================================================================
|
|
|
|
func TestGetCheckoutStatus_MissingBookingID(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetCheckoutStatus_InvalidBookingID(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=invalid", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
req = adminRequestCtx(req)
|
|
|
|
w := httptest.NewRecorder()
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetCheckoutStatus_BookingNotFound(t *testing.T) {
|
|
_, _ = testutils.SetupTestTx(t)
|
|
|
|
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=bbbbbbbbbbbb", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
req = adminRequestCtx(req)
|
|
GetCheckoutStatus(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateTerminalPayment — Validation gap tests
|
|
// =============================================================================
|
|
|
|
func TestTerminalPayment_NoAuth(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
}, "", ctx)
|
|
|
|
// No auth token → no role → the defense-in-depth isAdminRequest check
|
|
// rejects with 403 before the adminID check (S-1).
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTerminalPayment_InvalidJSON(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
body := bytes.NewReader([]byte(`{invalid}`))
|
|
req := httptest.NewRequest("POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "aaaaaaaaaaaa")
|
|
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
if info := extractUserFromTestJWT(adminToken); info != nil {
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, info.userID)
|
|
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, info.role)
|
|
}
|
|
req = req.WithContext(reqCtx)
|
|
|
|
w := httptest.NewRecorder()
|
|
CreateTerminalPayment(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTerminalPayment_ValidateAmountFails(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 0,
|
|
PaymentType: "full",
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) {
|
|
ctx, _ := testutils.SetupTestTx(t)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "invalid_type",
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
// A fresh pending payment (should NOT be failed).
|
|
freshID, err := fixtures.CreateTestPayment(tx, bookingID, 1000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create fresh pending payment: %v", err)
|
|
}
|
|
// A stale pending payment (25h old — past Square's ~24h key retention).
|
|
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
|
if err != nil {
|
|
t.Fatalf("failed to create stale pending payment: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
|
|
t.Fatalf("failed to age the stale payment: %v", err)
|
|
}
|
|
|
|
_, err = SweepStalePendingPayments(ctx)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
// Stale pending → failed; fresh pending untouched.
|
|
var staleStatus, freshStatus string
|
|
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&staleStatus); err != nil {
|
|
t.Fatalf("failed to query stale payment: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", freshID).Scan(&freshStatus); err != nil {
|
|
t.Fatalf("failed to query fresh payment: %v", err)
|
|
}
|
|
if staleStatus != "failed" {
|
|
t.Errorf("expected stale pending payment to be marked failed, got %q", staleStatus)
|
|
}
|
|
if freshStatus != "pending" {
|
|
t.Errorf("expected fresh pending payment to stay pending, got %q", freshStatus)
|
|
}
|
|
}
|
|
|
|
// TestSavedCardPayment_LostResponseRetry_Dedups verifies the R1 fix: two
|
|
// "Charge Saved Card" requests with identical inputs (booking + type + amount +
|
|
// card) derive the SAME deterministic idempotency key, so a lost-response
|
|
// retry reuses the completed payment instead of charging twice. Before the fix,
|
|
// every request used a fresh random key → the second click double-charged.
|
|
func TestSavedCardPayment_LostResponseRetry_Dedups(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:saved-card-test", "VISA", "4242")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
reqBody := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: strPtr("saved_card"),
|
|
UserSavedCardID: &cardID,
|
|
}
|
|
|
|
// First charge.
|
|
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Same-input retry (lost response) — must dedup, not double-charge.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Exactly ONE payment record for this booking.
|
|
var payCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND amount = 50.00`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if payCount != 1 {
|
|
t.Errorf("expected exactly 1 payment record (dedup), got %d — double-charge!", payCount)
|
|
}
|
|
}
|
|
|
|
// TestSavedCardPayment_SweptFailed_Rejected verifies the R2 fix: after the
|
|
// sweep marks a pending payment failed, a same-key retry is cleanly rejected
|
|
// with 409 instead of 500-ing on the idempotency_key UNIQUE constraint.
|
|
func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:swept-card-test", "VISA", "1111")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
// Seed a failed payment with the deterministic key the handler will derive.
|
|
scKey := bookingID + "-sc-full-5000-" + cardID
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
|
|
VALUES ($1, 'full', 'online_square', 'failed', 50.00, $2, $3, $4, NOW(), NOW())
|
|
`, bookingID, scKey, cardID, adminID); err != nil {
|
|
t.Fatalf("failed to seed failed payment: %v", err)
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
reqBody := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: strPtr("saved_card"),
|
|
UserSavedCardID: &cardID,
|
|
}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 (swept-failed rejection), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No new payment row was inserted.
|
|
var payCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, scKey).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if payCount != 1 {
|
|
t.Errorf("expected exactly 1 (failed) payment row, got %d", payCount)
|
|
}
|
|
}
|
|
|
|
// TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup verifies the Bug 2
|
|
// fix: the frontend sends a per-attempt idempotency UUID, so two DISTINCT
|
|
// identical saved-card charges on the same booking (same amount, same card,
|
|
// same payment type) must NOT collapse on the old deterministic
|
|
// booking+type+amount+card key. Each becomes its own payment.
|
|
func TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:distinct-key-card", "VISA", "4242")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
|
|
// Two legitimately distinct £50 'full' charges on the same booking — the
|
|
// frontend sends a different per-attempt UUID for each.
|
|
for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} {
|
|
reqBody := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: strPtr("saved_card"),
|
|
UserSavedCardID: &cardID,
|
|
IdempotencyKey: key,
|
|
}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("saved-card charge %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Exactly TWO payment records — the second charge must not be swallowed by
|
|
// the dedup branch.
|
|
var payCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if payCount != 2 {
|
|
t.Errorf("expected exactly 2 payment records (no dedup), got %d", payCount)
|
|
}
|
|
}
|
|
|
|
// TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups verifies that a lost-
|
|
// response retry carrying the SAME client UUID still dedups to a single
|
|
// payment — the client key does not disable retry safety.
|
|
func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:same-key-card", "VISA", "4242")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
handler := CreateTerminalPayment
|
|
reqBody := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: strPtr("saved_card"),
|
|
UserSavedCardID: &cardID,
|
|
// Distinct from TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup's
|
|
// keys: both tests share the package-level singleton mock (SquareClient
|
|
// is set once in TestMain), and the mock now mirrors Square's body-aware
|
|
// dedup — reusing a retained key with a different source_id returns
|
|
// IDEMPOTENCY_KEY_REUSED (same as real Square).
|
|
IdempotencyKey: "saved-card-uuid-retry-0001",
|
|
}
|
|
|
|
// First charge.
|
|
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w1.Code != http.StatusOK {
|
|
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
|
|
}
|
|
|
|
// Same-input retry (lost response) — same client UUID → dedup, not a
|
|
// second charge.
|
|
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
// Exactly ONE payment record.
|
|
var payCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if payCount != 1 {
|
|
t.Errorf("expected exactly 1 payment record (same-key dedup), got %d — double-charge!", payCount)
|
|
}
|
|
}
|
|
|
|
// TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/
|
|
// giftcard branch: two identical £50 cash receipts on the same booking are
|
|
// legitimate distinct payments and must each insert their own row (there is
|
|
// deliberately no idempotency dedup in this branch).
|
|
func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
handler := CreateTerminalPayment
|
|
reqBody := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
PaymentType: "full",
|
|
PaymentMethod: strPtr("cash"),
|
|
}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("cash receipt %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
var payCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount); err != nil {
|
|
t.Fatalf("failed to count payments: %v", err)
|
|
}
|
|
if payCount != 2 {
|
|
t.Errorf("expected exactly 2 cash payment rows (no dedup), got %d", payCount)
|
|
}
|
|
}
|
|
|
|
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
|
|
// also marks stale pending till_sales rows (card payments) as failed, so a
|
|
// lost-response till sale can't stay pending past Square's key retention.
|
|
func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
// A stale pending till sale (card payment, 25h old).
|
|
var tillSaleID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW() - INTERVAL '25 hours', NOW())
|
|
RETURNING id
|
|
`, adminID).Scan(&tillSaleID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending till sale: %v", err)
|
|
}
|
|
|
|
// A fresh pending till sale that must NOT be swept.
|
|
var freshSaleID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', 'Gift Card create', 1, 30.00, 30.00, 'online_square', 'pending', $1, NOW(), NOW())
|
|
RETURNING id
|
|
`, adminID).Scan(&freshSaleID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed fresh till sale: %v", err)
|
|
}
|
|
|
|
_, err = SweepStalePendingPayments(ctx)
|
|
if err != nil {
|
|
t.Fatalf("sweep failed: %v", err)
|
|
}
|
|
|
|
var staleStatus, freshStatus string
|
|
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", tillSaleID).Scan(&staleStatus); err != nil {
|
|
t.Fatalf("failed to query stale till sale: %v", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", freshSaleID).Scan(&freshStatus); err != nil {
|
|
t.Fatalf("failed to query fresh till sale: %v", err)
|
|
}
|
|
if staleStatus != "failed" {
|
|
t.Errorf("expected stale pending till sale to be marked failed, got %q", staleStatus)
|
|
}
|
|
if freshStatus != "pending" {
|
|
t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus)
|
|
}
|
|
}
|
|
|
|
func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) {
|
|
// A £60 payment on a £50 future booking with nothing paid yet:
|
|
// deposit = min(60, 25) = £25, balance = min(35, 25) = £25,
|
|
// tip = 60 - 25 - 25 = £10 — the overflow becomes a separate tip record.
|
|
record := makeTestRecord("b-overflow", "full", 60)
|
|
info := &BookingPaymentInfo{
|
|
StartTime: clock.Now().Add(48 * time.Hour),
|
|
TotalAmount: 50,
|
|
TotalPaid: 0,
|
|
}
|
|
records := buildSplitRecords(record, "full", info, 60)
|
|
|
|
if len(records) != 3 {
|
|
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
|
|
}
|
|
if records[0].PaymentType != "deposit" || records[0].Amount != 25 {
|
|
t.Errorf("expected deposit 25, got %q %.2f", records[0].PaymentType, records[0].Amount)
|
|
}
|
|
if records[1].PaymentType != "balance" || records[1].Amount != 25 {
|
|
t.Errorf("expected balance 25, got %q %.2f", records[1].PaymentType, records[1].Amount)
|
|
}
|
|
if records[2].PaymentType != "tip" || records[2].Amount != 10 {
|
|
t.Errorf("expected tip record 10, got %q %.2f", records[2].PaymentType, records[2].Amount)
|
|
}
|
|
// Tip is an overflow split — zero fees, derived idempotency key -split-2.
|
|
if records[2].Fees != 0 {
|
|
t.Errorf("expected tip record fees=0, got %.2f", records[2].Fees)
|
|
}
|
|
wantKey := *record.IdempotencyKey + "-split-3"
|
|
if *records[2].IdempotencyKey != wantKey {
|
|
t.Errorf("expected tip key %q, got %q", wantKey, *records[2].IdempotencyKey)
|
|
}
|
|
// All three share the same SquarePaymentID (one charge, three ledger rows).
|
|
if *records[2].SquarePaymentID != *record.SquarePaymentID {
|
|
t.Error("tip split must share square_payment_id")
|
|
}
|
|
}
|