fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change

Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 67cf5b9a45
commit 5cc5a7f6d2
28 changed files with 3328 additions and 208 deletions
+11 -2
View File
@@ -10,7 +10,11 @@ POSTGRES_DB=mydb
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
JWT_SECRET_KEY="a-very-secret-key-that-should-be-in-env" # JWT_SECRET_KEY — REQUIRED, FAIL-CLOSED. The backend refuses to start with an
# empty, weak (<32 chars), or known-placeholder value, because a shared/public
# signing key lets anyone forge an admin JWT. Generate a strong random key:
# openssl rand -hex 32
JWT_SECRET_KEY=
# S3/R2 Configuration (for image storage) # S3/R2 Configuration (for image storage)
# Dev: Uses local Rustfs container (see compose.yml) # Dev: Uses local Rustfs container (see compose.yml)
@@ -93,7 +97,12 @@ GO_TESTING=
# CardDAV (SabreDAV) — profile photo sync # CardDAV (SabreDAV) — profile photo sync
DAV_BASE_URL=http://localhost:8080 DAV_BASE_URL=http://localhost:8080
DAV_ADMIN_PASSWORD=admin # DAV_ADMIN_PASSWORD — REQUIRED, FAIL-CLOSED. sabredav/server.php refuses to
# start when unset or set to a known weak/default value ('admin' etc.) — this
# server exposes customer PII vCards, so no public default credential is ever
# acceptable. Generate a strong random value:
# openssl rand -hex 32
DAV_ADMIN_PASSWORD=
# Logging # Logging
# Set to "true" to disable ANSI color escape sequences in log output # Set to "true" to disable ANSI color escape sequences in log output
+2 -2
View File
@@ -5,8 +5,8 @@ WORKDIR /app
# Copy in your prebuilt Go binary (from local ./backend/bin/backend) # Copy in your prebuilt Go binary (from local ./backend/bin/backend)
COPY bin/backend ./backend COPY bin/backend ./backend
# Copy env file if you want to bake it in (or mount via volume/env_file in compose) # Secrets are injected at runtime via compose env_file / environment (see
COPY .env ./ # docker-compose.yml backend env_file: ./.env) — NEVER baked into the image.
EXPOSE 8080 EXPOSE 8080
+81 -30
View File
@@ -972,40 +972,31 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return return
} }
// M1: idempotency_key is optional. If not provided, a deterministic key is // M1: idempotency_key is optional. When omitted, a deterministic fallback
// generated server-side based on user_id + amount + recipient_type + card_id. // key is derived server-side AFTER the advisory lock is acquired (see
// This ensures retries of the same logical purchase use the same key while // deriveGiftCardIdempotencyKey): the base key (user_id + amount +
// distinct purchases get different keys. Max=45 matches Square's limit. // recipient_type + card_id) is reused while a PENDING payment row exists
// for the same logical purchase (a lost-response retry lands on the SAME
// key so Square dedups — the old fresh-random-suffix fallback generated a
// NEW key per retry, missed the pending row, and charged twice), and it
// advances deterministically past COMPLETED purchases so two genuinely
// distinct no-key purchases never collapse onto one dedup key. Clients who
// need full control still supply their own idempotency_key; that path is
// unchanged.
clientSuppliedKey := req.IdempotencyKey != ""
var noKeyCardPart string
if !clientSuppliedKey {
noKeyCardPart = "new"
if req.CardID != nil && *req.CardID != "" {
noKeyCardPart = *req.CardID
}
}
if err := validators.Validate.Struct(&req); err != nil { if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err) log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest) http.Error(w, "Invalid request", http.StatusBadRequest)
return return
} }
// M1: generate a deterministic idempotency key server-side if the client
// doesn't provide one. The key is based on user_id + amount + recipient_type
// + card_id (or "new" for new cards), ensuring retries of the same logical
// purchase use the same key while distinct purchases get different keys.
if req.IdempotencyKey == "" {
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
// Fallback key: base + a fresh random suffix (mirrors the refunds.go
// randomHexSuffix pattern) so two identical no-client-key purchases can
// never collapse onto one dedup key — the old deterministic fallback
// silently returned the first card's code for the second purchase.
// Clients who need retry-dedup supply their own idempotency_key; that
// path is unchanged.
base := fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, cardPart)
req.IdempotencyKey = base + "-" + randomHexSuffix(6)
if len(req.IdempotencyKey) > 45 {
// Hash long keys to fit Square's 45-char limit
hash := sha256.Sum256([]byte(req.IdempotencyKey))
req.IdempotencyKey = fmt.Sprintf("gc-%x", hash[:16])
}
}
// Product rule (security): only verified accounts may save cards. An // Product rule (security): only verified accounts may save cards. An
// unverified/guest/affiliate user may still buy a gift card, but // unverified/guest/affiliate user may still buy a gift card, but
// save_card=true is rejected here — before any charge source resolution. // save_card=true is rejected here — before any charge source resolution.
@@ -1046,8 +1037,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// Bounded try-lock (R6) so a contended lock never blocks the pool across // Bounded try-lock (R6) so a contended lock never blocks the pool across
// the Square round-trip. // the Square round-trip.
lockKey := req.IdempotencyKey lockKey := req.IdempotencyKey
if lockKey == "" { if !clientSuppliedKey {
lockKey = userID // Identical logical no-key purchases must serialize on the SAME lock
// key — the deterministic base, not a per-request random suffix — so
// concurrent lost-response retries cannot both derive a fresh slot and
// both charge.
lockKey = fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, noKeyCardPart)
} }
pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again") pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again")
if !lockOK { if !lockOK {
@@ -1055,6 +1050,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
} }
defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey) defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey)
// Derive the deterministic fallback key UNDER the lock so the spent-slot
// scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey
// in handlers.go, which derives under the per-booking lock).
if !clientSuppliedKey {
derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart)
if dErr != nil {
log.Printf("Failed to derive gift-card idempotency key: %v", dErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
req.IdempotencyKey = derivedKey
}
// Idempotency: only short-circuit when the existing record is 'completed'. // Idempotency: only short-circuit when the existing record is 'completed'.
// A 'pending' record means the previous Square call failed — returning it // A 'pending' record means the previous Square call failed — returning it
// as 200 would show a success without ever charging. Re-attempt below with // as 200 would show a success without ever charging. Re-attempt below with
@@ -1388,6 +1396,49 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
} }
} }
// deriveGiftCardIdempotencyKey returns the deterministic fallback idempotency
// key for a no-client-key gift-card purchase:
// "gc-<userID>-<amount>-<recipientType>-<cardPart>", sha256-truncated when the
// verbatim form exceeds Square's 45-char limit (the hash stays deterministic).
//
// The key must distinguish "same live purchase retried" (dedup) from "new
// purchase that happens to be identical" (new charge). The candidate is the
// base key (seq 0) then the base key with a "-<seq>" suffix (seq >= 1) until a
// slot without a COMPLETED purchase is found. A COMPLETED purchase always
// advances the sequence — two genuine identical no-key purchases (e.g. two
// £20 self cards) are distinct operations and must diverge onto distinct keys
// (the old random-suffix fallback's collapse fix), while a PENDING row never
// occupies a slot: a lost-response retry re-derives the base key, the
// idempotency lookup below reuses the pending row, and Square's same-key dedup
// returns the original charge — ONE charge instead of the old double-charge.
// Must be called under the crussell:giftcard advisory lock so the spent-slot
// scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey
// in handlers.go).
func deriveGiftCardIdempotencyKey(ctx context.Context, q db.Querier, userID string, amount int64, recipientType, cardPart string) (string, error) {
baseKey := fmt.Sprintf("gc-%s-%d-%s-%s", userID, amount, recipientType, cardPart)
for seq := 0; ; seq++ {
candidate := baseKey
if seq > 0 {
candidate = fmt.Sprintf("%s-%d", baseKey, seq)
}
if len(candidate) > 45 {
hash := sha256.Sum256([]byte(candidate))
candidate = fmt.Sprintf("gc-%x", hash[:16])
}
var completedID string
err := q.QueryRow(ctx, `
SELECT id FROM payments
WHERE created_by = $1 AND idempotency_key = $2 AND status = 'completed'
`, userID, candidate).Scan(&completedID)
if errors.Is(err, pgx.ErrNoRows) {
return candidate, nil
}
if err != nil {
return "", err
}
}
}
// --- Helpers --- // --- Helpers ---
// redactEmail masks an email for logs (PII — third-party addresses are not // redactEmail masks an email for logs (PII — third-party addresses are not
+41 -10
View File
@@ -964,17 +964,31 @@ func activeTerminalCheckoutID(ctx context.Context, bookingID string) string {
result, err := SquareClient.GetCheckout(ctx, checkoutID) result, err := SquareClient.GetCheckout(ctx, checkoutID)
switch { switch {
case err == nil && result.Status == "COMPLETED": case err == nil && result.Status == "COMPLETED":
// C1 actually completed at the terminal. Mark the row COMPLETED // C1 actually completed at the terminal. The payment MUST be
// so the booking's in-flight guard releases; the payment is // recorded now — the old code only marked the row COMPLETED and
// recorded by GetCheckoutStatus on the poll path (mirrors the // relied on GetCheckoutStatus (the poll path) to record it, but a
// real-checkout COMPLETED handling below). // checkout that is never polled (abandoned booking / lost poll)
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — marking COMPLETED", checkoutID, bookingID) // would leave the charge permanently untracked:
if _, upErr := db.Conn.Exec(ctx, ` // SweepStaleTerminalCheckouts only re-examines PENDING/IN_PROGRESS
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 // rows, so a row already marked COMPLETED here is never revisited
`, checkoutID); upErr != nil { // and the money stays unrecorded and unrefundable via the app.
log.Printf("Failed to mark provisional terminal checkout %s completed: %v", checkoutID, upErr) // Mirror the sweep's recordUntrackedTerminalPayment: the same
} // crussell:terminal:<squarePayID> advisory lock (serializes
// against a concurrent poll), the same dedup by booking_id +
// square_payment_id, the same PaymentRecord shape, the same
// deposit/balance/tip split, and the same fully-paid completion.
recorded := recordUntrackedTerminalPayment(ctx, checkoutID, bookingID, result)
if recorded {
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — payment recorded", checkoutID, bookingID)
return "" return ""
}
// Recording failed (transient DB/lock contention) — keep the
// in-flight guard UP so a second live checkout is never created
// while the charge is unrecorded. The row stays PENDING, so the
// stale-terminal sweep re-runs recordUntrackedTerminalPayment on
// it; once recorded, this guard releases on the next attempt.
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square but payment recording failed — keeping it in flight; no second checkout until the charge is recorded", checkoutID, bookingID)
return checkoutID
case errors.Is(err, square.ErrCheckoutPending): case errors.Is(err, square.ErrCheckoutPending):
// C1 is still live at Square — reuse it instead of creating C2. // C1 is still live at Square — reuse it instead of creating C2.
log.Printf("Provisional terminal checkout %s for booking %s is live at Square — reusing it", checkoutID, bookingID) log.Printf("Provisional terminal checkout %s for booking %s is live at Square — reusing it", checkoutID, bookingID)
@@ -2443,6 +2457,23 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
// Money-safety guard (M7): a payments row with NO booking is a gift-card
// purchase (BuyGiftCard inserts without a booking — the same discriminator
// the sweep uses in sweep.go). Refunding such a payment at Square returns
// the cash while the issued gift card and its balance credit stay live:
// £N paid out with the £N card still spendable = money created from
// nothing. The reversal alternative (delete the card + debit the pooled
// balance) is unsafe: a self-purchase is auto-redeemed into the account
// balance which may already be partially spent, and gift_card_transactions
// rows reference the card. Reject with a clear message directing the admin
// to the gift-card section, BEFORE any Square call or pending-refund row
// (this also blocks the dedup/resume paths below, which re-issue at
// Square).
if payment.BookingID == "" {
http.Error(w, "Cannot refund a gift-card purchase via payment refund. Refund gift-card purchases by cancelling the card in the gift-card section.", http.StatusBadRequest)
return
}
if payment.SquarePaymentID == nil { if payment.SquarePaymentID == nil {
http.Error(w, "Payment has no Square reference", http.StatusBadRequest) http.Error(w, "Payment has no Square reference", http.StatusBadRequest)
return return
@@ -0,0 +1,388 @@
//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"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/require"
)
// =============================================================================
// M7 — a manual refund of a gift-card purchase must never create money
// =============================================================================
// TestRefundPayment_GiftCardPurchase_Rejected locks the direct-handler guard:
// a completed gift-card purchase payment (payments row with NO booking_id)
// must be rejected with a clear message BEFORE any Square call or refund row —
// refunding it at Square would return the cash while the issued card + balance
// credit stay live (money created from nothing).
func TestRefundPayment_GiftCardPurchase_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
// Seed a completed gift-card purchase payment: NO booking_id, Square
// payment id set — exactly what BuyGiftCard inserts.
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 10.00, 'pay_gc_purchase_refund_1', $1, NOW(), NOW())
RETURNING id
`, userID).Scan(&paymentID)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
req := RefundRequest{Amount: 1000, Reason: "customer changed their mind"}
rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
require.Equal(t, http.StatusBadRequest, rec.Code, "a gift-card purchase must be rejected before any Square call, body: %s", rec.Body.String())
if !strings.Contains(rec.Body.String(), "gift-card") {
t.Errorf("expected the rejection message to direct the admin to the gift-card section, got %q", rec.Body.String())
}
// No refund row may be created for the blocked refund.
var refundCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount))
require.Equal(t, 0, refundCount, "no refund row may exist for a rejected gift-card purchase refund")
}
// TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued locks the sweep
// resume path: a pending manual refund for a gift-card purchase payment must
// NEVER be re-issued at Square (the sweep is a bypass of the handler guard).
// The row is reconciled only — a COMPLETED refund at Square resolves it to
// completed; a genuine no-match fails it + admin-notifies.
func TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
const sqPayID = "pay_gc_purchase_sweep_1"
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('full', 'online_square', 'completed', 10.00, $1, 'gc-purchase-sweep-key', $2, NOW(), NOW())
RETURNING id
`, sqPayID, userID).Scan(&paymentID)
require.NoError(t, err)
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, NULL, 10.00, 'pending', 'manual refund of gift-card purchase', 'refund-key-1', $2, NOW() - INTERVAL '1 minute', 'manual')
RETURNING id
`, paymentID, adminID).Scan(&refundID)
require.NoError(t, err)
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL AND created_at > NOW() - INTERVAL '1 hour'`)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
})
t.Run("no_completed_refund_at_square_fails_without_reissue", func(t *testing.T) {
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
require.NoError(t, err)
require.Equal(t, 0, n, "a blocked gift-card-purchase refund must not count as money-moved")
if calls := counting.refundCalls(); len(calls) != 0 {
t.Fatalf("expected NO Square refund re-issue for a gift-card purchase, got %d refund call(s)", len(calls))
}
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
require.Equal(t, "failed", status, "a gift-card-purchase manual refund with no Square refund must be failed, never re-issued")
var notifCount int
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`).Scan(&notifCount))
require.Equal(t, 1, notifCount, "the blocked refund must surface an admin notification")
})
t.Run("completed_refund_at_square_resolves_completed", func(t *testing.T) {
// Put the row back to pending (the previous sub-run failed it).
_, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', refund_attempts = 0 WHERE id = $1`, refundID)
require.NoError(t, err)
_, err = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`)
require.NoError(t, err)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Seed a COMPLETED refund at Square directly on the mock (this is the
// historical attempt's money already having moved — NOT a re-issue).
_, err = mock.RefundPayment(freshCtx, square.RefundPaymentReq{
PaymentID: sqPayID,
Amount: 1000,
IdempotencyKey: "seed-refund-gc-purchase",
Reason: "historical",
})
require.NoError(t, err)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
require.NoError(t, err)
require.Equal(t, 1, n, "the reconciled COMPLETED refund resolves the row")
if calls := counting.refundCalls(); len(calls) != 0 {
t.Fatalf("expected NO Square refund re-issue even with a completed refund at Square, got %d refund call(s)", len(calls))
}
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status))
require.Equal(t, "completed", status, "an exact COMPLETED refund at Square resolves the row to completed")
})
}
// =============================================================================
// M7 — BuyGiftCard no-client-key fallback: retry dedup without collapse
// =============================================================================
// buyGiftCardNoKey issues a BuyGiftCard request without a client-supplied
// idempotency key and returns the response code + body.
func buyGiftCardNoKey(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int, recipientType string) (int, string) {
t.Helper()
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": amount,
"recipient_type": recipientType,
"new_card_token": "cnon:card-nonce-ok",
})
req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/user/giftcards/buy", BuyGiftCard)
r.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge locks the M7 fix:
// a no-client-key purchase whose Square response is lost (charge COMMITTED at
// Square, handler saw a 503, payment row left pending) must, when retried with
// the identical body, re-derive the SAME deterministic fallback key, reuse the
// pending row, and land ONE Square charge — the old fresh-random-suffix
// fallback generated a new key per retry and charged twice.
func TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailAfterCommit = true
counting := &countingPaymentClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
// Attempt 1: Square commits the charge under the derived key but the
// response is lost — the payment row stays pending.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code == http.StatusCreated {
t.Fatalf("expected the lost-response attempt to fail (charge committed, response lost), got 201: %s", body)
}
mock.FailAfterCommit = false
// Retry: deterministic key reuse + Square dedup → the retry succeeds.
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated {
t.Fatalf("expected the retry to succeed, got %d: %s", code, body)
}
// Exactly ONE payment row, ONE idempotency key, ONE issued card.
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 1, payCount, "exactly one payment row for a lost-response retry")
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 1, keyCount, "both attempts must share ONE deterministic fallback key")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "exactly one gift card issued")
// Both Square calls reused the SAME key; only ONE successful payment exists.
keys := counting.keys
if len(keys) != 2 {
t.Fatalf("expected 2 CreatePayment calls (attempt + retry), got %d", len(keys))
}
if keys[0] != keys[1] {
t.Errorf("expected the retry to reuse the fallback key %q, got %q — a fresh key per retry is the double-charge bug", keys[0], keys[1])
}
if len(counting.payments) != 1 {
t.Errorf("expected exactly ONE successful Square charge, got %d", len(counting.payments))
}
}
// TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges locks the other
// half of the tradeoff: two genuinely DISTINCT no-key purchases (different
// amounts) must diverge onto distinct deterministic keys and issue two charges.
func TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
for _, amount := range []int{2000, 5000} {
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, amount, "self"); code != http.StatusCreated {
t.Fatalf("expected 201 for £%d purchase, got %d: %s", amount/100, code, body)
}
}
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "two different-amount no-key purchases must get distinct keys")
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 2, payCount)
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 2, cardCount)
}
// TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges locks the
// distinct-recipient branch of the tradeoff.
func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
for _, recipient := range []string{"self", "friend"} {
if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, recipient); code != http.StatusCreated {
t.Fatalf("expected 201 for %s purchase, got %d: %s", recipient, code, body)
}
}
var keyCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount))
require.Equal(t, 2, keyCount, "different-recipient no-key purchases must get distinct keys")
var payCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount))
require.Equal(t, 2, payCount)
var cardCount int
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount))
require.Equal(t, 2, cardCount)
}
// =============================================================================
// H4 — a COMPLETED provisional terminal checkout must be recorded, not just
// released
// =============================================================================
// TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the
// H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-)
// checkout COMPLETED at Square, it must RECORD the payment (mirroring the
// sweep's recordUntrackedTerminalPayment) instead of only marking the row
// COMPLETED and relying on a poll that may never come — a never-polled
// checkout would otherwise leave the charge permanently unrecorded and
// unrefundable via the app.
func TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment(t *testing.T) {
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)
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking in_progress: %v", err)
}
const tmpID = "tmp-completed-no-poll"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
const sqPayID = "pay_provisional_completed_1"
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: tmpID,
result: &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: sqPayID,
Amount: 5000,
Fees: 88,
CardBrand: "VISA",
CardLast4: "4242",
ReferenceID: bookingID,
},
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID)
})
// A recorded COMPLETED provisional checkout must release the in-flight
// guard ("" returned), not wedge the booking.
got := activeTerminalCheckoutID(freshCtx, bookingID)
require.Equal(t, "", got, "a recorded COMPLETED provisional checkout must release the in-flight guard")
// The charge must be recorded as a payment row.
var payCount int
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = $2`, bookingID, sqPayID).Scan(&payCount))
require.Equal(t, 1, payCount, "the never-polled COMPLETED provisional charge must be recorded as a payment")
var status string
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, tmpID).Scan(&status))
require.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED")
}
@@ -185,20 +185,16 @@ func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) {
require.Equal(t, "failed", status, "the provisional row must be marked failed") require.Equal(t, "failed", status, "the provisional row must be marked failed")
} }
// failingGetCheckoutClient errors on every GetCheckout — if the sweep wrongly // TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare locks
// resolves a provisional row against Square, the row is left pending (the error // the H4 fix: a provisional "tmp-" terminal_checkouts row is resolved against
// is not a terminal checkout state) and the test fails. // Square FIRST, never blind-failed. A hard crash between the row insert and
type failingGetCheckoutClient struct { // the provisional→real UPDATE can leave a LIVE checkout at Square (created
square.SquareClient // under the idempotency key embedded in the tmp id) while the row still
} // carries the synthetic id — so a tmp- row is NO LONGER provably not live.
// A tmp- id Square has never seen (NOT_FOUND — the crash happened before the
func (c *failingGetCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) { // Square call, or the id cannot be resolved) is the expected outcome and is
return nil, fmt.Errorf("GetCheckout was called for %s — provisional rows must resolve without a Square round-trip", checkoutID) // safely resolved to failed.
} func TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare(t *testing.T) {
// TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare proves
// the sweep resolves a stale provisional row to failed without calling Square.
func TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx) userID, bookingID, serviceID := setupTestData(t, ctx, tx)
@@ -230,8 +226,12 @@ func TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare(t *test
t.Fatalf("failed to clean leftover stale till sales: %v", err) t.Fatalf("failed to clean leftover stale till sales: %v", err)
} }
// A fresh mock holds no checkout under the tmp id → GetCheckout returns
// the mock's plain "checkout not found" error (NOT_FOUND), the expected
// outcome for a tmp- id that never reached Square → the row is resolved
// to failed.
origClient := SquareClient origClient := SquareClient
SquareClient = &failingGetCheckoutClient{SquareClient: square.NewDevClient()} SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }() defer func() { SquareClient = origClient }()
n, err := SweepStaleTerminalCheckouts(freshCtx) n, err := SweepStaleTerminalCheckouts(freshCtx)
@@ -0,0 +1,692 @@
//go:build test && dev
package payments
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
"github.com/stretchr/testify/require"
)
// =============================================================================
// R7 — CreateBookingPayment refunded-dedup 409 guard (paymentHasLiveRefund)
// =============================================================================
// TestCreateBookingPayment_RefundedDedup_409 locks the money-safety guard on
// the idempotent dedup path: a client-supplied idempotency_key that matches a
// COMPLETED payment which has since been refunded must NOT be reported as
// success (that would silently swallow a new equal-amount charge — the booking
// shows paid with no money collected). The replay is rejected with 409 and no
// new payment row is created.
func TestCreateBookingPayment_RefundedDedup_409(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
const key = "client-uuid-refunded-dedup"
cardToken := "cnon:refunded-dedup-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: key,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "initial charge must succeed, body: %s", w1.Body.String())
// A £25 deposit on the £50 fixture booking leaves the booking confirmed
// (not fully paid), so the retry reaches the GENERAL dedup path (with the
// refund re-validation) rather than the completed-booking short-circuit.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
require.Equal(t, "confirmed", status, "the replay must hit the general dedup path")
var paymentID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, key).Scan(&paymentID))
// The admin refunds the payment — a live (completed) refund row now makes
// the payment's money no longer collectable.
_, err := tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 25.00, 'completed', 'admin refund', NOW())
`, paymentID, bookingID)
require.NoError(t, err)
// Same key + same amount retry: MUST NOT return the refunded payment as
// success.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusConflict, w2.Code, "a refunded-payment replay must 409, body: %s", w2.Body.String())
require.Contains(t, w2.Body.String(), "refunded and can no longer be replayed")
// No new payment row may be created by the rejected replay.
var keyCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&keyCount))
require.Equal(t, 1, keyCount, "the rejected replay must not create a second payment row")
}
// =============================================================================
// R7 — sweep keyed blind-fail for rows past Square's key-retention window
// =============================================================================
// TestSweepStalePendingPayments_KeyedPastRetention_BlindFails locks the
// payments-table blind-fail: a stale pending row with a stored idempotency_key
// but no square_payment_id that is ALREADY older than Square's ~24h key
// retention window when swept is marked 'failed' WITHOUT a replay reconcile
// (replaying an expired key would misread the probe rejection as "never
// charged"), and it counts toward the unverifiable WARN accounting.
func TestSweepStalePendingPayments_KeyedPastRetention_BlindFails(t *testing.T) {
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)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
// 25h old: past stalePendingPaymentAge (24h), so replayExpired (now-24h) is
// already in the past even though the replay mock is FRESH — the row must be
// blind-failed, never replayed.
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-past-retention-pay', square_source_id = 'cnon:test-card' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// Fresh mock: has no payment under the key, but the blind-fail must happen
// WITHOUT any replay because the retention window already closed.
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
// The past-retention blind-fail must be counted in the unverifiable
// accounting (resolved, NOT completed).
resolved, completed, unverifiable, err := sweepKeyedStaleRows(context.Background(), "payments", clock.Now().Add(-stalePendingKeyedAge))
require.NoError(t, err)
require.Equal(t, 1, resolved, "the past-retention keyed row must be resolved by the sweep")
require.Equal(t, 0, completed, "a past-retention keyed row can never be rescued to completed")
require.Equal(t, 1, unverifiable, "the past-retention blind-fail must count toward the unverifiable WARN accounting")
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, "failed", rowStatus, "a keyed row past Square's retention window must be blind-failed")
// No reconcile happened: the row must not carry a square_payment_id.
var sqPayID *string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&sqPayID))
require.Nil(t, sqPayID, "a blind-failed row must never receive a square_payment_id")
}
// TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback
// locks the same blind-fail for a KEYED till sale: the sale is marked 'failed'
// and its funded gift card is NOT clawed back — unlike the proven-failure path,
// the blind-fail's charge outcome is unknown, so the funding must stay put.
func TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
pool := context.Background()
// seedStaleTillSaleWithCard already ages both the sale and its created gift
// card to 25h (create) — past Square's 24h retention window. Add the stored
// idempotency key to put it on the keyed pass.
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
if _, err := tx.Exec(ctx, "UPDATE till_sales SET idempotency_key = 'key-past-retention-till' WHERE id = $1", saleID); err != nil {
t.Fatalf("failed to set the till sale idempotency key: %v", err)
}
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
require.NoError(t, db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status))
require.Equal(t, "failed", status, "a keyed till sale past retention must be blind-failed")
var cardCount int
require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount))
require.Equal(t, 1, cardCount, "the blind-failed till sale's funded gift card must NOT be clawed back (charge outcome unknown)")
}
// =============================================================================
// R7 — reconcile tri-state status outcomes (by key AND by square_payment_id)
// =============================================================================
// TestSweepStalePendingPayments_ReconcileByKey_TriState locks the status
// switch in reconcileStalePaymentByKey: after a successful keyed replay,
// CANCELED/FAILED are definitive failures (row marked failed), APPROVED/PENDING
// are non-terminal (row left pending), and any unknown status is failed.
func TestSweepStalePendingPayments_ReconcileByKey_TriState(t *testing.T) {
cases := []struct {
name string
status string
wantFinal string // "failed" or "pending"
}{
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
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)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
key := "key-replay-tri-" + tc.name
// 23h old: past the 22h keyed cutoff (so the keyed pass picks it up)
// but still inside Square's 24h retention window (so the replay runs).
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, tc.wantFinal, rowStatus, "replay-by-key status %q must leave the row %q", tc.status, tc.wantFinal)
})
}
}
// TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses locks the
// same status switch in reconcileStalePaymentAtSquare (rows reconciled by their
// stored square_payment_id).
func TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses(t *testing.T) {
cases := []struct {
name string
status string
wantFinal string
}{
{name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"},
{name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"},
{name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"},
{name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"},
{name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
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)
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
require.NoError(t, err)
sqPayID := "sqp_reconcile_tri_" + tc.name
// 25h old: past the 24h pass-2 cutoff, with a square_payment_id so
// the reconcile runs by payment id (not by key).
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = $1 WHERE id = $2", sqPayID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status, SquarePayID: sqPayID}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
require.NotNil(t, pgxTx)
require.NoError(t, pgxTx.Commit(ctx))
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var rowStatus string
require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus))
require.Equal(t, tc.wantFinal, rowStatus, "by-payment-id status %q must leave the row %q", tc.status, tc.wantFinal)
})
}
}
// =============================================================================
// R7 — resolveChargeSource Square-failure branches (all 500 + ok=false)
// =============================================================================
// round7SquareFailureClient forces CreateCustomer / CreateCardOnFile to return
// errors so the resolveChargeSource error branches can be exercised
// deterministically (the embedding pattern mirrors staleGetPaymentClient /
// staleReplayClient).
type round7SquareFailureClient struct {
square.SquareClient
failCreateCustomer bool
failCreateCard bool
}
func (c *round7SquareFailureClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
if c.failCreateCustomer {
return nil, fmt.Errorf("square: network error creating customer")
}
return c.SquareClient.CreateCustomer(ctx, name, email)
}
func (c *round7SquareFailureClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
if c.failCreateCard {
return nil, fmt.Errorf("square: network error creating card-on-file")
}
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
}
func TestResolveChargeSource_SquareFailureBranches_500NoOrphan(t *testing.T) {
ctx := context.Background()
t.Run("ensure_square_customer_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
defer func() { SquareClient = origClient }()
token := "cnon:r7-customer-fail"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
require.False(t, ok, "an EnsureSquareCustomer failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphan saved-card row may be created when customer provisioning fails")
})
t.Run("create_card_on_file_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// Provisioning succeeds from the cache; the card-on-file creation fails.
squareCustomerCache.Store(userID, "cus_r7_createcard")
defer InvalidateSquareCustomerCache(userID)
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCard: true}
defer func() { SquareClient = origClient }()
token := "cnon:r7-createcard-fail"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "")
require.False(t, ok, "a CreateCardOnFile failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows))
require.Zero(t, rows, "no orphan saved-card row may be created when the Square card creation fails")
})
t.Run("saved_card_lazy_provisioning_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
InvalidateSquareCustomerCache(userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// A pre-P14 saved-card row with an EMPTY square_customer_id: charging it
// requires lazy provisioning, which fails here.
cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:r7-legacy-card", "VISA", "4242")
require.NoError(t, err)
origClient := SquareClient
SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true}
defer func() { SquareClient = origClient }()
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
require.False(t, ok, "a lazy-provisioning failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
// The pre-existing saved card must remain, still un-provisioned.
var rows int
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND user_id = $2`, cardID, userID).Scan(&rows))
require.Equal(t, 1, rows, "the existing saved card must not be deleted by the failed provisioning")
var custID *string
require.NoError(t, db.Conn.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, cardID).Scan(&custID))
require.Nil(t, custID, "the failed provisioning must not persist a Square customer id")
})
t.Run("get_card_non_404_failure", func(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
require.NoError(t, err)
defer func() {
_, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
}()
// A cancelled context makes the GetCardByID query fail with a non-404
// error (context canceled), exercising the 500 branch (NOT the 404
// no-rows branch).
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
cardID := "nonexistent-card-id"
w := httptest.NewRecorder()
sourceID, savedCardID, sqCustID, ok := resolveChargeSource(cancelCtx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found")
require.False(t, ok, "a non-404 GetCardByID failure must fail source resolution")
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Empty(t, sourceID)
require.Nil(t, savedCardID)
require.Empty(t, sqCustID)
})
}
// =============================================================================
// R7 — CreateBookingPayment end-to-end charge-failure classification
// =============================================================================
// round7ChargeClient forces CreatePayment to return a fixed error so the
// charge-failure status mapping can be exercised end to end through the
// handler (the pending record must stay pending on every failure).
type round7ChargeClient struct {
square.SquareClient
createErr error
}
func (c *round7ChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
if c.createErr != nil {
return nil, c.createErr
}
return c.SquareClient.CreatePayment(ctx, req)
}
// round7CancelAwareChargeClient blocks inside CreatePayment until the request
// context is cancelled and then returns ctx.Err() — simulating Square hanging
// until the client gives up mid-charge.
type round7CancelAwareChargeClient struct {
square.SquareClient
entered chan struct{}
}
func (c *round7CancelAwareChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
close(c.entered)
<-ctx.Done()
return nil, ctx.Err()
}
// structuredSquareErrorWithCode builds a structured *square.squareAPIError of
// the same concrete type the real client produces, re-stamped with the given
// HTTP status and Square error code. The type is not nameable outside
// internal/square, so the clone-through-reflection technique mirrors
// errors_test.go's structuredSquareAPIError (which rewrites only the status);
// here the code is also rewritten so a CARD_DECLINED decline can be produced.
func structuredSquareErrorWithCode(t *testing.T, status int, code string) error {
t.Helper()
mc := square.NewDevClient().(*square.MockClient)
_, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 1000,
Currency: "GBP",
SourceID: "ccof:card_1",
})
require.Error(t, err, "expected the mock to reject a ccof charge without a customer")
v := reflect.ValueOf(err)
require.Equal(t, reflect.Ptr, v.Kind(), "expected the structured error to be a pointer")
clone := reflect.New(v.Elem().Type())
clone.Elem().Set(v.Elem())
clone.Elem().FieldByName("StatusCode").SetInt(int64(status))
clone.Elem().FieldByName("Code").SetString(code)
return clone.Interface().(error)
}
func TestCreateBookingPayment_ChargeFailureStatuses_KeepPending(t *testing.T) {
t.Run("structured_500_returns_503_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)}
defer func() { SquareClient = origClient }()
cardToken := "cnon:r7-structured-500"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-500-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a structured Square 500 must classify as 503, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "an ambiguous charge failure must leave the row pending for a same-key retry")
})
t.Run("card_declined_returns_402_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
origClient := SquareClient
SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")}
defer func() { SquareClient = origClient }()
cardToken := "cnon:r7-card-declined"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-declined-" + bookingID,
}
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusPaymentRequired, w.Code, "a definitive decline (CARD_DECLINED 4xx) must classify as 402, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "a declined charge must leave the row pending")
})
t.Run("cancelled_context_returns_503_keeps_pending", func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
entered := make(chan struct{})
origClient := SquareClient
SquareClient = &round7CancelAwareChargeClient{SquareClient: square.NewDevClient(), entered: entered}
defer func() { SquareClient = origClient }()
reqCtx, cancel := context.WithCancel(ctx)
defer cancel()
cardToken := "cnon:r7-cancel-ctx"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "r7-cancel-" + bookingID,
}
done := make(chan *httptest.ResponseRecorder, 1)
go func() {
done <- makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, reqCtx)
}()
select {
case <-entered:
// The charge reached Square with the DB pending record already
// committed — cancel now.
case <-time.After(10 * time.Second):
t.Fatal("the charge never reached Square")
}
cancel()
w := <-done
require.Equal(t, http.StatusServiceUnavailable, w.Code, "a cancelled charge context must classify as 503, body: %s", w.Body.String())
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status))
require.Equal(t, "pending", status, "a cancelled-context charge must leave the row pending")
})
}
// =============================================================================
// R7 — deriveBookingPaymentIdempotencyKey >45-char sha256 truncation
// =============================================================================
// TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic
// locks the >45-char truncation: a candidate key built from a very long
// bookingID + card must be hashed down to a deterministic ≤45-char key, and
// the truncation must be input-sensitive (the seq-0 truncated key is distinct
// from the seq-1 candidate's key — distinct inputs never collapse).
func TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// A bookingID far longer than Square's 45-char idempotency-key limit.
longBookingID := "pay-" + strings.Repeat("b", 60)
cardPart := "ccof:some-long-card-id"
base := fmt.Sprintf("pay-%s-%s-%d-%s", longBookingID, "deposit", 2500, cardPart)
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
if len(key1) > 45 {
t.Errorf("the derived key must stay within Square's 45-char limit, got %d chars: %q", len(key1), key1)
}
require.True(t, strings.HasPrefix(key1, "pay-"), "the truncated key must keep the pay- prefix, got %q", key1)
// Deterministic: an identical re-derivation returns the same key.
key2, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
require.Equal(t, key1, key2, "the truncated key must be deterministic")
// The truncation hashes the FULL candidate: verify the seq-0 key against
// the sha256[:16] derivation and confirm it differs from the seq-1 key.
h0 := sha256.Sum256([]byte(base))
wantSeq0 := fmt.Sprintf("pay-%x", h0[:16])
require.Equal(t, wantSeq0, key1, "the seq-0 truncated key must be sha256[:16] of the full candidate")
h1 := sha256.Sum256([]byte(base + "-1"))
wantSeq1 := fmt.Sprintf("pay-%x", h1[:16])
require.NotEqual(t, wantSeq1, key1, "the seq-0 truncated key must be distinct from the seq-1 truncated key")
}
// TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct locks the
// sequence-advance path under truncation: when the seq-0 truncated key is
// occupied by a REFUNDED completed payment (so it can no longer be replayed),
// the derive advances to a seq-1 truncated key that is distinct from seq-0 —
// two different inputs must never collide in the truncated namespace.
func TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct(t *testing.T) {
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)
// A real CHAR(12) bookingID plus a long card ID pushes the candidate over
// 45 chars while the booking_id stays insertable in the payments table.
cardPart := "ccof:super-long-card-on-file-id-00000001"
key0, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
if len(key0) > 45 {
t.Errorf("the seq-0 truncated key must stay within 45 chars, got %d: %q", len(key0), key0)
}
// Occupy the seq-0 slot with a REFUNDED completed payment so the derive
// must advance to seq 1 (a refunded completed row never blocks a new
// equal-amount charge).
var paymentID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 'completed', 25.00, $2, NOW(), NOW())
RETURNING id
`, bookingID, key0).Scan(&paymentID))
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 25.00, 'completed', 'test refund', NOW())
`, paymentID, bookingID)
require.NoError(t, err)
key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart)
require.NoError(t, err)
require.NotEqual(t, key0, key1, "the seq-1 truncated key must be distinct from the seq-0 key")
if len(key1) > 45 {
t.Errorf("the seq-1 truncated key must stay within 45 chars, got %d: %q", len(key1), key1)
}
}
+47 -4
View File
@@ -3,6 +3,7 @@ package payments
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -1149,6 +1150,9 @@ func chargeAggKey(chargeID string) string {
type manualPendingRow struct { type manualPendingRow struct {
ID string ID string
PaymentID string PaymentID string
// BookingID is the payment's booking_id ("" when NULL = gift-card purchase;
// such manual refunds are never re-issued, see processManualPaymentGroup).
BookingID string
Amount float64 Amount float64
IdempotencyKey string IdempotencyKey string
Reason string Reason string
@@ -1168,7 +1172,7 @@ type manualPendingRow struct {
// retry is idempotent). // retry is idempotent).
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, ` rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason, SELECT r.id, r.payment_id, p.booking_id, r.amount, r.idempotency_key, r.reason,
p.square_payment_id, r.square_refund_id, r.created_at p.square_payment_id, r.square_refund_id, r.created_at
FROM refunds r FROM refunds r
JOIN payments p ON p.id = r.payment_id JOIN payments p ON p.id = r.payment_id
@@ -1186,10 +1190,12 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
var pr manualPendingRow var pr manualPendingRow
var key *string var key *string
var sqRefundID *string var sqRefundID *string
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil { var bookingID sql.NullString
if err := rows.Scan(&pr.ID, &pr.PaymentID, &bookingID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan manual pending refund: %v", err) log.Printf("Failed to scan manual pending refund: %v", err)
continue continue
} }
pr.BookingID = bookingID.String
if key != nil { if key != nil {
pr.IdempotencyKey = *key pr.IdempotencyKey = *key
} }
@@ -1301,7 +1307,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// Re-read under the lock — only rows still pending and under the attempt // Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some). // cap are eligible (a concurrent manual refund may have resolved some).
prRows, err := db.Conn.Query(ctx, ` prRows, err := db.Conn.Query(ctx, `
SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at, SELECT r.id, p.booking_id, r.amount, r.idempotency_key, r.reason, r.created_at,
r.payment_id, p.square_payment_id, r.square_refund_id r.payment_id, p.square_payment_id, r.square_refund_id
FROM refunds r FROM refunds r
JOIN payments p ON p.id = r.payment_id JOIN payments p ON p.id = r.payment_id
@@ -1316,10 +1322,12 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
var pr manualPendingRow var pr manualPendingRow
var key *string var key *string
var sqRefundID *string var sqRefundID *string
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil { var bookingID sql.NullString
if err := prRows.Scan(&pr.ID, &bookingID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil {
log.Printf("Failed to scan manual pending refund under lock: %v", err) log.Printf("Failed to scan manual pending refund under lock: %v", err)
continue continue
} }
pr.BookingID = bookingID.String
if key != nil { if key != nil {
pr.IdempotencyKey = *key pr.IdempotencyKey = *key
} }
@@ -1384,6 +1392,41 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
pr := &pending[i] pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100)) amountCents := int64(math.Round(pr.Amount * 100))
// A payment with NO booking is a gift-card purchase (BuyGiftCard
// inserts without a booking) — the handler rejects these outright, so a
// pending row that predates that guard must never be re-issued here
// (the sweep is a bypass of the handler guard). Reconcile only,
// exactly like the square_refund_id branch: an exact COMPLETED refund
// resolves to completed (money already moved); a no-match means Square
// never refunded — mark failed + admin-notified so the amount unblocks
// the over-refund guard and the customer is handled via the gift-card
// section.
if pr.BookingID == "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for gift-card-purchase manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark gift-card-purchase manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processed++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark gift-card-purchase manual refund %s failed: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Gift-card-purchase manual refund %s (payment %s) blocked — payment has no booking; Square shows no COMPLETED refund — marked failed, customer must be refunded via the gift-card section", pr.ID, pr.PaymentID)
}
continue
}
// Row WITH a stored square_refund_id — the RefundPayment handler's // Row WITH a stored square_refund_id — the RefundPayment handler's
// synchronous-PENDING response. Square already holds the refund, so a // synchronous-PENDING response. Square already holds the refund, so a
// re-issue would risk a SECOND refund (Square's key dedup does not // re-issue would risk a SECOND refund (Square's key dedup does not
+114 -10
View File
@@ -835,13 +835,10 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
resolved := 0 resolved := 0
for _, r := range pending { for _, r := range pending {
// A provisional (pre-Square) terminal_checkouts row carries a synthetic // An EMPTY checkout_id is a legacy provisional (pre-Square) row for
// "tmp-" checkout_id (or an empty one) — no checkout was ever created // which no Square call was ever possible — resolving it to failed
// at Square for it, so it is PROVABLY not live (R3). Resolve it to // directly is safe (R3).
// failed directly without a Square round-trip; a hard crash between the if r.CheckoutID == "" {
// row insert and the Square CreateCheckout call is the only way one
// exists.
if r.CheckoutID == "" || strings.HasPrefix(r.CheckoutID, "tmp-") {
if markTerminalCheckoutRowFailed(ctx, r) { if markTerminalCheckoutRowFailed(ctx, r) {
resolved++ resolved++
} }
@@ -849,6 +846,72 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
continue continue
} }
// A provisional row carries a synthetic "tmp-" checkout_id. It is no
// longer PROVABLY not live (H4): a hard crash between the
// terminal_checkouts insert and the provisional→real UPDATE leaves a
// LIVE checkout at Square (created under the idempotency key embedded
// in the tmp id) while the row still carries the synthetic id.
// Blind-failing would release the in-flight guard while that checkout
// can still complete at the terminal into an untracked charge. Query
// Square first and classify exactly like activeTerminalCheckoutID
// (handlers.go H4): COMPLETED → record the payment and release the
// guard; ErrCheckoutPending → the checkout is still live at Square,
// keep the guard; isTerminalCheckoutError (NOT_FOUND / CANCELED) →
// safe to fail (the crash happened before the Square call, or the
// checkout was cancelled); ambiguous → leave the row in flight. A tmp-
// id that never reached Square (or one Square cannot resolve) comes
// back NOT_FOUND — the expected outcome — and failing it is correct.
if strings.HasPrefix(r.CheckoutID, "tmp-") {
pr, gErr := SquareClient.GetCheckout(ctx, r.CheckoutID)
switch {
case gErr == nil && pr.Status == "COMPLETED":
// The checkout actually completed at the terminal. Record the
// payment if it was never polled/recorded — a COMPLETED charge
// must not stay an invisible untracked charge (H4). Booking
// checkouts record full payment rows; till-sale checkouts
// record the sale row.
if r.Kind == "terminal_checkout" {
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) {
resolved++
}
} else if recordUntrackedTillSalePayment(ctx, r.RowID, pr) {
resolved++
}
log.Printf("Provisional terminal checkout %s (%s %s) is COMPLETED at Square — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID)
case errors.Is(gErr, square.ErrCheckoutPending):
// The checkout is still live at Square — keep the in-flight
// guard so a second checkout cannot be created while it can
// still complete.
log.Printf("Provisional terminal checkout %s (%s %s) is still live at Square — leaving pending; the in-flight guard stays held", r.CheckoutID, r.Kind, r.RowID)
case isTerminalCheckoutError(gErr):
// NOT_FOUND (no checkout was ever created — the crash happened
// before the Square call) or CANCELED — safe to resolve failed.
switch {
case r.Kind == "till_sale" && isCheckoutDefinitivelyDead(gErr):
if clawBackTillSaleFunding(ctx, r.RowID) {
resolved++
}
log.Printf("Provisional terminal checkout %s is definitively terminal (%v) — marked till sale %s failed, gift-card funding clawed back", r.CheckoutID, gErr, r.RowID)
case r.Kind == "till_sale":
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("CRITICAL: provisional terminal checkout %s reports only CANCEL_REQUESTED (not provably dead) — till sale %s marked failed WITHOUT clawing back the funded gift card; verify at Square before re-issuing — MANUAL RECONCILIATION REQUIRED", r.CheckoutID, r.RowID)
default:
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Provisional (pre-Square) terminal checkout row %q (%s %s) resolved as failed — no live checkout at Square", r.CheckoutID, r.Kind, r.RowID)
}
default:
// Ambiguous error — the checkout's money state at Square is
// unknown. Leave the row in flight rather than releasing the
// guard and allowing a second checkout.
log.Printf("Provisional terminal checkout %s (%s %s) status unknown (%v) — leaving pending for a later sweep", r.CheckoutID, r.Kind, r.RowID, gErr)
}
continue
}
// Conservative status check: only cancel a checkout that is provably // Conservative status check: only cancel a checkout that is provably
// still waiting at Square. A COMPLETED checkout must never be // still waiting at Square. A COMPLETED checkout must never be
// cancelled, and an ambiguous status (network error) is left alone for // cancelled, and an ambiguous status (network error) is left alone for
@@ -991,9 +1054,15 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR
// lock key (serializes against a concurrent poll), the same dedup by // lock key (serializes against a concurrent poll), the same dedup by
// booking_id + square_payment_id, the same PaymentRecord shape and derived // booking_id + square_payment_id, the same PaymentRecord shape and derived
// idempotency key, and the same deposit/balance/tip split for a charge above // idempotency key, and the same deposit/balance/tip split for a charge above
// the remaining booking value. Returns true when the checkout row was resolved // the remaining booking value. It also mirrors GetCheckoutStatus's booking
// (payment recorded or already recorded); false when recording failed (the // re-check: a booking that moved out of a payable state (cancelled / lapsed /
// row is left pending so the next sweep re-runs the whole reconcile). // no-show) after the charge completed refuses to record the payment, marks the
// checkout failed, and inserts a critical-payment admin notification so the
// owner is told a charge landed on a cancelled booking and a manual refund is
// required. Returns true when the checkout row was resolved (payment recorded,
// already recorded, or refused on a cancelled booking); false when recording
// failed (the row is left pending so the next sweep re-runs the whole
// reconcile).
func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID string, pr *square.PaymentResult) bool { func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID string, pr *square.PaymentResult) bool {
if pr == nil || pr.SquarePayID == "" { if pr == nil || pr.SquarePayID == "" {
log.Printf("CRITICAL: terminal checkout %s is COMPLETED at Square but carries no Square payment ID — cannot record the payment — MANUAL RECONCILIATION REQUIRED", checkoutID) log.Printf("CRITICAL: terminal checkout %s is COMPLETED at Square but carries no Square payment ID — cannot record the payment — MANUAL RECONCILIATION REQUIRED", checkoutID)
@@ -1060,6 +1129,41 @@ func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID s
return false return false
} }
// Re-check the booking status under the advisory lock (mirrors
// GetCheckoutStatus): a cancellation/eviction that committed between the
// terminal charge completing at Square and this sweep recording it must
// not produce a completed payment on a cancelled/lapsed/no-show booking —
// the cancellation refund path computes refunds from completed payments
// and would silently exclude this charge. Mark the checkout failed and
// alert ops: money was taken at Square and MUST be refunded manually.
var recheckStatus string
// FOR UPDATE (C5): serializes against the cancellation path's lock on the
// same row so a concurrent cancellation cannot commit between this recheck
// and the transaction commit below.
if err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1 FOR UPDATE`, bookingID).Scan(&recheckStatus); err != nil {
log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required",
pr.SquarePayID, checkoutID, bookingID, err)
return false
}
if !bookingStatusAllowsCompletedPayment(recheckStatus) {
log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually",
pr.SquarePayID, checkoutID, bookingID, recheckStatus)
if _, upErr := tx.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW()
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, checkoutID); upErr != nil {
log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required",
pr.SquarePayID, recheckStatus, bookingID, checkoutID, upErr)
}
if cErr := tx.Commit(ctx); cErr != nil {
log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required",
pr.SquarePayID, recheckStatus, bookingID, cErr)
return false
}
insertCriticalPaymentNotification(ctx, &bookingID, nil)
return true
}
// The payment type the admin charged is recorded on the checkout row by // The payment type the admin charged is recorded on the checkout row by
// CreateTerminalPayment; fall back to 'full' for legacy rows. // CreateTerminalPayment; fall back to 'full' for legacy rows.
var checkoutPaymentType string var checkoutPaymentType string
+458
View File
@@ -1941,3 +1941,461 @@ func TestSweepStaleTerminalCheckouts_MockCancelRequested_CancelThenClawback(t *t
t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount) t.Errorf("expected the clawed-back created gift card deleted, got %d cards", cardCount)
} }
} }
// =============================================================================
// SweepStaleTerminalCheckouts — provisional "tmp-" rows are resolved against
// Square first (H4), never blind-failed
// =============================================================================
// provisionalCheckoutClient forces GetCheckout for one target checkout id to a
// fixed result/error so the sweep's provisional "tmp-" row resolution
// (mirroring handlers.go activeTerminalCheckoutID's H4 classification) can be
// exercised deterministically, while delegating everything else to the mock.
type provisionalCheckoutClient struct {
square.SquareClient
checkoutID string
result *square.PaymentResult
err error
}
func (c *provisionalCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
if c.err != nil {
return nil, c.err
}
return c.result, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
// seedStaleProvisionalTerminalCheckout seeds a stale (2h old) PENDING
// terminal_checkouts row carrying a synthetic "tmp-" checkout_id for the
// caller's booking inside the setup transaction. The caller commits the setup
// tx; pool-level cleanup of the row is registered.
func seedStaleProvisionalTerminalCheckout(t *testing.T, ctx context.Context, q db.Querier, bookingID, tmpID string) {
t.Helper()
pool := context.Background()
if _, err := q.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, tmpID, bookingID); err != nil {
t.Fatalf("failed to seed stale provisional terminal checkout: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID)
})
}
// TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment locks
// the H4 fix: a provisional "tmp-" terminal_checkouts row whose checkout
// actually COMPLETED at Square (a hard crash between the row insert and the
// provisional→real UPDATE leaves the real checkout live while the row still
// carries the synthetic id) is resolved against Square and the untracked charge
// RECORDED — never blind-failed. Blind-failing would release the in-flight
// guard while the customer was still charged, leaving an invisible untracked
// payment (a second checkout could then be created for the same booking).
func TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// The booking must be in a payable state for the untracked charge to be
// recorded.
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking in_progress: %v", err)
}
const tmpID = "tmp-crash-completed-key"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: tmpID,
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tmp_completed", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"},
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = 'sqp_tmp_completed'`)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
// Drop any other stale terminal rows left by sequential tests so the count
// is deterministic.
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected the COMPLETED provisional checkout resolved by the sweep, got %d resolutions", n)
}
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "COMPLETED" {
t.Errorf("expected a COMPLETED provisional checkout marked 'COMPLETED', got %q", status)
}
// The untracked charge must have been recorded as a payment row (H4).
var payCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_tmp_completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count recorded payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected the COMPLETED provisional charge recorded as a payment, got %d rows", payCount)
}
}
// TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_Fails locks the H4
// NOT_FOUND direction: a provisional "tmp-" row that Square has never seen (the
// crash happened before the Square call, so no checkout was ever created — or
// the tmp id cannot be resolved) resolves to the expected NOT_FOUND and is
// safely marked failed.
func TestSweepStaleTerminalCheckouts_TmpProvisional_NotFound_Fails(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
const tmpID = "tmp-never-reached-square-key"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
// A fresh mock holds no checkout under the tmp id → GetCheckout returns the
// mock's plain "checkout not found" error, which isTerminalCheckoutError
// classifies as terminal — the expected outcome for an unresolvable tmp id.
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected the not-found provisional checkout resolved to failed, got %d resolutions", n)
}
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "failed" {
t.Errorf("expected a not-found provisional checkout marked 'failed', got %q", status)
}
}
// TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending locks
// the H4 ambiguous direction: a provisional "tmp-" row whose Square status is
// unknown (transport error) must NOT be blind-failed — the checkout may still
// be live or may have completed, so the row stays pending for a later run.
func TestSweepStaleTerminalCheckouts_TmpProvisional_Ambiguous_LeavesPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
const tmpID = "tmp-ambiguous-key"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: tmpID,
err: fmt.Errorf("network error: connection reset by peer"),
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 0 {
t.Errorf("expected an ambiguous provisional checkout left unresolved, got %d resolutions", n)
}
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "PENDING" {
t.Errorf("expected an ambiguous provisional checkout left 'PENDING', got %q", status)
}
}
// TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending locks
// the H4 ErrCheckoutPending direction: a provisional "tmp-" row whose checkout
// is still live at Square (PENDING / IN_PROGRESS) must keep the in-flight guard
// — the sweep must never resolve it failed (releasing the guard) while the
// customer can still complete the payment at the terminal into an untracked
// charge.
func TestSweepStaleTerminalCheckouts_TmpProvisional_StillLive_LeavesPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
const tmpID = "tmp-still-live-key"
seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID)
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: tmpID,
err: square.ErrCheckoutPending,
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 0 {
t.Errorf("expected a still-live provisional checkout left unresolved, got %d resolutions", n)
}
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "PENDING" {
t.Errorf("expected a still-live provisional checkout left 'PENDING' (guard held), got %q", status)
}
}
// TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded locks the
// sweep's booking re-check (mirroring GetCheckoutStatus): a terminal checkout
// that COMPLETED at Square while the booking is we_cancelled must NOT be
// recorded as a completed payment — the cancellation refund path computes
// refunds from completed payments and would silently exclude this charge.
// Instead the checkout is marked failed, no payment row is created, and a
// critical-payment admin notification tells the owner a charge landed on a
// cancelled booking and a manual refund is required.
func TestSweepStaleTerminalCheckouts_CancelledBooking_NoPaymentRecorded(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// The charge completes at Square AFTER the booking was cancelled.
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'we_cancelled' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking we_cancelled: %v", err)
}
const checkoutID = "chk_completed_on_cancelled_booking"
if _, err := tx.Exec(ctx, `
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
`, checkoutID, bookingID); err != nil {
t.Fatalf("failed to seed stale terminal checkout: %v", err)
}
origClient := SquareClient
SquareClient = &provisionalCheckoutClient{
SquareClient: square.NewDevClient(),
checkoutID: checkoutID,
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_cancelled_booking", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242"},
}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID)
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
})
if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkoutID); err != nil {
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
}
if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
t.Fatalf("failed to clean leftover stale till sales: %v", err)
}
n, err := SweepStaleTerminalCheckouts(pool)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected the COMPLETED checkout on a cancelled booking resolved (to failed) by the sweep, got %d resolutions", n)
}
// The checkout must be marked failed (guard released, sweep does not retry
// forever), never COMPLETED and never left with a recorded payment.
var status string
if err := db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", checkoutID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "failed" {
t.Errorf("expected the cancelled-booking checkout marked 'failed', got %q", status)
}
// No payment row may exist for the charge.
var payCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = 'sqp_cancelled_booking'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count recorded payments: %v", err)
}
if payCount != 0 {
t.Errorf("expected NO payment recorded on the cancelled booking, got %d rows", payCount)
}
// The owner must be told a charge landed on a cancelled booking.
var notifCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1 AND user_id IS NULL`, bookingID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected a critical-payment admin notification for the cancelled booking, got %d", notifCount)
}
}
@@ -24,6 +24,12 @@ func TestMain(m *testing.M) {
// explicitly. Tests that assert enforcement flip these via t.Setenv. // explicitly. Tests that assert enforcement flip these via t.Setenv.
os.Setenv("REQUIRE_2FA", "") os.Setenv("REQUIRE_2FA", "")
os.Setenv("SQUARE_ENVIRONMENT", "mock") os.Setenv("SQUARE_ENVIRONMENT", "mock")
// The dev mock's CreateCheckout mirrors real Square's requirement that a
// terminal checkout carries device_options.device_id (it 400s when both the
// request DeviceID and this env fallback are empty). Default the terminal
// device id so terminal/till/sweep tests can create checkouts; tests that
// assert the rejection flip it via t.Setenv.
os.Setenv("SQUARE_TERMINAL_DEVICE_ID", "dev-terminal")
testdb.SeedBaseline(pool) testdb.SeedBaseline(pool)
code := m.Run() code := m.Run()
testdb.DestroyTestDatabase(pool, "crussell_test_handlers_payments") testdb.DestroyTestDatabase(pool, "crussell_test_handlers_payments")
+6 -2
View File
@@ -895,9 +895,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return return
} }
// Square succeeded — update the till_sale record. // Square succeeded — update the till_sale record. The status='pending'
// guard (claim-first) prevents resurrecting a sale the stale-pending
// sweep / gift-card clawback resolved to 'failed' while the Square
// charge was in flight: money was taken and the card was already
// clawed back, so the sale must stay failed (0 rows → CRITICAL below).
tillTag, upErr := db.Conn.Exec(ctx, tillTag, upErr := db.Conn.Exec(ctx,
`UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2`, `UPDATE till_sales SET status = 'completed', square_payment_id = $1 WHERE id = $2 AND status = 'pending'`,
paymentResult.SquarePayID, tillSaleID, paymentResult.SquarePayID, tillSaleID,
) )
if upErr != nil { if upErr != nil {
+183
View File
@@ -8,8 +8,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -2712,3 +2714,184 @@ func TestCreateTillSale_PendingRetry_FreshKey_DifferentAmount_NewCharge(t *testi
t.Errorf("expected the original pending row to be untouched, got %s", origStatus) t.Errorf("expected the original pending row to be untouched, got %s", origStatus)
} }
} }
// =============================================================================
// CreateTillSale — the post-charge completion UPDATE is claim-first: a sale
// resolved to 'failed' by the stale-pending sweep / gift-card clawback while
// the Square charge is in flight must NOT be resurrected to 'completed'.
// =============================================================================
// lockedBuffer is a mutex-guarded bytes.Buffer so the standard logger's output
// can be captured and asserted on even when concurrent writers (mock
// auto-complete goroutines) log independently.
type lockedBuffer struct {
buf bytes.Buffer
mu sync.Mutex
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// captureStdLog redirects the standard logger's output to a mutex-guarded
// buffer until the returned restore function is called.
func captureStdLog(t *testing.T) (*lockedBuffer, func()) {
t.Helper()
l := &lockedBuffer{}
orig := log.Writer()
log.SetOutput(l)
return l, func() { log.SetOutput(orig) }
}
// tillSweepResolveClient returns a successful CreatePayment but flips the
// till_sales row to 'failed' first — simulating the stale-pending sweep /
// gift-card clawback resolving the sale mid-flight (status change, row still
// exists) while the Square charge is in flight. The retry's post-charge
// completion UPDATE must then hit 0 rows instead of resurrecting the sale.
type tillSweepResolveClient struct {
square.SquareClient
saleID string
}
func (c *tillSweepResolveClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
if _, err := db.Conn.Exec(context.Background(), `UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1`, c.saleID); err != nil {
log.Printf("failed to simulate sweep resolution for till sale %s: %v", c.saleID, err)
}
return &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: "sqp_sweep_resolved_midflight",
Amount: 5000,
Fees: 88,
CardBrand: "VISA",
CardLast4: "4242",
}, nil
}
// TestCreateTillSale_SweepResolvedMidFlight_NoResurrect locks the claim-first
// guard: a pending till sale retried while the stale-pending sweep / gift-card
// clawback marks the sale 'failed' (the charge was proven never to complete, so
// the funded gift card is clawed back) must NOT be resurrected by the retry's
// post-charge completion UPDATE. The Square charge still succeeds (money moves
// at Square), so the handler detects RowsAffected()==0, logs CRITICAL and fails
// the response — the sale stays 'failed', the row is never flipped back to
// 'completed'.
func TestCreateTillSale_SweepResolvedMidFlight_NoResurrect(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (the retry target) with its funded gift card,
// exactly like TestCreateTillSale_PendingRetry_ReattemptsSquare.
key := "till-sweep-resolve-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
RETURNING id
`, giftCardID, userID, cardID, key, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id IN ($1, $2)`, userID, adminID)
})
// The charge lands at Square while the sweep/clawback flips the sale to
// 'failed' mid-flight.
origClient := SquareClient
SquareClient = &tillSweepResolveClient{SquareClient: square.NewDevClient(), saleID: saleID}
defer func() { SquareClient = origClient }()
logBuf, restore := captureStdLog(t)
defer restore()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected the sweep-resolved retry to fail the response (500), got %d. body: %s", w.Code, w.Body.String())
}
// The sale must NOT be resurrected to 'completed' by the post-charge
// UPDATE — the sweep already resolved it to 'failed'.
var status string
var sqPayID *string
if err := db.Conn.QueryRow(pool, `SELECT status, square_payment_id FROM till_sales WHERE id = $1`, saleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected the sweep-resolved sale to stay 'failed' (not resurrected), got %q", status)
}
if sqPayID != nil {
t.Errorf("expected no square_payment_id written on the sweep-resolved sale, got %q", *sqPayID)
}
// The RowsAffected()==0 branch must have fired its CRITICAL reconciliation
// log (money taken at Square + card funded + row already resolved).
if got := logBuf.String(); !strings.Contains(got, "already resolved (0 rows updated)") {
t.Errorf("expected the CRITICAL 0-rows reconciliation log, got: %s", got)
}
}
+37 -15
View File
@@ -126,24 +126,43 @@ var twoFAMaxTrackedAttempts = 10_000
// twoFAAttemptState tracks consecutive failed verify attempts for one user. The // twoFAAttemptState tracks consecutive failed verify attempts for one user. The
// per-user mutex serializes the whole verify critical section so concurrent // per-user mutex serializes the whole verify critical section so concurrent
// attempts from the same user cannot race the limit check. count is atomic so // attempts from the same user cannot race the limit check. count and lastAt are
// the map eviction path can read it without taking the per-user mutex (lock // atomic so the map eviction path can read them without taking the per-user
// ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then takes mapMu). // mutex (lock ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown). // takes mapMu). lastAt is stored as nanoseconds since the Unix epoch so the
// eviction scan and lockedOut read it race-free even on 32-bit platforms — a
// plain time.Time read/write pair there could tear the 8-byte timestamp and
// reset or extend the lockout window.
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown);
// it is only ever touched under st.mu.
type twoFAAttemptState struct { type twoFAAttemptState struct {
mu sync.Mutex mu sync.Mutex
count atomic.Int32 count atomic.Int32
lastAt time.Time lastAt atomic.Int64
lastMintAt time.Time lastMintAt time.Time
} }
// lastActive returns the state's last-activity timestamp (nanoseconds since the
// Unix epoch, UTC). Reads are atomic so the map eviction scan can call it while
// holding only mapMu.
func (st *twoFAAttemptState) lastActive() time.Time {
return time.Unix(0, st.lastAt.Load()).UTC()
}
// setLastActive records a last-activity timestamp. Writes happen under st.mu
// (checkTwoFACode) while the eviction scan reads under mapMu only — the atomic
// store makes both race-free.
func (st *twoFAAttemptState) setLastActive(t time.Time) {
st.lastAt.Store(t.UnixNano())
}
// lockedOut reports whether the state is inside its lockout window: the attempt // lockedOut reports whether the state is inside its lockout window: the attempt
// counter has reached the cap and the window has not yet elapsed. Such a record // counter has reached the cap and the window has not yet elapsed. Such a record
// is the rate limit's source of truth for its user and must never be evicted // is the rate limit's source of truth for its user and must never be evicted
// while in-window — evicting it would silently reset the counter and grant a // while in-window — evicting it would silently reset the counter and grant a
// fresh guessing budget. // fresh guessing budget.
func (st *twoFAAttemptState) lockedOut(now time.Time) bool { func (st *twoFAAttemptState) lockedOut(now time.Time) bool {
return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastAt) <= twoFAAttemptWindow return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastActive()) <= twoFAAttemptWindow
} }
var ( var (
@@ -168,7 +187,7 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
var oldestID string var oldestID string
var oldestAt time.Time var oldestAt time.Time
for id, st := range twoFAAttemptMap { for id, st := range twoFAAttemptMap {
if now.Sub(st.lastAt) > twoFAAttemptWindow { if now.Sub(st.lastActive()) > twoFAAttemptWindow {
// Idle/expired — its counter has already lapsed; safe to evict. // Idle/expired — its counter has already lapsed; safe to evict.
delete(twoFAAttemptMap, id) delete(twoFAAttemptMap, id)
continue continue
@@ -178,8 +197,8 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
// for this user. Never evict (finding-e fix). // for this user. Never evict (finding-e fix).
continue continue
} }
if oldestID == "" || st.lastAt.Before(oldestAt) { if at := st.lastActive(); oldestID == "" || at.Before(oldestAt) {
oldestID, oldestAt = id, st.lastAt oldestID, oldestAt = id, at
} }
} }
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" { if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
@@ -190,13 +209,16 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
// (that would reset its rate limit) and do not grow past the cap: // (that would reset its rate limit) and do not grow past the cap:
// return a transient, untracked state so THIS request still // return a transient, untracked state so THIS request still
// proceeds under a fresh budget. // proceeds under a fresh budget.
return &twoFAAttemptState{lastAt: now} st := &twoFAAttemptState{}
st.setLastActive(now)
return st
} }
} }
st := twoFAAttemptMap[userID] st := twoFAAttemptMap[userID]
if st == nil { if st == nil {
st = &twoFAAttemptState{lastAt: now} st = &twoFAAttemptState{}
st.setLastActive(now)
twoFAAttemptMap[userID] = st twoFAAttemptMap[userID] = st
} }
return st return st
@@ -394,9 +416,9 @@ const (
// (callers return 500); a lockout's pending-code invalidation failure is logged // (callers return 500); a lockout's pending-code invalidation failure is logged
// here and still reported as a lockout. // here and still reported as a lockout.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) { func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow { if now := clock.Now(); now.Sub(st.lastActive()) > twoFAAttemptWindow {
st.count.Store(0) st.count.Store(0)
st.lastAt = now st.setLastActive(now)
} }
if st.count.Load() >= twoFAMaxAttempts { if st.count.Load() >= twoFAMaxAttempts {
return twoFACodeLockedOut, nil return twoFACodeLockedOut, nil
@@ -422,7 +444,7 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
match, legacy := verifyTwoFACodeHash(reqCode, pendingHash.String) match, legacy := verifyTwoFACodeHash(reqCode, pendingHash.String)
if !match { if !match {
st.count.Add(1) st.count.Add(1)
st.lastAt = clock.Now() st.setLastActive(clock.Now())
if st.count.Load() >= twoFAMaxAttempts { if st.count.Load() >= twoFAMaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest // Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop. // cannot be replayed against a fresh guessing loop.
@@ -453,7 +475,7 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
// Success: clear the attempt counter (and any disable-flow mint cooldown) // Success: clear the attempt counter (and any disable-flow mint cooldown)
// before the caller performs its action. // before the caller performs its action.
st.count.Store(0) st.count.Store(0)
st.lastAt = clock.Now() st.setLastActive(clock.Now())
st.lastMintAt = time.Time{} st.lastMintAt = time.Time{}
twoFAResetAttempts(userID) twoFAResetAttempts(userID)
return twoFACodeOK, nil return twoFACodeOK, nil
+195 -4
View File
@@ -24,6 +24,7 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -681,7 +682,8 @@ func TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// checkTwoFACode (the shared verify path) must accept the legacy hash. // checkTwoFACode (the shared verify path) must accept the legacy hash.
st := &twoFAAttemptState{lastAt: clock.Now()} st := &twoFAAttemptState{}
st.setLastActive(clock.Now())
req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx) req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx)
result, err := checkTwoFACode(req, userID, st, "123456") result, err := checkTwoFACode(req, userID, st, "123456")
require.NoError(t, err) require.NoError(t, err)
@@ -845,9 +847,12 @@ func TestTwoFAAttemptMap_InLockoutRecordNotEvicted(t *testing.T) {
now := clock.Now() now := clock.Now()
for _, id := range []string{"idle_a", "idle_b", "idle_c"} { for _, id := range []string{"idle_a", "idle_b", "idle_c"} {
twoFAAttemptMap[id] = &twoFAAttemptState{lastAt: now.Add(-time.Minute)} st := &twoFAAttemptState{}
st.setLastActive(now.Add(-time.Minute))
twoFAAttemptMap[id] = st
} }
victim := &twoFAAttemptState{lastAt: now.Add(-time.Second)} victim := &twoFAAttemptState{}
victim.setLastActive(now.Add(-time.Second))
victim.count.Store(5) victim.count.Store(5)
twoFAAttemptMap["victim"] = victim twoFAAttemptMap["victim"] = victim
@@ -889,7 +894,8 @@ func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) {
now := clock.Now() now := clock.Now()
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
st := &twoFAAttemptState{lastAt: now.Add(-time.Second)} st := &twoFAAttemptState{}
st.setLastActive(now.Add(-time.Second))
st.count.Store(5) st.count.Store(5)
twoFAAttemptMap[fmt.Sprintf("locked_%d", i)] = st twoFAAttemptMap[fmt.Sprintf("locked_%d", i)] = st
} }
@@ -903,3 +909,188 @@ func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) {
t.Errorf("expected all 3 locked-out records to survive, got %d", len(twoFAAttemptMap)) t.Errorf("expected all 3 locked-out records to survive, got %d", len(twoFAAttemptMap))
} }
} }
// =============================================================================
// Attempt-map concurrency (finding-f — lastAt data race)
// =============================================================================
// TestTwoFAAttemptMap_ConcurrentVerifyAndEviction is a -race smoke test for the
// attempt-map data race: checkTwoFACode's st.mu-guarded writes to count and
// lastAt run concurrently with twoFAAttemptStateFor's mapMu-only eviction scan
// reading them. lastAt is an atomic.Int64 (nanoseconds since the epoch), so the
// scan and lockedOut read it without st.mu — no mutex inversion (mapMu→st.mu is
// forbidden) and no torn 8-byte timestamp. Asserts every concurrent call
// completes (no deadlock), pre-pinned locked-out records survive the eviction
// pressure, and the map never grows past the cap.
func TestTwoFAAttemptMap_ConcurrentVerifyAndEviction(t *testing.T) {
twoFAAttemptMapMu.Lock()
origMap := twoFAAttemptMap
origCap := twoFAMaxTrackedAttempts
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
twoFAMaxTrackedAttempts = 128
twoFAAttemptMapMu.Unlock()
t.Cleanup(func() {
twoFAAttemptMapMu.Lock()
twoFAAttemptMap = origMap
twoFAMaxTrackedAttempts = origCap
twoFAAttemptMapMu.Unlock()
})
const verifyWorkers = 6
const evictWorkers = 4
const iters = 25
// Pre-pin locked-out victims so we can assert afterwards that in-window
// lockout records are never evicted under concurrent pressure.
now := clock.Now()
victims := make(map[string]*twoFAAttemptState, verifyWorkers)
twoFAAttemptMapMu.Lock()
for i := 0; i < verifyWorkers; i++ {
st := &twoFAAttemptState{}
st.setLastActive(now.Add(-time.Second))
st.count.Store(twoFAMaxAttempts)
id := fmt.Sprintf("victim_%d", i)
twoFAAttemptMap[id] = st
victims[id] = st
}
twoFAAttemptMapMu.Unlock()
var wg sync.WaitGroup
// Verifiers mirror checkTwoFACode's critical section on shared states: take
// st.mu, reset an expired window, bump the counter, stamp lastAt, and read
// lockedOut — overlapping the eviction scan's lock-free atomic reads.
for w := 0; w < verifyWorkers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < iters; iter++ {
st := twoFAAttemptStateFor(fmt.Sprintf("verify_%d_%d", w, iter))
st.mu.Lock()
if now := clock.Now(); now.Sub(st.lastActive()) > twoFAAttemptWindow {
st.count.Store(0)
st.setLastActive(now)
}
_ = st.lockedOut(clock.Now())
st.count.Add(1)
st.setLastActive(clock.Now())
st.mu.Unlock()
}
}(w)
}
// Evictors drive twoFAAttemptStateFor's cap-driven eviction scan, which
// reads count + lastAt WITHOUT st.mu — the access pattern under test.
for w := 0; w < evictWorkers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < 2000; iter++ {
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
}
}(w)
}
wg.Wait()
twoFAAttemptMapMu.Lock()
defer twoFAAttemptMapMu.Unlock()
for id, st := range victims {
if _, ok := twoFAAttemptMap[id]; !ok {
t.Errorf("in-window lockout record %s was evicted under concurrent pressure", id)
}
if !st.lockedOut(clock.Now()) {
t.Errorf("victim %s must still report locked out", id)
}
}
if len(twoFAAttemptMap) > twoFAMaxTrackedAttempts {
t.Errorf("map grew past the cap: %d > %d", len(twoFAAttemptMap), twoFAMaxTrackedAttempts)
}
}
// TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock runs the REAL verify path
// concurrently: each goroutine mints its own transaction and user (pgx.Tx is
// not concurrency-safe, so per-goroutine tx avoids sharing one), burns the
// 5-attempt budget to lockout, and asserts lockedOut afterwards — while other
// goroutines hammer the map eviction scan through twoFAAttemptStateFor. The
// test completes only if no goroutine deadlocks on mapMu/st.mu.
func TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock(t *testing.T) {
twoFAAttemptMapMu.Lock()
origMap := twoFAAttemptMap
origCap := twoFAMaxTrackedAttempts
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
twoFAMaxTrackedAttempts = 64
twoFAAttemptMapMu.Unlock()
t.Cleanup(func() {
twoFAAttemptMapMu.Lock()
twoFAAttemptMap = origMap
twoFAMaxTrackedAttempts = origCap
twoFAAttemptMapMu.Unlock()
})
const workers = 6
const iters = 15
var wg sync.WaitGroup
errCh := make(chan error, workers)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
ctx := context.Background()
tx, err := db.Conn.Pool().Begin(ctx)
if err != nil {
errCh <- fmt.Errorf("worker %d begin: %w", w, err)
return
}
defer tx.Rollback(context.Background())
tctx := db.ContextWithTx(ctx, tx)
for iter := 0; iter < iters; iter++ {
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
errCh <- fmt.Errorf("worker %d iter %d create user: %w", w, iter, err)
return
}
seedPendingTwoFA(t, tctx, tx, userID, "123456")
st := twoFAAttemptStateFor(userID)
for attempt := 1; attempt <= twoFAMaxAttempts; attempt++ {
st.mu.Lock()
res, err := checkTwoFACode(httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(tctx), userID, st, "999999")
st.mu.Unlock()
if err != nil {
errCh <- fmt.Errorf("worker %d iter %d check: %w", w, iter, err)
return
}
want := twoFACodeIncorrect
if attempt == twoFAMaxAttempts {
want = twoFACodeLockedOut
}
if res != want {
errCh <- fmt.Errorf("worker %d iter %d attempt %d: got %v, want %v", w, iter, attempt, res, want)
return
}
}
if !st.lockedOut(clock.Now()) {
errCh <- fmt.Errorf("worker %d iter %d: must be locked out after %d wrong codes", w, iter, twoFAMaxAttempts)
return
}
}
}(w)
}
// Concurrent map pressure: twoFAAttemptStateFor reads count + lastAt under
// mapMu only, racing the workers' st.mu-guarded writes (the old data race).
for w := 0; w < 4; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for iter := 0; iter < 1000; iter++ {
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
}
}(w)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
@@ -0,0 +1,397 @@
//go:build test
package webhooks
// Round 7 regression tests — webhook money paths found at 0% coverage:
//
// (a) handleDisputeStateUpdated's findPaymentByDisputeID fallback when a
// dispute.state.updated payload carries no resolvable Square payment id.
// (b) clawbackOneTillSale's non-gift-card branch (a definitively-failed charge
// marks the till sale failed WITHOUT reversing any gift-card funding).
// (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch
// paths that must complete 200 + log without mutating state.
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"strings"
"testing"
"crussell/db"
"crussell/testutils/fixtures"
)
// =============================================================================
// Helpers
// =============================================================================
// createWebhookTestGiftCard inserts a funded gift card and returns its id.
func createWebhookTestGiftCard(t *testing.T, amount float64) string {
t.Helper()
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var id string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES ($1, $1, $2, FALSE, 'SPV')
RETURNING id
`, amount, adminID).Scan(&id); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
return id
}
// createWebhookTestTillSale inserts a pending till sale with the given item
// type and item id (nil for a NULL item_id) and returns the sale id.
func createWebhookTestTillSale(t *testing.T, squarePaymentID, itemType string, itemID any) string {
t.Helper()
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var saleID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_payment_id, created_by, created_at, updated_at)
VALUES ($1, $2, 'webhook round7 test', 1, 40.00, 40.00, 'online_square', 'pending',
$3, $4, NOW(), NOW())
RETURNING id
`, itemType, itemID, squarePaymentID, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to create pending till sale: %v", err)
}
return saleID
}
// createWebhookTestBookingPayment creates a completed payment bound to a fresh
// booking and returns the local payment id and booking id. A booking-scoped
// payment makes the critical-notification assertions below attributable to one
// booking, so they cannot race with other tests' NULL-booking rows.
func createWebhookTestBookingPayment(t *testing.T) (payID, bookingID string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err = fixtures.CreateTestBooking(db.Conn, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
payID, err = fixtures.CreateTestPayment(db.Conn, bookingID, 10.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create test payment: %v", err)
}
return payID, bookingID
}
// countUnackedCriticalNotificationsForBooking returns the number of
// unacknowledged critical_payment_log notifications for a booking.
func countUnackedCriticalNotificationsForBooking(t *testing.T, bookingID string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM admin_notifications
WHERE reason = 'critical_payment_log' AND booking_id = $1 AND acknowledged_at IS NULL
`, bookingID).Scan(&n); err != nil {
t.Fatalf("failed to count critical notifications for booking %s: %v", bookingID, err)
}
return n
}
// =============================================================================
// (a) handleDisputeStateUpdated — findPaymentByDisputeID fallback
// =============================================================================
// TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow
// covers the findPaymentByDisputeID fallback: a dispute.state.updated event
// whose disputed_payment.payment_id is empty cannot resolve the payment via
// square_payment_id, so the handler recovers it from the seeded disputes row.
// A LOST state must still mark the payment failed and raise a CRITICAL
// notification — the fallback must not silently drop the chargeback.
func TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow(t *testing.T) {
payID, bookingID := createWebhookTestBookingPayment(t)
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason)
VALUES ('dts_fallback_1', $1, 'open', 12.34, 'NO_KNOWLEDGE')
`, payID); err != nil {
t.Fatalf("failed to seed dispute row: %v", err)
}
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_fallback_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_fallback_1",
"object": {
"dispute": {
"id": "dts_fallback_1",
"state": "LOST",
"amount_money": {"amount": 1234, "currency": "GBP"},
"reason": "NO_KNOWLEDGE",
"disputed_payment": {"payment_id": ""}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getDisputeStatus(t, "dts_fallback_1"); got != "lost" {
t.Errorf("expected dispute status 'lost', got %q", got)
}
if got := getPaymentStatus(t, payID); got != "failed" {
t.Errorf("expected payment 'failed' after lost dispute recovered via dispute row, got %q", got)
}
// The lost dispute is a CRITICAL money event — the admin notification
// centre must surface it for the payment's booking.
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 1 {
t.Errorf("expected 1 unacknowledged critical_payment_log notification for booking %s, got %d", bookingID, n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation
// covers the fallback's dead end: when neither the payload's (empty) Square
// payment id nor the disputes table yields a payment, the handler returns
// success WITHOUT mutating state — payment untouched, no disputes row, no
// notification — and still commits the dedup row (Square's retry is
// acknowledged 200, not re-dispatched forever).
func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_NoMutation(t *testing.T) {
payID, bookingID := createWebhookTestBookingPayment(t)
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_no_row_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_no_dispute_row_1",
"object": {
"dispute": {
"id": "dts_no_dispute_row_1",
"state": "LOST",
"amount_money": {"amount": 1234, "currency": "GBP"},
"reason": "NO_KNOWLEDGE",
"disputed_payment": {"payment_id": ""}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_no_dispute_row_1'").Scan(&n); err != nil {
t.Fatalf("failed to count disputes: %v", err)
}
if n != 0 {
t.Errorf("expected no disputes row when the fallback finds no payment, got %d", n)
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected payment untouched ('completed') when the fallback finds no dispute, got %q", got)
}
// No critical notification: the handler returns before any insert.
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 0 {
t.Errorf("expected NO critical notification when the fallback finds no dispute, got %d", n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row (the no-op dispatch still commits), got %d", n)
}
}
// =============================================================================
// (b) clawbackOneTillSale — non-gift-card branch
// =============================================================================
// TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback
// covers the non-gift-card branch of clawbackOneTillSale: a retail_product
// till sale (item_type != "gift_card") funded by a definitively-failed Square
// charge is marked failed WITHOUT reversing any gift-card funding — even when
// the sale's item_id happens to reference a real, funded gift card (the LEFT
// JOIN would find it; the item_type guard must short-circuit before any
// reversal).
func TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback(t *testing.T) {
const squarePaymentID = "sqp_clawback_retail"
giftCardID := createWebhookTestGiftCard(t, 60.00)
saleID := createWebhookTestTillSale(t, squarePaymentID, "retail_product", giftCardID)
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getTillSaleStatus(t, saleID); got != "failed" {
t.Errorf("expected till sale 'failed', got %q", got)
}
// No gift-card balance was reversed: the card referenced by the sale's
// item_id keeps its full £60.00 funding.
total, remaining := getGiftCardFunding(t, giftCardID)
if total != 60.00 || remaining != 60.00 {
t.Errorf("expected gift card funding untouched (no clawback for a non-gift-card sale), got total=%v remaining=%v", total, remaining)
}
}
// TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback
// covers the nil-isCreate variant of the same branch: a gift_card till sale
// whose item_id points at NO gift card (dangling id) makes the LEFT JOIN yield
// a NULL is_create — the branch must still mark the sale failed without
// attempting any reversal.
func TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback(t *testing.T) {
const squarePaymentID = "sqp_clawback_dangling"
saleID := createWebhookTestTillSale(t, squarePaymentID, "gift_card", "GCMISSING001")
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getTillSaleStatus(t, saleID); got != "failed" {
t.Errorf("expected till sale 'failed', got %q", got)
}
// The missing card means there is nothing to claw back — and the handler
// must not error out: the webhook acknowledges 200 with a dedup row.
if n := countWebhookEvents(t, "evt_"+squarePaymentID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// =============================================================================
// (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch
// =============================================================================
// TestWebhook_DisputeEvidence_InformationalOnly covers both
// dispute.evidence.created and dispute.evidence.deleted: the handler logs and
// acknowledges 200 without writing a disputes row or raising any notification.
func TestWebhook_DisputeEvidence_InformationalOnly(t *testing.T) {
cases := []struct {
eventType string
disputeID string
eventID string
}{
{"dispute.evidence.created", "dts_evidence_created_1", "evt_evidence_created_1"},
{"dispute.evidence.deleted", "dts_evidence_deleted_1", "evt_evidence_deleted_1"},
}
for _, tc := range cases {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + tc.disputeID + `",
"object": {
"dispute": {
"id": "` + tc.disputeID + `",
"state": "EVIDENCE_REQUIRED"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String())
}
// No state mutation: no disputes row is written for an evidence event.
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM disputes WHERE square_dispute_id = $1", tc.disputeID).Scan(&n); err != nil {
t.Fatalf("failed to count disputes: %v", err)
}
if n != 0 {
t.Errorf("expected no disputes row from %s, got %d", tc.eventType, n)
}
if got := countWebhookEvents(t, tc.eventID); got != 1 {
t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got)
}
}
// The handler logs the evidence event (informational only).
var buf bytes.Buffer
oldOutput := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOutput)
event := SquareWebhookEvent{
Type: "dispute.evidence.created",
EventID: "evt_evidence_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_evidence_log_1",
"object": {"dispute": {"id": "dts_evidence_log_1", "state": "UNDER_REVIEW"}}
}`),
}
if w := deliverWebhook(t, event); w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if out := buf.String(); !strings.Contains(out, "dispute evidence event for dispute") {
t.Errorf("expected an informational evidence log line, got:\n%s", out)
}
}
// TestWebhook_TerminalCheckout_InformationalOnly covers both
// terminal.checkout.created and terminal.checkout.updated: the handler logs
// and acknowledges 200 without writing any terminal_checkouts row.
func TestWebhook_TerminalCheckout_InformationalOnly(t *testing.T) {
cases := []struct {
eventType string
checkoutID string
eventID string
}{
{"terminal.checkout.created", "chk_round7_created_1", "evt_terminal_created_1"},
{"terminal.checkout.updated", "chk_round7_updated_1", "evt_terminal_updated_1"},
}
for _, tc := range cases {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "` + tc.checkoutID + `"}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String())
}
// No state mutation: no terminal_checkouts row is written.
var n int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM terminal_checkouts WHERE checkout_id = $1", tc.checkoutID).Scan(&n); err != nil {
t.Fatalf("failed to count terminal checkouts: %v", err)
}
if n != 0 {
t.Errorf("expected no terminal_checkouts row from %s, got %d", tc.eventType, n)
}
if got := countWebhookEvents(t, tc.eventID); got != 1 {
t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got)
}
}
var buf bytes.Buffer
oldOutput := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOutput)
event := SquareWebhookEvent{
Type: "terminal.checkout.updated",
EventID: "evt_terminal_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "chk_round7_log_1"}`),
}
if w := deliverWebhook(t, event); w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if out := buf.String(); !strings.Contains(out, "terminal.checkout event received") {
t.Errorf("expected an informational terminal.checkout log line, got:\n%s", out)
}
}
+98 -33
View File
@@ -22,15 +22,18 @@ package square
// //
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail, // FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout, // FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateCardTokenUsed) that let dev/tests drive Square // FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus) that let dev/tests
// failure modes that are otherwise only reachable against the real API. // drive Square failure modes that are otherwise only reachable against the real
// FailAfterCommit simulates the exact "charged but response lost → same-key // API. FailAfterCommit simulates the exact "charged but response lost → same-key
// retry" prod scenario: CreatePayment COMMITS the charge (retaining the key // retry" prod scenario: CreatePayment COMMITS the charge (retaining the key
// and source in the ledgers exactly like a successful charge) and THEN returns // and source in the ledgers exactly like a successful charge) and THEN returns
// a 5xx-style error to the caller. A subsequent CreatePayment with the SAME // a 5xx-style error to the caller. A subsequent CreatePayment with the SAME
// key + SAME source dedups to the committed payment, proving no double charge. // key + SAME source dedups to the committed payment, proving no double charge.
// SimulateCardTokenUsed simulates Square's CARD_TOKEN_USED rejection of a card // SimulateSourceUsed simulates Square's SOURCE_USED rejection of a card source
// token (cnon: nonce) reused after a previous save. // (cnon: nonce) reused after a previous save. ForcePaymentStatus forces
// CreatePayment's payment status while returning nil error — the "Square
// returned 200 with a non-terminal payment" prod scenario, so a status-blind
// handler (records 'completed' on nil error alone) is caught in dev.
// //
// REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to // REAL-API SAFETY GUARD. A `//go:build dev` build must never silently route to
// the real PRODUCTION Square API on an env-string match alone — a typo'd or // the real PRODUCTION Square API on an env-string match alone — a typo'd or
@@ -111,18 +114,29 @@ type MockClient struct {
// committed payment — never a second charge — exercising the retry path // committed payment — never a second charge — exercising the retry path
// devs hit in prod when Square processes a charge but the response is lost. // devs hit in prod when Square processes a charge but the response is lost.
FailAfterCommit bool FailAfterCommit bool
// SimulateCardTokenUsed makes CreateCardOnFile enforce Square's // SimulateSourceUsed makes CreateCardOnFile enforce Square's SOURCE_USED
// CARD_TOKEN_USED rejection: a card token (cnon: nonce) already used to // rejection: a card source (cnon: nonce) already used to create a card on
// create a card on this mock instance is rejected with the same structured // this mock instance is rejected with the same structured 400 SOURCE_USED
// 400 CARD_TOKEN_USED error real Square returns. Off by default — dev/test // error real Square's CreateCard API returns (SOURCE_USED — NOT the
// flows reuse plain "cnon:test-card"-style tokens across requests, so // CreatePayment code CARD_TOKEN_USED). Off by default — dev/test flows
// enforcement is enabled only in tests that exercise the reused-token // reuse plain "cnon:test-card"-style tokens across requests, so enforcement
// rejection. UsedCardTokens() reports the tokens consumed so far. // is enabled only in tests that exercise the reused-source rejection.
SimulateCardTokenUsed bool // UsedSources() reports the sources consumed so far.
// usedCardTokens records card tokens consumed by CreateCardOnFile while SimulateSourceUsed bool
// SimulateCardTokenUsed is enabled (Square consumes a cnon: nonce on card // usedSources records card sources consumed by CreateCardOnFile while
// creation, so reusing it is rejected with CARD_TOKEN_USED). // SimulateSourceUsed is enabled (Square consumes a cnon: nonce on card
usedCardTokens map[string]bool // creation, so reusing it is rejected with SOURCE_USED).
usedSources map[string]bool
// ForcePaymentStatus forces CreatePayment's payment status instead of the
// default "COMPLETED" (or "APPROVED" for autocomplete=false). When set,
// CreatePayment returns a payment carrying the forced status with nil
// error — the "Square returned 200 with a non-terminal payment" prod
// scenario. It proves a status-blind handler (one that records 'completed'
// on nil error alone) is a regression: the client surfaces Status
// faithfully (paymentFromSquare never errors on a non-terminal status —
// see square_http_client.go), so only the handler's own status check can
// catch a FAILED/CANCELED/PENDING/APPROVED payment.
ForcePaymentStatus string
} }
type devProdClient struct{} type devProdClient struct{}
@@ -211,7 +225,7 @@ func NewDevClient() SquareClient {
refundByKey: make(map[string]*RefundResult), refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult), customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult), completed: make(map[string]*PaymentResult),
usedCardTokens: make(map[string]bool), usedSources: make(map[string]bool),
} }
} }
} }
@@ -326,6 +340,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
if req.Autocomplete != nil && !*req.Autocomplete { if req.Autocomplete != nil && !*req.Autocomplete {
status = "APPROVED" status = "APPROVED"
} }
if m.ForcePaymentStatus != "" {
// Drive the "Square returned 200 with a non-terminal payment" prod
// scenario: the payment comes back with a non-default status and nil
// error, so a status-blind caller (records 'completed' on nil error
// alone) is exposed as a regression.
status = m.ForcePaymentStatus
}
amount := req.Amount amount := req.Amount
tipAmount := int64(0) tipAmount := int64(0)
@@ -343,6 +364,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
} }
paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano()) paymentID := fmt.Sprintf("pay_mock_%d", now.UnixNano())
// Sign-convention parity (finding A): Square reports processing_fee amounts
// as NEGATIVE on the wire, and paymentFromSquare negates them so
// PaymentResult.Fees is POSITIVE — the value handlers store as p.fees. The
// mock fabricates the same positive magnitude directly: online rate 1.4% +
// 25p (amount*14/1000+25). Mock and real client must agree on the sign;
// see TestProcessingFeeSign_Parity_MockAndRealClientAgree.
fees := amount*14/1000 + 25 // online rate: 1.4% + 25p fees := amount*14/1000 + 25 // online rate: 1.4% + 25p
locationID := req.LocationID locationID := req.LocationID
@@ -404,6 +431,26 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
if m.FailCreateCheckout { if m.FailCreateCheckout {
return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)") return nil, fmt.Errorf("mock: checkout creation failed (simulated failure)")
} }
// Real Square's TerminalCheckout API REQUIRES device_options.device_id: a
// checkout with an empty device id is rejected with a 400. The real client
// resolves the per-request device ID with an env fallback
// (SQUARE_TERMINAL_DEVICE_ID, square_http_client.go:516); the mock mirrors
// the SAME resolution and rejects when neither is set — so a missing
// terminal misconfiguration is caught in dev instead of silently
// "succeeding" where prod 400s.
deviceID := req.DeviceID
if deviceID == "" {
deviceID = os.Getenv("SQUARE_TERMINAL_DEVICE_ID")
}
if deviceID == "" {
return nil, &squareAPIError{
Code: "INVALID_REQUEST_ERROR",
Detail: "device_options.device_id is required to create a terminal checkout",
Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest,
err: errors.New("square: device_options.device_id is required for a terminal checkout (set SQUARE_TERMINAL_DEVICE_ID or pass DeviceID)"),
}
}
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID) log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
now := clock.Now().UTC() now := clock.Now().UTC()
@@ -488,6 +535,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
ReferenceID: req.ReferenceID, ReferenceID: req.ReferenceID,
} }
m.completed[checkoutID] = paymentResult m.completed[checkoutID] = paymentResult
// Real Square registers the terminal payment under its own ID:
// GET /v2/payments/{id} succeeds on a completed terminal
// checkout's payment in prod, but failed in dev because the
// payment was never added to m.payments (finding I). Mirror prod
// by registering it under both the ID and SquarePayID keys, exactly
// like CreatePayment, so the reconcile/sweep GetPayment path
// behaves identically.
m.payments[paymentID] = paymentResult
m.payments[paymentResult.SquarePayID] = paymentResult
m.checkouts[checkoutID].Status = "COMPLETED" m.checkouts[checkoutID].Status = "COMPLETED"
m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339) m.checkouts[checkoutID].UpdatedAt = payNow.Format(time.RFC3339)
m.checkouts[checkoutID].PaymentIDs = []string{paymentID} m.checkouts[checkoutID].PaymentIDs = []string{paymentID}
@@ -697,15 +753,15 @@ func (m *MockClient) RefundKeyCount() int {
return len(m.refundByKey) return len(m.refundByKey)
} }
// UsedCardTokens returns the card tokens consumed by CreateCardOnFile while // UsedSources returns the card sources consumed by CreateCardOnFile while
// SimulateCardTokenUsed is enabled. Test accessor for asserting that a reused // SimulateSourceUsed is enabled. Test accessor for asserting that a reused
// token is rejected with CARD_TOKEN_USED after a previous save. // source is rejected with SOURCE_USED after a previous save.
func (m *MockClient) UsedCardTokens() []string { func (m *MockClient) UsedSources() []string {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
out := make([]string, 0, len(m.usedCardTokens)) out := make([]string, 0, len(m.usedSources))
for tok := range m.usedCardTokens { for src := range m.usedSources {
out = append(out, tok) out = append(out, src)
} }
return out return out
} }
@@ -738,16 +794,18 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.SimulateCardTokenUsed && m.usedCardTokens[cardToken] { if m.SimulateSourceUsed && m.usedSources[cardToken] {
// Real Square consumes a cnon: nonce on card creation — reusing it to // Real Square consumes a cnon: nonce on card creation — reusing it to
// create another card is rejected with CARD_TOKEN_USED. The mock // create another card is rejected with SOURCE_USED (the CreateCard
// mirrors that structured 400 rejection (opt-in, see the struct doc). // error; CARD_TOKEN_USED is a CreatePayment code and would be wrong
// here). The mock mirrors that structured 400 rejection (opt-in, see
// the struct doc).
return nil, &squareAPIError{ return nil, &squareAPIError{
Code: "CARD_TOKEN_USED", Code: "SOURCE_USED",
Detail: "The card token has already been used.", Detail: "The provided source id was already used to create a card",
Category: "INVALID_REQUEST_ERROR", Category: "INVALID_REQUEST_ERROR",
StatusCode: http.StatusBadRequest, StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: card token %s has already been used", tokenPrefix(cardToken)), err: fmt.Errorf("square: card source %s has already been used to create a card", tokenPrefix(cardToken)),
} }
} }
@@ -779,8 +837,8 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
} }
m.cards[userID][cardID] = card m.cards[userID][cardID] = card
m.cardByToken[card.CardID] = card m.cardByToken[card.CardID] = card
if m.SimulateCardTokenUsed { if m.SimulateSourceUsed {
m.usedCardTokens[cardToken] = true m.usedSources[cardToken] = true
} }
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4) log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil return card, nil
@@ -799,6 +857,13 @@ func (m *MockClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardO
var cards []CardOnFile var cards []CardOnFile
for _, card := range userCards { for _, card := range userCards {
// Real Square's List Cards API EXCLUDES disabled cards by default
// (the client sends no include_disabled param) — a disabled/deleted
// card disappears from GetCardsOnFile. Mirror that so dev parity
// matches prod (finding C).
if !card.Enabled {
continue
}
cards = append(cards, *card) cards = append(cards, *card)
} }
return cards, nil return cards, nil
+197 -33
View File
@@ -83,6 +83,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
IdempotencyKey: "checkout-key-1", IdempotencyKey: "checkout-key-1",
ReferenceID: "booking-456", ReferenceID: "booking-456",
AllowTipping: true, AllowTipping: true,
DeviceID: "dvc_test",
} }
result, err := client.CreateCheckout(ctx, req) result, err := client.CreateCheckout(ctx, req)
@@ -126,6 +127,7 @@ func TestDevClient_CreateCheckout_DeadlineDurationFormat(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "checkout-deadline", IdempotencyKey: "checkout-deadline",
ReferenceID: "deadline-ref", ReferenceID: "deadline-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "PT5M", result.Deadline, "deadline_duration must be an RFC 3339 duration, not a timestamp") assert.Equal(t, "PT5M", result.Deadline, "deadline_duration must be an RFC 3339 duration, not a timestamp")
@@ -146,6 +148,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
IdempotencyKey: "checkout-key-notip", IdempotencyKey: "checkout-key-notip",
ReferenceID: "booking-789", ReferenceID: "booking-789",
AllowTipping: false, AllowTipping: false,
DeviceID: "dvc_test",
} }
result, err := client.CreateCheckout(ctx, req) result, err := client.CreateCheckout(ctx, req)
@@ -267,11 +270,11 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
err = client.DeleteCardOnFile(ctx, card.ID) err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err) require.NoError(t, err)
// Square's List Cards API excludes disabled cards by default — a
// disabled/deleted card is no longer returned by GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID) cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
require.Len(t, cards, 1)
assert.False(t, cards[0].Enabled)
} }
func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) { func TestDevClient_CardOnFile_DeleteNotFound(t *testing.T) {
@@ -724,10 +727,11 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
err = client.DeleteCardOnFile(ctx, card.ID) err = client.DeleteCardOnFile(ctx, card.ID)
require.NoError(t, err) require.NoError(t, err)
// Square's List Cards API excludes disabled cards by default — the
// disabled card is no longer returned by GetCardsOnFile.
cards, err := client.GetCardsOnFile(ctx, userID) cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, cards, 1) assert.Empty(t, cards, "a disabled card must be excluded from GetCardsOnFile like Square's List Cards")
assert.False(t, cards[0].Enabled)
} }
func TestDevClient_GetCardsOnFile_Empty(t *testing.T) { func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
@@ -900,6 +904,7 @@ func TestDevClient_GetCheckout_StillPending(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "pending-checkout", IdempotencyKey: "pending-checkout",
ReferenceID: "pending-ref", ReferenceID: "pending-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status) assert.Equal(t, "PENDING", result.Status)
@@ -919,6 +924,7 @@ func TestDevClient_CreateCheckout_HoldCheckouts(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "hold-checkout", IdempotencyKey: "hold-checkout",
ReferenceID: "hold-ref", ReferenceID: "hold-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status) assert.Equal(t, "PENDING", result.Status)
@@ -940,6 +946,7 @@ func TestDevClient_GetCheckout_ForceInProgress(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "in-progress-checkout", IdempotencyKey: "in-progress-checkout",
ReferenceID: "in-progress-ref", ReferenceID: "in-progress-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "IN_PROGRESS", result.Status) assert.Equal(t, "IN_PROGRESS", result.Status)
@@ -971,6 +978,7 @@ func TestDevClient_GetCheckout_ForceCancelRequested(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "cancel-requested-checkout", IdempotencyKey: "cancel-requested-checkout",
ReferenceID: "cancel-requested-ref", ReferenceID: "cancel-requested-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "CANCEL_REQUESTED", result.Status) assert.Equal(t, "CANCEL_REQUESTED", result.Status)
@@ -1000,6 +1008,7 @@ func TestDevClient_GetCheckout_ForceCanceled(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "canceled-checkout", IdempotencyKey: "canceled-checkout",
ReferenceID: "canceled-ref", ReferenceID: "canceled-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "CANCELED", result.Status) assert.Equal(t, "CANCELED", result.Status)
@@ -1228,6 +1237,7 @@ func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "cancel-checkout", IdempotencyKey: "cancel-checkout",
ReferenceID: "cancel-ref", ReferenceID: "cancel-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "PENDING", result.Status) assert.Equal(t, "PENDING", result.Status)
@@ -1261,6 +1271,7 @@ func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) {
Currency: "GBP", Currency: "GBP",
IdempotencyKey: "cancel-completed", IdempotencyKey: "cancel-completed",
ReferenceID: "cancel-comp-ref", ReferenceID: "cancel-comp-ref",
DeviceID: "dvc_test",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -1598,39 +1609,41 @@ func TestDevClient_CreatePayment_RejectsOversizedIdempotencyKey(t *testing.T) {
assert.Equal(t, "COMPLETED", ok.Status) assert.Equal(t, "COMPLETED", ok.Status)
} }
// TestDevClient_CreateCardOnFile_SimulateCardTokenUsed locks the CARD_TOKEN_USED // TestDevClient_CreateCardOnFile_SimulateSourceUsed locks the SOURCE_USED
// simulation: when SimulateCardTokenUsed is enabled, a card token (cnon: nonce) // simulation: when SimulateSourceUsed is enabled, a card source (cnon: nonce)
// reused after a previous save is rejected with Square's structured 400 // reused after a previous save is rejected with Square's structured 400
// CARD_TOKEN_USED error. Off by default (dev/test flows reuse plain test // SOURCE_USED error (the CreateCard error for a reused source — NOT the
// tokens across requests), so the toggle must not reject reuse when disabled. // CreatePayment code CARD_TOKEN_USED). Off by default (dev/test flows reuse
func TestDevClient_CreateCardOnFile_SimulateCardTokenUsed(t *testing.T) { // plain test tokens across requests), so the toggle must not reject reuse when
// disabled.
func TestDevClient_CreateCardOnFile_SimulateSourceUsed(t *testing.T) {
client := NewDevClient().(*MockClient) client := NewDevClient().(*MockClient)
client.SimulateCardTokenUsed = true client.SimulateSourceUsed = true
ctx := context.Background() ctx := context.Background()
card, err := client.CreateCardOnFile(ctx, "user-token-used", "cnon:single-use-nonce", "cus_test123") card, err := client.CreateCardOnFile(ctx, "user-token-used", "cnon:single-use-nonce", "cus_test123")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, card.ID) assert.NotEmpty(t, card.ID)
// Reusing the same token → Square's CARD_TOKEN_USED rejection. // Reusing the same source → Square's SOURCE_USED rejection.
_, err = client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:single-use-nonce", "cus_test123") _, err = client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:single-use-nonce", "cus_test123")
require.Error(t, err) require.Error(t, err)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(err)) assert.Equal(t, "SOURCE_USED", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedCardTokens()) assert.ElementsMatch(t, []string{"cnon:single-use-nonce"}, client.UsedSources())
// A fresh token still works. // A fresh source still works.
fresh, err := client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:fresh-nonce", "cus_test123") fresh, err := client.CreateCardOnFile(ctx, "user-token-used-2", "cnon:fresh-nonce", "cus_test123")
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, fresh.ID) assert.NotEmpty(t, fresh.ID)
// With the toggle OFF (default), reusing a token is allowed — dev/test // With the toggle OFF (default), reusing a source is allowed — dev/test
// flows reuse plain "cnon:test-card"-style tokens across requests. // flows reuse plain "cnon:test-card"-style tokens across requests.
client.SimulateCardTokenUsed = false client.SimulateSourceUsed = false
_, err = client.CreateCardOnFile(ctx, "user-token-reuse", "cnon:reused-token", "cus_test123") _, err = client.CreateCardOnFile(ctx, "user-token-reuse", "cnon:reused-token", "cus_test123")
require.NoError(t, err) require.NoError(t, err)
_, err = client.CreateCardOnFile(ctx, "user-token-reuse-2", "cnon:reused-token", "cus_test123") _, err = client.CreateCardOnFile(ctx, "user-token-reuse-2", "cnon:reused-token", "cus_test123")
require.NoError(t, err, "with SimulateCardTokenUsed off, token reuse must be allowed") require.NoError(t, err, "with SimulateSourceUsed off, source reuse must be allowed")
} }
// TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and // TestIdempotencyKeyLength_Parity_MockAndRealClientAgree asserts the mock and
@@ -1668,37 +1681,38 @@ func TestIdempotencyKeyLength_Parity_MockAndRealClientAgree(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
} }
// TestCardTokenUsed_Parity_MockAndRealClientAgree asserts the mock and the // TestSourceUsed_Parity_MockAndRealClientAgree asserts the mock and the
// real HTTP client AGREE on the reused-card-token rejection: both surface the // real HTTP client AGREE on the reused-card-source rejection: both surface the
// same structured code (CARD_TOKEN_USED) and HTTP status (400). // same structured code (SOURCE_USED — Square's CreateCard error, NOT the
func TestCardTokenUsed_Parity_MockAndRealClientAgree(t *testing.T) { // CreatePayment code CARD_TOKEN_USED) and HTTP status (400).
func TestSourceUsed_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background() ctx := context.Background()
token := "cnon:reused-nonce" source := "cnon:reused-nonce"
// Real client: Square's 400 CARD_TOKEN_USED response surfaces as a // Real client: Square's 400 SOURCE_USED response surfaces as a
// structured squareAPIError. // structured squareAPIError.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"CARD_TOKEN_USED","detail":"The card token has already been used."}]}`)) _, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"SOURCE_USED","detail":"The provided source id was already used to create a card"}]}`))
})) }))
defer srv.Close() defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
_, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", token, "cus_1", hc) _, realErr := createCardOnFileHTTPWithClient(ctx, "user_1", source, "cus_1", hc)
require.Error(t, realErr) require.Error(t, realErr)
// Mock: with the simulation enabled, reusing a consumed token surfaces the // Mock: with the simulation enabled, reusing a consumed source surfaces the
// identical structured error. // identical structured error.
mock := NewDevClient().(*MockClient) mock := NewDevClient().(*MockClient)
mock.SimulateCardTokenUsed = true mock.SimulateSourceUsed = true
_, err := mock.CreateCardOnFile(ctx, "user_1", token, "cus_1") _, err := mock.CreateCardOnFile(ctx, "user_1", source, "cus_1")
require.NoError(t, err) require.NoError(t, err)
_, mockErr := mock.CreateCardOnFile(ctx, "user_2", token, "cus_1") _, mockErr := mock.CreateCardOnFile(ctx, "user_2", source, "cus_1")
require.Error(t, mockErr) require.Error(t, mockErr)
assert.Equal(t, "CARD_TOKEN_USED", ErrorCode(realErr)) assert.Equal(t, "SOURCE_USED", ErrorCode(realErr))
assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card token") assert.Equal(t, ErrorCode(realErr), ErrorCode(mockErr), "mock and real client must agree on the error code for a reused card source")
assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card token") assert.Equal(t, ErrorStatusCode(realErr), ErrorStatusCode(mockErr), "mock and real client must agree on the error status for a reused card source")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr)) assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(mockErr))
} }
@@ -1726,3 +1740,153 @@ func TestEnvResolution_HelperMatchesHTTPClient(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "mock") t.Setenv("SQUARE_ENVIRONMENT", "mock")
assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)") assert.Equal(t, squareSandboxURL, newHTTPClient().baseURL, "any non-production env resolves the sandbox base URL (never the production URL)")
} }
// TestProcessingFeeSign_Parity_MockAndRealClientAgree locks the
// processing-fee sign convention: Square reports processing_fee amounts as
// NEGATIVE on the wire and paymentFromSquare negates them so PaymentResult.Fees
// is POSITIVE — the magnitude the handlers store as p.fees. The mock
// fabricates the same positive magnitude directly, so mock and real client must
// agree on the value (finding A).
func TestProcessingFeeSign_Parity_MockAndRealClientAgree(t *testing.T) {
ctx := context.Background()
amount := int64(5000)
wantFees := int64(5000*14/1000 + 25) // 1.4% + 25p on £50.00 = 95p
// Real client: Square's wire processing_fee is negative; paymentFromSquare
// must surface the positive magnitude.
pr := paymentFromSquare(&sqPayment{
ID: "pay_fee",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: amount, Currency: "GBP"},
ProcessingFee: []sqFee{
{AmountMoney: sqMoney{Amount: -wantFees, Currency: "GBP"}, Type: "INITIAL"},
},
})
assert.Equal(t, wantFees, pr.Fees, "real client must negate Square's negative processing_fee into a positive PaymentResult.Fees")
// Mock: fabricates the same positive fee.
mock := NewDevClient().(*MockClient)
got, err := mock.CreatePayment(ctx, CreatePaymentReq{
Amount: amount, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "fee-parity-key",
})
require.NoError(t, err)
assert.Equal(t, wantFees, got.Fees, "mock and real client must agree on the positive fee magnitude")
}
// TestDevClient_GetCardsOnFile_ExcludesDisabled locks the List Cards parity:
// real Square's List Cards API EXCLUDES disabled cards by default (the client
// sends no include_disabled param), so a card disabled via DeleteCardOnFile
// must disappear from GetCardsOnFile — exactly like prod (finding C).
func TestDevClient_GetCardsOnFile_ExcludesDisabled(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
userID := "user-disabled-exclusion"
c1, err := client.CreateCardOnFile(ctx, userID, "cnon:enabled-card", "cus_test123")
require.NoError(t, err)
c2, err := client.CreateCardOnFile(ctx, userID, "cnon:to-be-disabled", "cus_test123")
require.NoError(t, err)
require.NoError(t, client.DeleteCardOnFile(ctx, c2.ID))
cards, err := client.GetCardsOnFile(ctx, userID)
require.NoError(t, err)
require.Len(t, cards, 1, "only the enabled card must be listed")
assert.Equal(t, c1.ID, cards[0].ID)
}
// TestDevClient_CreatePayment_ForcePaymentStatus drives the "Square returned
// 200 with a non-terminal payment" prod scenario: with ForcePaymentStatus set,
// CreatePayment returns a payment carrying a non-default status with NIL error
// (the client never errors on a status — see paymentFromSquare). A status-blind
// handler that records 'completed' on nil error alone would mis-record these;
// the toggle makes that regression exercisable in dev (finding D).
func TestDevClient_CreatePayment_ForcePaymentStatus(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
for _, status := range []string{"FAILED", "CANCELED", "APPROVED", "PENDING"} {
t.Run(status, func(t *testing.T) {
client.ForcePaymentStatus = status
res, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "forced-status-" + status,
})
require.NoError(t, err, "a forced status must still return nil error (the client is status-transparent)")
assert.Equal(t, status, res.Status, "the payment must carry the forced status")
got, err := client.GetPayment(ctx, res.ID)
require.NoError(t, err)
assert.Equal(t, status, got.Status, "GetPayment must surface the same status")
})
}
}
// TestDevClient_CreateCheckout_RequiresDeviceID locks the TerminalCheckout
// device_id parity: real Square REQUIRES device_options.device_id (400 on
// empty). The mock resolves the per-request device ID with the same env
// fallback as the real client (SQUARE_TERMINAL_DEVICE_ID) and rejects when
// neither is set — a missing terminal misconfiguration is caught in dev
// (finding H).
func TestDevClient_CreateCheckout_RequiresDeviceID(t *testing.T) {
ctx := context.Background()
t.Run("empty_device_id_is_rejected", func(t *testing.T) {
t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "")
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-no-device",
})
require.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err))
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
})
t.Run("request_device_id_is_accepted", func(t *testing.T) {
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-req-device", DeviceID: "dvc_req",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", res.Status)
})
t.Run("env_device_id_fallback_is_accepted", func(t *testing.T) {
t.Setenv("SQUARE_TERMINAL_DEVICE_ID", "dvc_env")
client := NewDevClient().(*MockClient)
res, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 5000, Currency: "GBP", IdempotencyKey: "chk-env-device",
})
require.NoError(t, err)
assert.Equal(t, "PENDING", res.Status)
})
}
// TestDevClient_CreateCheckout_CompletedPaymentResolvableByID locks the
// terminal-payment registration parity: a completed terminal checkout's payment
// must be resolvable via GetPayment (GET /v2/payments/{id} in prod), not just
// via GetCheckout. The mock previously never stored it in m.payments, so
// GetPayment failed in dev where prod succeeded (finding I).
func TestDevClient_CreateCheckout_CompletedPaymentResolvableByID(t *testing.T) {
client := NewDevClient().(*MockClient)
ctx := context.Background()
checkout, err := client.CreateCheckout(ctx, CreateCheckoutReq{
Amount: 7500, Currency: "GBP", IdempotencyKey: "chk-payment-resolvable", DeviceID: "dvc_test",
})
require.NoError(t, err)
var completed *PaymentResult
assert.Eventually(t, func() bool {
var getErr error
completed, getErr = client.GetCheckout(ctx, checkout.ID)
return getErr == nil && completed.Status == "COMPLETED"
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
// The completed terminal payment must resolve by ID — the sweep's
// reconcile-by-id GetPayment path, which prod supports.
got, err := client.GetPayment(ctx, completed.ID)
require.NoError(t, err)
assert.Equal(t, completed.ID, got.ID)
assert.Equal(t, "COMPLETED", got.Status)
}
+21 -1
View File
@@ -996,6 +996,19 @@ func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *ht
// Conversion helpers — Square JSON → domain types. // Conversion helpers — Square JSON → domain types.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// paymentFromSquare maps Square's Payment object into the domain PaymentResult.
// Status is copied FAITHFULLY (COMPLETED/APPROVED/PENDING/FAILED/CANCELED are
// all surfaced verbatim) — the client deliberately does NOT turn a non-terminal
// status into an error. doJSON already parses 2xx bodies without dropping them,
// so a 200-with-FAILED payment reaches the caller with nil error and Status
// "FAILED". This is intentional: the payments sweep reconciles by id via
// GetPayment/ReplayPaymentByKey and SWITCHES on pr.Status
// (reconcileStalePaymentAtSquare marks a FAILED/CANCELED payment
// definitively-failed, leaves APPROVED/PENDING pending). If the client returned
// an error for a FAILED payment, the sweep would classify it as an ambiguous
// "leave pending" instead — strictly worse. Handlers must therefore check
// Status, not nil-error alone; the mock's ForcePaymentStatus toggle exists so a
// status-blind handler regression is exercisable in dev.
func paymentFromSquare(sq *sqPayment) *PaymentResult { func paymentFromSquare(sq *sqPayment) *PaymentResult {
r := &PaymentResult{ r := &PaymentResult{
ID: sq.ID, ID: sq.ID,
@@ -1015,8 +1028,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
if sq.TipMoney != nil { if sq.TipMoney != nil {
r.TipAmount = sq.TipMoney.Amount r.TipAmount = sq.TipMoney.Amount
} }
// Square reports processing_fee amounts as NEGATIVE (money withheld from the
// gross charge) or zero — never positive. PaymentResult.Fees is the POSITIVE
// magnitude the handlers store into p.fees (accounting sums a positive
// total_square_fees), so the raw negative Square amounts are negated at this
// boundary. The dev mock fabricates the same positive magnitude directly, so
// mock and real client agree on the sign convention (see
// TestProcessingFeeSign_Parity_MockAndRealClientAgree).
for _, f := range sq.ProcessingFee { for _, f := range sq.ProcessingFee {
r.Fees += f.AmountMoney.Amount r.Fees += -f.AmountMoney.Amount
} }
if sq.CardDetails != nil { if sq.CardDetails != nil {
cd := sq.CardDetails cd := sq.CardDetails
@@ -1686,3 +1686,100 @@ func TestDeleteCustomerHTTP(t *testing.T) {
} }
}) })
} }
// TestPaymentFromSquare_NegatesProcessingFees locks the processing-fee sign
// convention (finding A): Square reports processing_fee amounts as NEGATIVE on
// the wire, and paymentFromSquare must surface a POSITIVE PaymentResult.Fees
// (the magnitude handlers store as p.fees). A fee of -95 on the wire must
// become Fees == 95.
func TestPaymentFromSquare_NegatesProcessingFees(t *testing.T) {
p := &sqPayment{
ID: "pay_fee",
Status: "COMPLETED",
TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
ProcessingFee: []sqFee{
{AmountMoney: sqMoney{Amount: -95, Currency: "GBP"}, Type: "INITIAL"},
{AmountMoney: sqMoney{Amount: -20, Currency: "GBP"}, Type: "SECONDARY"},
},
}
result := paymentFromSquare(p)
if result.Fees != 115 {
t.Errorf("expected Fees 115 (sum of negated Square fees), got %d", result.Fees)
}
// A zero fee stays zero.
zero := paymentFromSquare(&sqPayment{ID: "pay_zero", Status: "COMPLETED", ProcessingFee: []sqFee{{AmountMoney: sqMoney{Amount: 0, Currency: "GBP"}}}})
if zero.Fees != 0 {
t.Errorf("expected Fees 0 for a zero fee, got %d", zero.Fees)
}
}
// TestPaymentFromSquare_StatusMapping verifies paymentFromSquare maps every
// documented Square payment status FAITHFULLY (finding D): APPROVED, PENDING,
// FAILED, CANCELED and COMPLETED all flow through into PaymentResult.Status.
// The client never downgrades or drops a non-terminal status.
func TestPaymentFromSquare_StatusMapping(t *testing.T) {
for _, status := range []string{"APPROVED", "PENDING", "FAILED", "CANCELED", "COMPLETED"} {
t.Run(status, func(t *testing.T) {
p := &sqPayment{ID: "pay_" + status, Status: status, TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"}}
result := paymentFromSquare(p)
if result.Status != status {
t.Errorf("expected Status %q mapped verbatim, got %q", status, result.Status)
}
})
}
}
// TestDoJSON_200WithFailedPayment_NotDropped verifies doJSON does NOT silently
// drop a 200-with-FAILED payment (finding D): a 2xx body carrying a FAILED
// payment is parsed into a PaymentResult with Status "FAILED" and nil error —
// the client surfaces the status faithfully instead of erroring, so a
// status-blind handler (records 'completed' on nil error alone) is exposed.
func TestDoJSON_200WithFailedPayment_NotDropped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"payment":{"id":"pay_failed_200","status":"FAILED","total_money":{"amount":5000,"currency":"GBP"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z"}}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
res, err := createPaymentHTTPWithClient(context.Background(), CreatePaymentReq{
Amount: 5000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "ik-failed-200",
}, hc)
if err != nil {
t.Fatalf("a 200-with-FAILED payment must NOT error (the client is status-transparent), got %v", err)
}
if res.Status != "FAILED" {
t.Errorf("expected Status FAILED surfaced faithfully, got %q", res.Status)
}
if res.ID != "pay_failed_200" {
t.Errorf("expected the failed payment returned, got %+v", res)
}
}
// TestDoJSON_CardProcessingNotEnabled403 verifies a 403 CARD_PROCESSING_NOT_ENABLED
// response surfaces the structured Square error with StatusCode 403 (finding E)
// so the handlers agent can special-case it (errors.go, not owned here).
func TestDoJSON_CardProcessingNotEnabled403(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"errors":[{"category":"PAYMENT_METHOD_ERROR","code":"CARD_PROCESSING_NOT_ENABLED","detail":"Card processing is not enabled for this account."}]}`))
}))
defer srv.Close()
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
err := hc.doJSON(context.Background(), http.MethodPost, "/v2/payments", nil, nil)
if err == nil {
t.Fatal("expected error for 403")
}
if got := ErrorStatusCode(err); got != http.StatusForbidden {
t.Errorf("expected ErrorStatusCode 403, got %d", got)
}
if got := ErrorCode(err); got != "CARD_PROCESSING_NOT_ENABLED" {
t.Errorf("expected ErrorCode CARD_PROCESSING_NOT_ENABLED, got %q", got)
}
if got := ErrorCategory(err); got != "PAYMENT_METHOD_ERROR" {
t.Errorf("expected ErrorCategory PAYMENT_METHOD_ERROR, got %q", got)
}
}
+50
View File
@@ -53,9 +53,59 @@ func init() {
if jwtSecret == "" { if jwtSecret == "" {
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.") log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
} }
// Fail-closed: a weak, publicly-known, or placeholder JWT_SECRET_KEY must
// not start the server. Every deployment copying .env.example unchanged
// would otherwise share the SAME signing key, letting anyone forge an
// admin JWT (gift-card minting, refunds, saved-card access).
if isWeakJWTSecret(jwtSecret) {
log.Fatal("FATAL: JWT_SECRET_KEY is too weak: it must be at least 32 characters and not a known placeholder value (the current value is publicly documented). Generate a strong random key and set it, e.g. `openssl rand -hex 32`.")
}
auth.InitJWT(jwtSecret) auth.InitJWT(jwtSecret)
} }
// minJWTSecretBytes is the minimum length enforced for JWT_SECRET_KEY. HS256
// needs at least 32 bytes (256 bits) to be meaningful; shorter keys are
// trivially brute-forceable and every deployment sharing one is forgeable.
const minJWTSecretBytes = 32
// weakJWTSecretValues lists known placeholder/example values for
// JWT_SECRET_KEY that are public (documented in .env.example, READMEs, or
// attack tooling) and must never be accepted as a signing key.
var weakJWTSecretValues = []string{
"a-very-secret-key-that-should-be-in-env",
"change-me",
"changeme",
"changethis",
"CHANGE_ME",
"secret",
"password",
"your-secret-key",
"your-secret",
"jwt-secret",
"jwt-secret-key",
"default-secret",
"my-secret",
"test-secret",
"test-secret-key",
"test-secret-key-for-testing-only",
"super-secret",
}
// isWeakJWTSecret reports whether a JWT_SECRET_KEY is a known placeholder or
// shorter than the minimum safe length.
func isWeakJWTSecret(secret string) bool {
trimmed := strings.TrimSpace(strings.ToLower(secret))
if len(trimmed) < minJWTSecretBytes {
return true
}
for _, weak := range weakJWTSecretValues {
if trimmed == weak {
return true
}
}
return false
}
func limitBody(limit int64) func(http.Handler) http.Handler { func limitBody(limit int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -41,7 +41,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DAV_ADMIN_PASSWORD: ${DAV_ADMIN_PASSWORD:-admin} # Must be set in production DAV_ADMIN_PASSWORD: ${DAV_ADMIN_PASSWORD:?DAV_ADMIN_PASSWORD must be set — generate a strong random value with `openssl rand -hex 32`}
volumes: volumes:
- ./sabredav:/var/www/dav - ./sabredav:/var/www/dav
depends_on: depends_on:
@@ -450,12 +450,70 @@
toast.success('Payment successful!'); toast.success('Payment successful!');
} else { } else {
const text = await response.text(); const text = await response.text();
// A 409 "already paid" (double-tab, or a lost-response retry that
// actually landed) must not leave the user wedged on the pay form
// with a stale deposit_paid=false — money was taken. Reconcile
// against the server's truth so the confirmation gate (depositPaid
// / confirmedBooking.deposit_paid) opens and the user reaches the
// confirmation screen. The body-text match is a belt-and-braces
// fallback for 4xx responses that still report the charge as
// already processed.
if (response.status === 409 || /already|paid|processed/.test(text.toLowerCase())) {
try {
const bookingResp = await apiFetch(`/api/bookings/${bookingId}`);
if (bookingResp.ok) {
const serverBooking = await bookingResp.json();
// Immutable update — spread, never mutate (see audit note above).
confirmedBooking = {
...confirmedBooking,
status: serverBooking.status ?? confirmedBooking.status,
deposit_paid: serverBooking.deposit_paid ?? confirmedBooking.deposit_paid,
deposit_amount:
serverBooking.deposit_amount ?? confirmedBooking.deposit_amount,
amount_paid: serverBooking.amount_paid ?? confirmedBooking.amount_paid,
amount_due: serverBooking.amount_due ?? confirmedBooking.amount_due,
payments: serverBooking.payments ?? confirmedBooking.payments,
total_amount: serverBooking.total_amount ?? confirmedBooking.total_amount
};
depositPaid = confirmedBooking.deposit_paid;
toast.success('Payment successful!');
} else {
toast.warning(
text || 'Payment failed — you can pay again from your booking details.'
);
}
} catch {
toast.warning(
text || 'Payment failed — you can pay again from your booking details.'
);
}
} else {
toast.warning(text || 'Payment failed — you can pay again from your booking details.'); toast.warning(text || 'Payment failed — you can pay again from your booking details.');
} }
// A definitive charge failure (declined card, any 4xx) consumes the
// nonce + SCA verification token (Square nonces are single-use) —
// clear the cached pair so a retry re-tokenizes fresh instead of
// resubmitting a spent nonce for up to 240s. The idempotency key
// stays so a lost-response retry still dedups against the original
// charge (matches the TipPayment pattern).
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
}
} catch { } catch {
toast.error( toast.error(
'An error occurred. Your booking may still be confirmed — check your appointments.' 'An error occurred. Your booking may still be confirmed — check your appointments.'
); );
// A thrown error (network / malformed response) means the nonce + SCA
// verification token are unreliable — clear them so a retry
// re-tokenizes fresh. The idempotency key stays for dedup.
depositNonce = '';
depositVerificationToken = '';
depositTokenAmount = 0;
depositTokenizedAt = 0;
depositTokenizedForSaveCard = false;
} finally { } finally {
isProcessingPayment = false; isProcessingPayment = false;
isProcessingPaymentSync = false; isProcessingPaymentSync = false;
@@ -2441,7 +2499,13 @@
{/if} {/if}
<div class="flex items-center justify-between border-t pt-4"> <div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep}>Back</Button> <Button
variant="ghost"
onclick={prevStep}
disabled={isProcessingPayment}
>
Back
</Button>
<Button <Button
disabled={isProcessingPayment || !depositCardFormValid} disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())} onclick={() => processPayment(calculateDepositAmount())}
@@ -49,6 +49,12 @@
const { booking }: { booking: Booking } = $props(); const { booking }: { booking: Booking } = $props();
let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle'); let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
// Synchronous double-click guard for submitTip. paymentState is only set to
// 'processing' AFTER tokenization, so during the tokenize await the reactive
// `disabled` on the Pay button is not yet active and a rapid second click
// would tokenize twice (minting a second nonce). This non-reactive flag is
// checked at entry before any await and cleared in finally.
let isSubmittingTipSync = false;
// Cached idempotency key: generated once per payment attempt, reused on retry // Cached idempotency key: generated once per payment attempt, reused on retry
// (so a network-timeout retry dedupes instead of double-charging), cleared on // (so a network-timeout retry dedupes instead of double-charging), cleared on
@@ -208,6 +214,9 @@
}); });
async function submitTip() { async function submitTip() {
if (isSubmittingTipSync) return;
isSubmittingTipSync = true;
try {
if (tipAmount <= 0) { if (tipAmount <= 0) {
toast.error('Please select a tip amount'); toast.error('Please select a tip amount');
return; return;
@@ -320,6 +329,9 @@
tipTokenizedAt = 0; tipTokenizedAt = 0;
tipTokenizedForSaveCard = false; tipTokenizedForSaveCard = false;
} }
} finally {
isSubmittingTipSync = false;
}
} }
function retryPayment() { function retryPayment() {
@@ -756,9 +756,17 @@
</div> </div>
</div> </div>
<!-- Card Selection (mounted while idle OR error so a declined card <!-- Card Selection. Mounted for idle/error so a declined card can be
can be retried/swapped without closing the modal) --> retried/swapped without closing the modal, AND for processing:
{#if (status === 'idle' || status === 'error') && authStore.isAuthenticated} makePayment() sets status='processing' FIRST, then awaits the
loyalty redemption and tokenizeWithVerification. Svelte 5 flushes
on the next microtask after the await yields, so a status gate
that unmounts CardSelection mid-payment would null the bind:this
ref AND destroy the Square iframe mid-tokenization, making the
new-card path fail with "Please select a payment method" /
"Card entry failed". Staying mounted keeps both alive for the
full duration of makePayment. -->
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading} {#if paymentMethodsLoading}
<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div> <div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
{:else} {:else}
+29 -1
View File
@@ -196,6 +196,14 @@
let giftCardCode = $state(''); let giftCardCode = $state('');
let redeemingGiftCard = $state(false); let redeemingGiftCard = $state(false);
let showRedeemConfirm = $state(false); let showRedeemConfirm = $state(false);
// Synchronous double-click guard for redeemGiftCard. The redeem POST is
// one-shot; a rapid second click would fire a duplicate redeem (the backend
// FOR UPDATE lock makes the second fail with "already been redeemed",
// showing an error toast right after a success). Svelte 5 reactivity is
// async, so the reactive `disabled` may not have propagated before a fast
// second click — this non-reactive flag is checked at entry before any
// await and cleared in finally.
let isRedeemingSync = false;
// Buy Gift Card State // Buy Gift Card State
let buyAmount = $state<10 | 20 | 50>(10); let buyAmount = $state<10 | 20 | 50>(10);
@@ -203,6 +211,12 @@
let buyRecipientEmail = $state(''); let buyRecipientEmail = $state('');
let buySelectedCard = $state(''); let buySelectedCard = $state('');
let buyingGiftCard = $state(false); let buyingGiftCard = $state(false);
// Synchronous double-click guard for buyGiftCard. buyingGiftCard is only set
// to true AFTER tokenization, so during the tokenize await the reactive
// `disabled` on the Pay button is not yet active and a rapid second click
// would tokenize twice (minting a second nonce, wasting one). This non-
// reactive flag is checked at entry before any await and cleared in finally.
let isBuyingSync = false;
let purchaseResultCode = $state<string | null>(null); let purchaseResultCode = $state<string | null>(null);
let buyCardSelection = $state<CardSelection | null>(null); let buyCardSelection = $state<CardSelection | null>(null);
let buyCardSelectionValid = $state(false); let buyCardSelectionValid = $state(false);
@@ -251,6 +265,9 @@
} }
async function redeemGiftCard() { async function redeemGiftCard() {
if (isRedeemingSync) return;
isRedeemingSync = true;
try {
if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) { if (giftCardCode.replace(/[^a-zA-Z0-9]/g, '').length !== 12) {
toast.error('Invalid gift card code format'); toast.error('Invalid gift card code format');
return; return;
@@ -264,7 +281,9 @@
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
toast.success(`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`); toast.success(
`Success! Redeemed ${formatCurrency(data.amount_redeemed)} to your balance.`
);
giftCardCode = ''; giftCardCode = '';
await fetchGiftCardBalance(); await fetchGiftCardBalance();
} else { } else {
@@ -277,9 +296,15 @@
} finally { } finally {
redeemingGiftCard = false; redeemingGiftCard = false;
} }
} finally {
isRedeemingSync = false;
}
} }
async function buyGiftCard() { async function buyGiftCard() {
if (isBuyingSync) return;
isBuyingSync = true;
try {
let newCardToken: string | undefined; let newCardToken: string | undefined;
let verificationToken: string | undefined; let verificationToken: string | undefined;
if (buySelectedCard) { if (buySelectedCard) {
@@ -396,6 +421,9 @@
} finally { } finally {
buyingGiftCard = false; buyingGiftCard = false;
} }
} finally {
isBuyingSync = false;
}
} }
function formatAndPreserveCursor( function formatAndPreserveCursor(
+26 -2
View File
@@ -44,8 +44,32 @@ fi
# --- 3. Database Reset --- # --- 3. Database Reset ---
log_step "Resetting PostgreSQL..." log_step "Resetting PostgreSQL..."
docker compose down -v postgres > /dev/null 2>&1 # Host port 5432 must be free: the container maps 5432:5432, so a squatter
docker compose up postgres -d > /dev/null 2>&1 # (e.g. a leftover manual postgres) makes the port bind fail. Under ERR_EXIT
# with hidden output that failure used to kill the script silently.
if command -v ss > /dev/null 2>&1 && ss -tln 2>/dev/null | grep -q ":5432 "; then
log_error "Port 5432 is already in use. The postgres container cannot bind it."
log_error "Something else is listening on 5432:"
ss -tlnp 2>/dev/null | grep ":5432 "
log_error "Stop the process holding 5432 (e.g. a manually-started postgres), then re-run."
exit 1
fi
if ! docker compose down -v postgres > /tmp/opencode/pg-down.log 2>&1; then
log_error "Failed to stop old postgres container. See /tmp/opencode/pg-down.log"
exit 1
fi
if ! docker compose up postgres -d > /tmp/opencode/pg-up.log 2>&1; then
log_error "Failed to start postgres container. See /tmp/opencode/pg-up.log"
exit 1
fi
# ERR_EXIT cannot catch a container that is 'Up' but lost its host port mapping
# (docker can report success while the bind silently fails). Verify the map.
if ! docker port postgres 5432 > /dev/null 2>&1; then
log_error "postgres container started but is NOT mapped to host port 5432."
docker logs postgres 2>&1 | tail -5
log_error "Free host port 5432 (see ss output above) and re-run."
exit 1
fi
log_success "PostgreSQL reset complete" log_success "PostgreSQL reset complete"
# --- 3a. Wait for PostgreSQL to be ready --- # --- 3a. Wait for PostgreSQL to be ready ---
+11 -2
View File
@@ -144,8 +144,17 @@ if ($stmt->fetchColumn() == 0) {
"); ");
} }
// Create default user if not exists (username: admin, password from DAV_ADMIN_PASSWORD env) // Create default user if not exists (username: admin, password from DAV_ADMIN_PASSWORD env).
$davPassword = getenv('DAV_ADMIN_PASSWORD') ?: 'admin'; // DAV_ADMIN_PASSWORD is REQUIRED: this CardDAV/CalDAV server exposes customer
// PII (vCards), so a default or publicly-known admin credential is never
// acceptable — fail fast instead of starting with one.
$davPassword = getenv('DAV_ADMIN_PASSWORD');
$weakDavPasswords = ['admin', 'password', 'changeme', 'change-me', 'changethis', 'secret', 'sabredav', 'test'];
if ($davPassword === false || $davPassword === '' || in_array(strtolower(trim($davPassword)), $weakDavPasswords, true)) {
error_log("FATAL: DAV_ADMIN_PASSWORD is not set or is a known weak/default value. Refusing to start: set a strong random DAV_ADMIN_PASSWORD (e.g. `openssl rand -hex 32`) in the environment and restart.");
http_response_code(500);
die("DAV_ADMIN_PASSWORD is not configured");
}
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_users"); $stmt = $pdo->query("SELECT COUNT(*) FROM dav_users");
if ($stmt->fetchColumn() == 0) { if ($stmt->fetchColumn() == 0) {
$digest = md5('admin:SabreDAV:' . $davPassword); $digest = md5('admin:SabreDAV:' . $davPassword);