fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
@@ -101,12 +101,12 @@ type TransferGiftCardRequest struct {
|
||||
}
|
||||
|
||||
type BuyGiftCardRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientEmail string `json:"recipient_email,omitempty"`
|
||||
CardID *string `json:"card_id,omitempty"`
|
||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||
SaveCard bool `json:"save_card"`
|
||||
Amount int64 `json:"amount"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientEmail string `json:"recipient_email,omitempty"`
|
||||
CardID *string `json:"card_id,omitempty"`
|
||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||
SaveCard bool `json:"save_card"`
|
||||
// IdempotencyKey is optional (M1): if not provided, a deterministic key is
|
||||
// generated server-side based on user_id + amount + recipient_type + card_id.
|
||||
// This ensures retries of the same logical purchase use the same key while
|
||||
@@ -991,7 +991,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
cardPart = *req.CardID
|
||||
}
|
||||
req.IdempotencyKey = fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, cardPart)
|
||||
// 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))
|
||||
@@ -1087,6 +1094,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
||||
// enforced. New-card (nonce) charges are not gated.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, paymentService, userID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
var savedCardCustomerID string
|
||||
@@ -1120,6 +1135,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// Reusing the pending record from a failed prior attempt — do not
|
||||
// insert a duplicate (idempotency_key is UNIQUE). Proceed straight to
|
||||
// the Square call, which dedups on the same key.
|
||||
// Refresh square_source_id: this attempt may charge a DIFFERENT token
|
||||
// than the failed attempt (one-time cnon: nonces are spent), and the
|
||||
// sweep replays the charge from the stored source.
|
||||
if _, srcErr := tx.Exec(ctx, `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, reusePendingID); srcErr != nil {
|
||||
log.Printf("Failed to update square_source_id on reused gift-card payment %s: %v", reusePendingID, srcErr)
|
||||
}
|
||||
buyPaymentID = reusePendingID
|
||||
} else {
|
||||
fees := paymentService.CalculateFees(req.Amount, "online")
|
||||
@@ -1137,10 +1158,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
CreatedBy: &userID,
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at, square_source_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id
|
||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&buyPaymentID)
|
||||
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt, sourceID).Scan(&buyPaymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
@@ -1188,9 +1209,9 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
// CustomerID carries the saved-card row's Square customer id on ccof:
|
||||
// charges (save-card path); a cnon: nonce charge (one-off) needs none
|
||||
// (R6).
|
||||
@@ -1201,6 +1222,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
|
||||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
// request on key reuse, and a reconstructed body returns
|
||||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), buyPaymentID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for gift-card payment %s: %v", buyPaymentID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process gift card purchase payment: %v", err)
|
||||
@@ -1325,8 +1356,10 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// TODO: send gift card code via email to recipient once SMTP is wired.
|
||||
// Do NOT log the spendable gift-card code — it is a credential (12-digit
|
||||
// code anyone can redeem). Log only the value and recipient for audit.
|
||||
log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, recipient)
|
||||
// code anyone can redeem). Log only the value and redacted recipient for
|
||||
// audit — the full email is third-party PII with no retention coverage
|
||||
// and the raw free-text field is a log-injection vector.
|
||||
log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, redactEmail(recipient))
|
||||
|
||||
_, err = issueTx.Exec(ctx, `
|
||||
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
|
||||
@@ -1357,6 +1390,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// redactEmail masks an email for logs (PII — third-party addresses are not
|
||||
// covered by retention/anonymization, and the raw free-text field is a
|
||||
// log-injection vector). Mirrors the dev mock's redaction: first two chars of
|
||||
// the local part plus the domain, e.g. "ja***@example.com"; malformed
|
||||
// addresses fall back to "[redacted]". The full email stays in the DB row.
|
||||
func redactEmail(email string) string {
|
||||
at := strings.Index(email, "@")
|
||||
if at < 2 || at+1 >= len(email) {
|
||||
return "[redacted]"
|
||||
}
|
||||
return email[:2] + "***@" + email[at+1:]
|
||||
}
|
||||
|
||||
type ExpiredBalance struct {
|
||||
ID string `json:"id"`
|
||||
AccountID *string `json:"account_id,omitempty"`
|
||||
|
||||
@@ -2358,3 +2358,126 @@ func TestTransferGiftCard_JSONDecodeError(t *testing.T) {
|
||||
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_NoClientKey_TwoPurchases_DoNotCollapse verifies the fallback
|
||||
// idempotency-key fix: two identical purchases WITHOUT a client-supplied key
|
||||
// must be treated as DISTINCT purchases — each issues its own gift card. The
|
||||
// old deterministic fallback key (user+amount+recipient+card) collapsed the
|
||||
// second purchase into the first, silently returning the first card's code.
|
||||
func TestBuyGiftCard_NoClientKey_TwoPurchases_DoNotCollapse(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
buy := func() (int, string) {
|
||||
t.Helper()
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"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.(pgx.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()
|
||||
}
|
||||
|
||||
if code, body := buy(); code != http.StatusCreated {
|
||||
t.Fatalf("first purchase: expected 201, got %d: %s", code, body)
|
||||
}
|
||||
if code, body := buy(); code != http.StatusCreated {
|
||||
t.Fatalf("second purchase: expected 201, got %d: %s", code, body)
|
||||
}
|
||||
|
||||
// Two DISTINCT fallback keys — never one shared dedup key.
|
||||
var distinctKeys int
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&distinctKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query distinct keys: %v", err)
|
||||
}
|
||||
if distinctKeys != 2 {
|
||||
t.Errorf("expected 2 DISTINCT fallback idempotency keys for two no-key purchases, got %d", distinctKeys)
|
||||
}
|
||||
|
||||
// Two payment records and two issued gift cards — the second purchase must
|
||||
// NOT have been deduped into the first.
|
||||
var payCount int
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query payments: %v", err)
|
||||
}
|
||||
if payCount != 2 {
|
||||
t.Errorf("expected 2 payment records for 2 distinct no-key purchases, got %d", payCount)
|
||||
}
|
||||
|
||||
var cardCount int
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query gift cards: %v", err)
|
||||
}
|
||||
if cardCount != 2 {
|
||||
t.Errorf("expected 2 gift cards issued for 2 distinct purchases, got %d", cardCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_PendingRow_StoresSquareSourceID verifies that BuyGiftCard's
|
||||
// pending payment row stores the EXACT source_id sent to Square's CreatePayment
|
||||
// (payments.square_source_id), so the sweep can replay the charge with an
|
||||
// identical request body under the same idempotency key.
|
||||
func TestBuyGiftCard_PendingRow_StoresSquareSourceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateTestToken(userID, "verified_email")
|
||||
|
||||
idempotencyKey := "buy-gc-source-id-test"
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:card-nonce-ok",
|
||||
"idempotency_key": idempotencyKey,
|
||||
})
|
||||
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.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/user/giftcards/buy", BuyGiftCard)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The row must carry the exact one-off nonce sent as CreatePayment's
|
||||
// SourceID (new-card path with save_card=false).
|
||||
var sourceID string
|
||||
err = tx.QueryRow(ctx, "SELECT COALESCE(square_source_id, '') FROM payments WHERE idempotency_key = $1", idempotencyKey).Scan(&sourceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query square_source_id: %v", err)
|
||||
}
|
||||
if sourceID != "cnon:card-nonce-ok" {
|
||||
t.Errorf("expected square_source_id %q (the exact CreatePayment SourceID), got %q", "cnon:card-nonce-ok", sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,6 +560,13 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 2FA gating (C5): charging the customer's SAVED card requires 2FA when
|
||||
// the feature is enforced. Gate on the card's owner — the booking's
|
||||
// user, not the admin. New-card/terminal paths are not gated.
|
||||
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the saved-card Square source for the booking's user (the
|
||||
// card's owner, not the admin) — shared new-card-vs-saved-card
|
||||
// resolution, see resolveChargeSource for the R6 rationale.
|
||||
@@ -657,20 +664,27 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
Amount: float64(amount) / 100.0,
|
||||
IdempotencyKey: &scKey,
|
||||
UserSavedCardID: req.UserSavedCardID,
|
||||
SquareSourceID: &sourceID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &adminID,
|
||||
}
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, square_source_id, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
|
||||
`, record.BookingID, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.IdempotencyKey, record.UserSavedCardID, record.SquareSourceID, record.CreatedBy, record.CreatedAt, record.UpdatedAt).Scan(&paymentID); err != nil {
|
||||
log.Printf("Failed to insert pending saved-card payment: %v", err)
|
||||
_ = tx.Rollback(r.Context())
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Refresh square_source_id on a reused pending row — the sweep
|
||||
// replays the charge from the stored source.
|
||||
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil {
|
||||
log.Printf("Failed to update square_source_id on reused saved-card payment %s: %v", paymentID, srcErr)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit pending saved-card payment: %v", err)
|
||||
@@ -683,7 +697,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
_ = db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, bookingUserID.String).Scan(&buyerEmail)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), square.CreatePaymentReq{
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
@@ -692,7 +706,18 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
BuyerEmail: buyerEmail,
|
||||
})
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
// request on key reuse, and a reconstructed body returns
|
||||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for saved-card payment %s: %v", paymentID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for saved-card payment %s: %v", paymentID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process saved-card payment: %v", err)
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||
@@ -927,20 +952,52 @@ func activeTerminalCheckoutID(ctx context.Context, bookingID string) string {
|
||||
}
|
||||
|
||||
// A provisional (pre-Square) row carries a synthetic "tmp-" checkout_id (or
|
||||
// an empty one for legacy rows) — no checkout was ever created at Square
|
||||
// for it, so it is PROVABLY not live (R3). A hard crash between the
|
||||
// terminal_checkouts insert and the Square CreateCheckout call is the only
|
||||
// way one exists. Mark it failed so a fresh checkout can be created instead
|
||||
// of wedging the booking behind a non-existent Square checkout.
|
||||
// an empty one for legacy rows). 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.
|
||||
// Resolving it to failed unconditionally would let a lost-response retry
|
||||
// create a SECOND live checkout (C2) while C1 can still complete at the
|
||||
// terminal into an untracked charge. Query Square first to disambiguate.
|
||||
if checkoutID == "" || strings.HasPrefix(checkoutID, "tmp-") {
|
||||
log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, bookingID)
|
||||
if checkoutID != "" {
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
|
||||
`, checkoutID); upErr != nil {
|
||||
log.Printf("Failed to mark provisional terminal checkout %s failed: %v", checkoutID, upErr)
|
||||
result, err := SquareClient.GetCheckout(ctx, checkoutID)
|
||||
switch {
|
||||
case err == nil && result.Status == "COMPLETED":
|
||||
// C1 actually completed at the terminal. Mark the row COMPLETED
|
||||
// so the booking's in-flight guard releases; the payment is
|
||||
// recorded by GetCheckoutStatus on the poll path (mirrors the
|
||||
// real-checkout COMPLETED handling below).
|
||||
log.Printf("Provisional terminal checkout %s for booking %s is COMPLETED at Square — marking COMPLETED", checkoutID, bookingID)
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1
|
||||
`, checkoutID); upErr != nil {
|
||||
log.Printf("Failed to mark provisional terminal checkout %s completed: %v", checkoutID, upErr)
|
||||
}
|
||||
return ""
|
||||
case errors.Is(err, square.ErrCheckoutPending):
|
||||
// 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)
|
||||
return checkoutID
|
||||
case isTerminalCheckoutError(err):
|
||||
// NOT_FOUND (no checkout was ever created — the crash happened
|
||||
// before the Square call) or CANCELED — safe to resolve failed
|
||||
// and create a fresh checkout.
|
||||
log.Printf("Provisional (pre-Square) terminal checkout row %q for booking %s resolved as failed — no live checkout at Square", checkoutID, bookingID)
|
||||
if _, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1
|
||||
`, checkoutID); upErr != nil {
|
||||
log.Printf("Failed to mark provisional terminal checkout %s failed: %v", checkoutID, upErr)
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
// Ambiguous error — the checkout's money state at Square is
|
||||
// unknown. Keep it in flight rather than spawning a second live
|
||||
// checkout.
|
||||
return checkoutID
|
||||
}
|
||||
}
|
||||
log.Printf("Legacy empty checkout_id row for booking %s resolved as failed — no live checkout at Square", bookingID)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1329,6 +1386,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve buyer email for Square receipt delivery (failure is non-fatal).
|
||||
var bookingBuyerEmail string
|
||||
if err := db.Conn.QueryRow(r.Context(), `SELECT email FROM users WHERE id = $1`, userID).Scan(&bookingBuyerEmail); err != nil {
|
||||
@@ -1376,8 +1440,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
if req.PaymentType == "partial" {
|
||||
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
@@ -1628,6 +1690,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
var savedCardCustomerID string
|
||||
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
||||
// enforced. New-card (nonce) charges are not gated.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Resolve the new-card-vs-saved-card Square source (shared with
|
||||
// CreateTipPayment, BuyGiftCard, and the saved-card branch of
|
||||
// CreateTerminalPayment — see resolveChargeSource for the R6 rationale).
|
||||
@@ -1653,6 +1722,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
IdempotencyKey: &req.IdempotencyKey,
|
||||
Fees: float64(fees) / 100.0,
|
||||
UserSavedCardID: savedCardID,
|
||||
SquareSourceID: &sourceID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
@@ -1666,6 +1736,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Apply VAT to the pending record inside the same transaction — same
|
||||
// pattern as CreateTipPayment.
|
||||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||
} else {
|
||||
// Refresh square_source_id on a reused pending row: this attempt may
|
||||
// charge a different token than the failed attempt (one-time cnon:
|
||||
// nonces are spent), and the sweep replays the charge from the stored
|
||||
// source.
|
||||
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil {
|
||||
log.Printf("Failed to update square_source_id on reused payment %s: %v", paymentID, srcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Always commit the transaction. In the reuse path no rows were written,
|
||||
@@ -1700,6 +1778,16 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
|
||||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
// request on key reuse, and a reconstructed body returns
|
||||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for payment %s: %v", paymentID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for payment %s: %v", paymentID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment: %v", err)
|
||||
@@ -3260,6 +3348,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
@@ -3278,8 +3373,6 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -3344,18 +3437,25 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Idempotency key: prefer the client-supplied UUID (one per attempt, so
|
||||
// two legitimate identical tips on the same booking don't collapse into
|
||||
// one). Fall back to a unique key when absent — must NOT be derived from
|
||||
// request fields alone (bookingID + amount would dedupe distinct tips).
|
||||
// one). When the client sends NO key, a DETERMINISTIC fallback is derived
|
||||
// inside the transaction below (the count query must see the committed
|
||||
// rows) — never a random key: a random fallback meant a lost-response
|
||||
// no-key retry minted a fresh key, a fresh pending row, and a SECOND
|
||||
// Square charge (H2).
|
||||
idempotencyKey := req.IdempotencyKey
|
||||
if idempotencyKey == "" {
|
||||
idempotencyKey = uniqueChargeKey("tip-")
|
||||
}
|
||||
|
||||
// Resolve the card source ID — same pattern as CreateBookingPayment (see
|
||||
// resolveChargeSource for the R6 rationale).
|
||||
var sourceID string
|
||||
var savedCardID *string
|
||||
var savedCardCustomerID string
|
||||
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
||||
// enforced. New-card (nonce) charges are not gated.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
|
||||
if !sourceOK {
|
||||
return
|
||||
@@ -3386,6 +3486,36 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// No-client-key fallback: derive a deterministic key INSIDE the tx so the
|
||||
// count query races no other tip attempt (the tip advisory lock serializes
|
||||
// per booking). Money-safety: n counts COMPLETED tips only, so a
|
||||
// lost-response retry of a charge whose pending row exists derives the SAME
|
||||
// n → the same key → the dedup lookup below reuses the pending row instead
|
||||
// of minting a second Square charge, while two genuinely distinct identical
|
||||
// tips get n=1, n=2 and never collapse onto one key.
|
||||
if idempotencyKey == "" {
|
||||
var completedTips int
|
||||
if err := tx.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*) FROM payments
|
||||
WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'
|
||||
`, bookingID).Scan(&completedTips); err != nil {
|
||||
log.Printf("Failed to count completed tips for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cardPart := "new"
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
cardPart = *req.CardID
|
||||
}
|
||||
idempotencyKey = fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1)
|
||||
if len(idempotencyKey) > 45 {
|
||||
// Hash long keys to fit Square's 45-char limit — the hash stays
|
||||
// deterministic, so a retry still derives the same key.
|
||||
hash := sha256.Sum256([]byte(idempotencyKey))
|
||||
idempotencyKey = fmt.Sprintf("tip-%x", hash[:16])
|
||||
}
|
||||
}
|
||||
|
||||
// Check idempotency inside the transaction.
|
||||
// Only short-circuit when the existing record is 'completed'. A 'pending'
|
||||
// record means the previous Square call failed — returning it as 200 would
|
||||
@@ -3458,6 +3588,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: 0,
|
||||
UserSavedCardID: savedCardID,
|
||||
SquareSourceID: &sourceID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
CreatedBy: &userID,
|
||||
@@ -3470,6 +3601,14 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||
} else {
|
||||
// Refresh square_source_id on a reused pending row: this attempt may
|
||||
// charge a different token than the failed attempt (one-time cnon:
|
||||
// nonces are spent), and the sweep replays the charge from the stored
|
||||
// source.
|
||||
if _, srcErr := tx.Exec(r.Context(), `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, paymentID); srcErr != nil {
|
||||
log.Printf("Failed to update square_source_id on reused tip payment %s: %v", paymentID, srcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Always commit the transaction. In the reuse path no rows were written,
|
||||
@@ -3510,6 +3649,16 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
|
||||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
// request on key reuse, and a reconstructed body returns
|
||||
// IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for tip payment %s: %v", paymentID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(r.Context(), `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), paymentID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for tip payment %s: %v", paymentID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create tip payment: %v", err)
|
||||
|
||||
@@ -2809,6 +2809,100 @@ func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow verifies the H2
|
||||
// fix: a no-client-key tip whose Square call failed ambiguously leaves a
|
||||
// pending row; a retry (still no client key) must derive the SAME deterministic
|
||||
// key from the booking+amount+card+sequence and reuse that pending row — never
|
||||
// mint a fresh random key, a fresh pending row, and a second Square charge.
|
||||
func TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow(t *testing.T) {
|
||||
// NOT t.Parallel: it swaps the package-global SquareClient (a ShouldFail
|
||||
// mock) mid-test, and a concurrent parallel charge test would observe the
|
||||
// swapped client and fail with a spurious 503.
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
origClient := SquareClient
|
||||
mc := square.NewDevClient().(*square.MockClient)
|
||||
mc.ShouldFail = true
|
||||
SquareClient = mc
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
cardToken := "cnon:no-key-tip-retry"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 1000,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
|
||||
handler := CreateTipPayment
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code, "first attempt must fail ambiguously: %s", w.Body.String())
|
||||
|
||||
// The pending row must exist with a deterministic key.
|
||||
var pendingKey string
|
||||
var pendingCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT idempotency_key, COUNT(*) OVER() FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&pendingKey, &pendingCount))
|
||||
require.Equal(t, 1, pendingCount)
|
||||
require.NotEmpty(t, pendingKey)
|
||||
require.Contains(t, pendingKey, bookingID, "the no-client-key fallback must derive a deterministic key from the booking, not a random one")
|
||||
|
||||
// Retry with Square healthy: no client key, same amount/card.
|
||||
mc.ShouldFail = false
|
||||
w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "retry must succeed: %s", w.Body.String())
|
||||
|
||||
// Exactly ONE tip payment row, completed, with the SAME key.
|
||||
var count int
|
||||
var status string
|
||||
var storedKey string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&count, &status, &storedKey))
|
||||
require.Equal(t, 1, count, "a no-client-key retry must reuse the pending row, not insert a duplicate")
|
||||
require.Equal(t, "completed", status)
|
||||
require.Equal(t, pendingKey, storedKey, "the retry must derive the same deterministic key")
|
||||
}
|
||||
|
||||
// TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse verifies the
|
||||
// H2 fix's other half: two genuinely distinct identical no-client-key tips on
|
||||
// the same booking get n=1 and n=2, so they never collapse onto one
|
||||
// idempotency key (which would silently swallow the second tip).
|
||||
func TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := CreateTipPayment
|
||||
cardToken := "cnon:no-key-tip-dup"
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 1000,
|
||||
NewCardToken: &cardToken,
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, "tip %d: %s", i, w.Body.String())
|
||||
}
|
||||
|
||||
var keys []string
|
||||
rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND payment_type = 'tip' ORDER BY created_at`, bookingID)
|
||||
require.NoError(t, err)
|
||||
for rows.Next() {
|
||||
var k string
|
||||
require.NoError(t, rows.Scan(&k))
|
||||
keys = append(keys, k)
|
||||
}
|
||||
rows.Close()
|
||||
require.Equal(t, 2, len(keys), "two distinct identical tips must both be recorded")
|
||||
require.NotEqual(t, keys[0], keys[1], "distinct identical tips must not collapse onto one idempotency key")
|
||||
}
|
||||
|
||||
func TestGetUserPaymentMethods_NoCards(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
@@ -2970,6 +3064,69 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBookingRemainingBalanceCents_RefundsReopenCapacity verifies the M-cap
|
||||
// is refund-aware: a completed refund returns money, so it re-opens booking
|
||||
// capacity — remaining = total - paid + refunded — while the cap never exceeds
|
||||
// the booking total.
|
||||
func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
require.NoError(t, err)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var bookingTotal int64
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
// Pay the full booking amount.
|
||||
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "full",
|
||||
PaymentMethod: "online_square",
|
||||
Status: "completed",
|
||||
Amount: float64(bookingTotal) / 100.0,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
|
||||
|
||||
// Refund half the booking value — capacity must re-open by that amount.
|
||||
var payRowID string
|
||||
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID))
|
||||
|
||||
refundAmount := bookingTotal / 2
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
|
||||
VALUES ($1, $2, $3, 'completed', 'test refund', 'manual')
|
||||
`, payRowID, bookingID, float64(refundAmount)/100.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount")
|
||||
|
||||
// The cap must never exceed the booking total even if refunds exceed payments.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
|
||||
VALUES ($1, $2, $3, 'completed', 'over-refund test', 'manual')
|
||||
`, payRowID, bookingID, bookingTotal)
|
||||
require.NoError(t, err)
|
||||
|
||||
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total")
|
||||
}
|
||||
|
||||
func TestCreatePaymentMethod_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -103,17 +103,20 @@ func CalculateRefundForCancellation(
|
||||
// lockCancellationPayments serializes a cancellation refund against the manual
|
||||
// RefundPayment handler and the sweep. Both hold
|
||||
// `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level)
|
||||
// on the card payment ids they touch; a cancellation that computes residuals
|
||||
// on the payment ids they touch; a cancellation that computes residuals
|
||||
// without the same locks can over-refund against a manual refund in flight (the
|
||||
// manual guard read precedes the cancellation's commit). Locks are acquired in
|
||||
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks,
|
||||
// and only for card methods the manual handler can touch.
|
||||
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks.
|
||||
// EVERY payment row the cancellation may refund is locked — giftcard and cash
|
||||
// rows included, not just card methods — so two concurrent refunds of the same
|
||||
// giftcard/cash payment serialize on the residual computation and can never
|
||||
// double-credit (H3).
|
||||
func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []paymentRow) error {
|
||||
var ids []string
|
||||
for _, p := range payments {
|
||||
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
// discount/on_the_house rows are already excluded by the caller's
|
||||
// query, so every remaining row is one this loop may refund.
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
@@ -338,6 +341,12 @@ func ProcessCancellationRefundTx(
|
||||
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err)
|
||||
} else if expired {
|
||||
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
|
||||
// Money-safety (C4): the UPDATE gift_cards credit above is
|
||||
// SKIPPED for expired cards (money retained by the salon), so
|
||||
// creditFailed must be set here — otherwise the shared tail
|
||||
// below inserts the refund record as 'completed' claiming money
|
||||
// was returned when it never moved.
|
||||
creditFailed = true
|
||||
break
|
||||
}
|
||||
// Refunding to the card is a "use" per the rolling-expiry terms —
|
||||
|
||||
@@ -531,6 +531,18 @@ func TestProcessCancellationRefund_ExpiredGiftCard_Retained(t *testing.T) {
|
||||
if txCount != 0 {
|
||||
t.Errorf("expected NO refund-to-expired-card transaction, got %d", txCount)
|
||||
}
|
||||
|
||||
// C4 money-safety: the refund record must be 'failed' — never 'completed'.
|
||||
// The old bug fell through the switch and recorded a completed refund even
|
||||
// though the UPDATE gift_cards credit was skipped (no money moved).
|
||||
var refundStatus string
|
||||
if err := tx.QueryRow(ctx,
|
||||
"SELECT status FROM refunds WHERE booking_id = $1", bookingID).Scan(&refundStatus); err != nil {
|
||||
t.Fatalf("failed to query refund status: %v", err)
|
||||
}
|
||||
if refundStatus != "failed" {
|
||||
t.Errorf("expected expired-card refund record status 'failed' (no money credited), got %q", refundStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
|
||||
|
||||
@@ -57,12 +57,17 @@ type PaymentRecord struct {
|
||||
NetAmount *float64
|
||||
UserSavedCardID *string
|
||||
SquarePaymentID *string
|
||||
IdempotencyKey *string
|
||||
Fees float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
GiftCardID *string
|
||||
// SquareSourceID is the exact source_id (cnon: nonce or ccof: card id) sent
|
||||
// in the CreatePayment call, stored on the pending row so the sweep can
|
||||
// replay the charge with an IDENTICAL request body under the same
|
||||
// idempotency key.
|
||||
SquareSourceID *string
|
||||
IdempotencyKey *string
|
||||
Fees float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CreatedBy *string
|
||||
GiftCardID *string
|
||||
}
|
||||
|
||||
type RefundRecord struct {
|
||||
@@ -129,8 +134,8 @@ func (s *PaymentService) insertPaymentRecord(ctx context.Context, record Payment
|
||||
booking_id, payment_type, payment_method, vendor_code, invoice_number,
|
||||
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
|
||||
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
|
||||
gift_card_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
gift_card_id, square_source_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
RETURNING id
|
||||
`,
|
||||
bookingID,
|
||||
@@ -152,6 +157,7 @@ func (s *PaymentService) insertPaymentRecord(ctx context.Context, record Payment
|
||||
record.UpdatedAt,
|
||||
record.CreatedBy,
|
||||
giftCardID,
|
||||
record.SquareSourceID,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
@@ -509,9 +515,21 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
|
||||
-- A tip is money paid beyond the booking total — it does not
|
||||
-- reduce the balance owed, so it must not count as "paid".
|
||||
AND payment_type <> 'tip'
|
||||
),
|
||||
refunded_total AS (
|
||||
SELECT COALESCE(SUM(r.amount), 0) AS refunded_pounds
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status = 'completed'
|
||||
)
|
||||
SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint
|
||||
FROM booking_total bt, paid_total pt
|
||||
-- Money-safety (M-cap): refunds return money, so they re-open booking
|
||||
-- capacity — remaining = total - paid + refunded. LEAST clamps the cap
|
||||
-- at the booking total so the M-cap can never allow a charge beyond the
|
||||
-- booking's full value even in the pathological case where completed
|
||||
-- refunds exceed payments, and GREATEST floors at 0 so a fully-paid
|
||||
-- (or over-paid) booking can never be charged again.
|
||||
SELECT GREATEST(0, ROUND(LEAST(bt.total_pounds - pt.paid_pounds + rt.refunded_pounds, bt.total_pounds) * 100))::bigint
|
||||
FROM booking_total bt, paid_total pt, refunded_total rt
|
||||
`, bookingID).Scan(&remainingCents)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -140,14 +143,40 @@ type staleRow struct {
|
||||
// the lost-response case: the sweep replays the key at Square to learn the
|
||||
// true charge outcome before declaring failure.
|
||||
IdempotencyKey string
|
||||
// SquareSourceID is the exact source_id sent in the original CreatePayment
|
||||
// call, stored on the row so the replay-by-key can rebuild an IDENTICAL
|
||||
// request body (same key + source + amount). Replaying a different body
|
||||
// would return IDEMPOTENCY_KEY_REUSED, which proves nothing about whether
|
||||
// the charge landed.
|
||||
SquareSourceID string
|
||||
// SquareRequestSnapshot is the verbatim original CreatePayment request JSON
|
||||
// stored on the row at charge time (square_request_snapshot) — the FULL
|
||||
// body the replay-by-key must repeat (source, key, amount, customer_id,
|
||||
// reference_id, note, buyer_email_address, verification_token, ...).
|
||||
// Square's idempotency dedup compares the whole request, so a replay built
|
||||
// from partial row data returns IDEMPOTENCY_KEY_REUSED for a RETAINED key
|
||||
// and the row stays pending forever (safe but never auto-rescued). Nil for
|
||||
// legacy rows — the sweep then rebuilds the minimal body from the stored
|
||||
// key + source + amount.
|
||||
SquareRequestSnapshot []byte
|
||||
// AmountPence is the row's charge amount in pence — the amount the original
|
||||
// CreatePayment used. The replay-by-key must repeat it so Square's
|
||||
// idempotency dedup returns the original payment.
|
||||
AmountPence int64
|
||||
// CreatedAt is the pending row's creation time; rows already past Square's
|
||||
// idempotency-key retention window cannot be replayed trustworthily.
|
||||
CreatedAt time.Time
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
CreatedAt time.Time
|
||||
// BookingID is the payments row's booking_id ("" when the row has none).
|
||||
// A payments row with NO booking is a gift-card purchase (BuyGiftCard
|
||||
// inserts without a booking): rescuing it to 'completed' on a Square
|
||||
// COMPLETED reconcile would permanently block the same-key retry that
|
||||
// delivers the card, so such rows are kept pending instead (C6).
|
||||
BookingID *string
|
||||
// CreatedBy is the payments row's created_by user id (gift-card purchases
|
||||
// always carry the purchaser), used to attribute the critical-payment admin
|
||||
// notification.
|
||||
CreatedBy *string
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem
|
||||
IsCreate bool // true when this sale created the gift card (timestamps equal)
|
||||
HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL)
|
||||
@@ -177,6 +206,14 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv
|
||||
if r.SquarePaymentID != "" {
|
||||
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
|
||||
case staleReconcileCompleted:
|
||||
if table == "payments" && r.BookingID == nil {
|
||||
// Gift-card purchase row: the charge landed at Square but
|
||||
// the gift card was never delivered (C6). Completing the row
|
||||
// permanently blocks the same-key retry that delivers the
|
||||
// card — leave it pending and alert.
|
||||
leaveGiftCardPurchasePending(ctx, r)
|
||||
continue
|
||||
}
|
||||
if rescueStaleRowCompleted(ctx, table, r.ID) {
|
||||
resolved++
|
||||
completed++
|
||||
@@ -257,8 +294,15 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
|
||||
log.Printf("Stale pending %s row %s has a stored idempotency key but is already past Square's key retention window — marked failed without a replay reconcile (may have been charged with a lost response)", table, r.ID)
|
||||
continue
|
||||
}
|
||||
switch res, sqPayID := reconcileStalePaymentByKey(ctx, table, r.IdempotencyKey, r.AmountPence); res {
|
||||
switch res, sqPayID := reconcileStalePaymentByKey(ctx, table, r); res {
|
||||
case staleReconcileCompleted:
|
||||
if table == "payments" && r.BookingID == nil {
|
||||
// Gift-card purchase row (C6): the charge landed at Square but
|
||||
// the card was never delivered. Completing the row blocks the
|
||||
// same-key retry that delivers the card — leave it pending.
|
||||
leaveGiftCardPurchasePending(ctx, r)
|
||||
continue
|
||||
}
|
||||
if rescueKeyedStaleRowCompleted(ctx, table, r.ID, sqPayID) {
|
||||
resolved++
|
||||
completed++
|
||||
@@ -306,6 +350,7 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
if table == "till_sales" {
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT ts.id, COALESCE(ts.square_payment_id, ''), COALESCE(ts.idempotency_key, ''),
|
||||
COALESCE(ts.square_source_id, ''), COALESCE(ts.square_request_snapshot, ''),
|
||||
ts.created_at, ts.item_id, gc.redeemed_by,
|
||||
(ts.created_at = gc.created_at) AS is_create,
|
||||
(gc.id IS NOT NULL) AS has_gift_card, ts.total_amount
|
||||
@@ -316,7 +361,8 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
} else {
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT id, COALESCE(square_payment_id, ''), COALESCE(idempotency_key, ''),
|
||||
created_at, amount
|
||||
COALESCE(square_source_id, ''), COALESCE(square_request_snapshot, ''),
|
||||
created_at, amount, booking_id, created_by
|
||||
FROM `+table+`
|
||||
WHERE status = 'pending' AND created_at < $1`+keyedPredicate+`
|
||||
`, cutoff)
|
||||
@@ -343,13 +389,14 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) {
|
||||
var r staleRow
|
||||
if table == "till_sales" {
|
||||
var itemID, redeemedBy sql.NullString
|
||||
var itemID, redeemedBy, snapshot sql.NullString
|
||||
var isCreate *bool
|
||||
var hasGiftCard bool
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt,
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.SquareSourceID, &snapshot, &r.CreatedAt,
|
||||
&itemID, &redeemedBy, &isCreate, &hasGiftCard, &r.TotalAmount); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.SquareRequestSnapshot = []byte(snapshot.String)
|
||||
r.ItemID = itemID.String
|
||||
if redeemedBy.Valid && redeemedBy.String != "" {
|
||||
r.RedeemToUserID = &redeemedBy.String
|
||||
@@ -360,10 +407,20 @@ func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) {
|
||||
return r, nil
|
||||
}
|
||||
var amount float64
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt, &amount); err != nil {
|
||||
var bookingID, createdBy, snapshot sql.NullString
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.SquareSourceID, &snapshot, &r.CreatedAt, &amount, &bookingID, &createdBy); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.SquareRequestSnapshot = []byte(snapshot.String)
|
||||
r.AmountPence = int64(math.Round(amount * 100))
|
||||
if bookingID.Valid && bookingID.String != "" {
|
||||
b := bookingID.String
|
||||
r.BookingID = &b
|
||||
}
|
||||
if createdBy.Valid && createdBy.String != "" {
|
||||
c := createdBy.String
|
||||
r.CreatedBy = &c
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -438,22 +495,51 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
|
||||
|
||||
// reconcileStalePaymentByKey asks Square for the authoritative status of the
|
||||
// charge made under a stale pending row's idempotency key and returns the
|
||||
// tri-state result. The replay returns the ORIGINAL payment for a retained key
|
||||
// (Square's documented idempotency behavior — never a second charge); a
|
||||
// COMPLETED payment rescues the row to 'completed' with its real payment id. A
|
||||
// definitive rejection (ErrReplayKeyNotRetained — Square has no payment under
|
||||
// the key, so the charge never happened) or a FAILED/CANCELED payment proves
|
||||
// the charge never completed and fails the row. Any OTHER error (transport /
|
||||
// 5xx / ambiguous) leaves the row pending — the charge may still have
|
||||
// completed at Square. The second return value is the Square payment id of the
|
||||
// completed payment ("" otherwise), written back on a rescue.
|
||||
func reconcileStalePaymentByKey(ctx context.Context, table, idempotencyKey string, amountPence int64) (staleReconcileResult, string) {
|
||||
pr, err := SquareClient.ReplayPaymentByKey(ctx, idempotencyKey, amountPence)
|
||||
// tri-state result. The replay sends an IDENTICAL body to the original charge:
|
||||
// the stored square_request_snapshot (the FULL request — source_id, key,
|
||||
// amount and every field the original carried). Replaying a partial body would
|
||||
// return IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending
|
||||
// forever. Square's idempotency guarantee returns the ORIGINAL payment for a
|
||||
// retained key (never a second charge); a COMPLETED payment rescues the row to
|
||||
// 'completed' with its real payment id. A definitive 4xx rejection
|
||||
// (ErrReplayKeyNotRetained — Square attempted a real charge with the
|
||||
// expired/used source and refused it, so the charge never happened) or a
|
||||
// FAILED/CANCELED payment proves the charge never completed and fails the row.
|
||||
// IDEMPOTENCY_KEY_REUSED is NEVER proof of no charge: with an identical body it
|
||||
// can only mean the stored source differs from the original (a data bug), so
|
||||
// the row is left pending with a CRITICAL log. Any OTHER error (transport /
|
||||
// 5xx / ambiguous) leaves the row pending — the charge may still have completed
|
||||
// at Square. The second return value is the Square payment id of the completed
|
||||
// payment ("" otherwise), written back on a rescue.
|
||||
func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (staleReconcileResult, string) {
|
||||
snapshot := r.SquareRequestSnapshot
|
||||
if len(bytes.TrimSpace(snapshot)) == 0 {
|
||||
// Legacy row without a stored request snapshot — rebuild the minimal
|
||||
// identical body (key + source + amount) exactly as the pre-snapshot
|
||||
// replay did. Such rows can still be reconciled as long as the stored
|
||||
// source/amount match the original charge.
|
||||
fallback, mErr := json.Marshal(square.CreatePaymentReq{
|
||||
Amount: r.AmountPence,
|
||||
Currency: "GBP",
|
||||
SourceID: r.SquareSourceID,
|
||||
IdempotencyKey: r.IdempotencyKey,
|
||||
})
|
||||
if mErr != nil {
|
||||
log.Printf("Stale pending %s reconcile by key: failed to rebuild replay body for row %s (%v) — leaving pending", table, r.ID, mErr)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
snapshot = fallback
|
||||
}
|
||||
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
|
||||
if err != nil {
|
||||
if errors.Is(err, square.ErrReplayKeyNotRetained) {
|
||||
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (replay probe rejected) — marking failed; the charge provably never happened", table)
|
||||
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (identical-body replay rejected) — marking failed; the charge provably never happened", table)
|
||||
return staleReconcileDefinitivelyFailed, ""
|
||||
}
|
||||
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
@@ -479,6 +565,44 @@ func reconcileStalePaymentByKey(ctx context.Context, table, idempotencyKey strin
|
||||
}
|
||||
}
|
||||
|
||||
// leaveGiftCardPurchasePending keeps a gift-card-purchase payment row (payments
|
||||
// table, booking_id NULL) pending after Square confirms the charge COMPLETED,
|
||||
// instead of rescuing it to 'completed'. Completing the row would permanently
|
||||
// block the same-key retry (BuyGiftCard) that reuses the pending record to
|
||||
// deliver the card — the customer would stay charged with no gift card (C6).
|
||||
// The row is left pending with a CRITICAL log + admin notification so the retry
|
||||
// can still deliver the card and an admin is alerted to reconcile manually.
|
||||
func leaveGiftCardPurchasePending(ctx context.Context, r staleRow) {
|
||||
log.Printf("CRITICAL: gift card purchase payment %s is COMPLETED at Square but the gift card was never issued (issue transaction failed, retry abandoned) — leaving the payment PENDING so a same-key retry can still deliver the card — MANUAL RECONCILIATION REQUIRED", r.ID)
|
||||
insertCriticalPaymentNotification(ctx, nil, r.CreatedBy)
|
||||
}
|
||||
|
||||
// insertCriticalPaymentNotification surfaces an unresolved money event in the
|
||||
// admin notification centre (reason 'critical_payment_log' — the DB-backed
|
||||
// stand-in for the un-watched CRITICAL payment logs). bookingID is set when the
|
||||
// issue ties to a booking (untracked terminal charges); userID is set when it
|
||||
// ties to a user (gift-card purchases). The NOT EXISTS guard keeps ONE
|
||||
// notification per issue instead of one per sweep run.
|
||||
func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *string) {
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id, created_at)
|
||||
SELECT 'critical_payment_log', $1, $2, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.reason = 'critical_payment_log'
|
||||
AND an.booking_id IS NOT DISTINCT FROM $1
|
||||
AND an.user_id IS NOT DISTINCT FROM $2
|
||||
)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert admin_notifications for critical payment issue (booking=%v user=%v): %v", bookingID, userID, err)
|
||||
return
|
||||
}
|
||||
if int(tag.RowsAffected()) > 0 {
|
||||
log.Printf("Inserted critical-payment admin notification (booking=%v user=%v)", bookingID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// staleReconcileResult is the tri-state outcome of reconciling one stale
|
||||
// pending row against Square. Only a definitively-resolved outcome touches the
|
||||
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
|
||||
@@ -624,7 +748,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
// terminal_checkouts table. A stale PENDING/IN_PROGRESS row means the
|
||||
// checkout is still live at Square (or was left after a crash / lost poll).
|
||||
rows, err := db.Conn.Query(ctx, `
|
||||
SELECT 'terminal_checkout', checkout_id, checkout_id
|
||||
SELECT 'terminal_checkout', checkout_id, checkout_id, booking_id
|
||||
FROM terminal_checkouts
|
||||
WHERE status IN ('PENDING', 'IN_PROGRESS')
|
||||
AND created_at < $1
|
||||
@@ -634,7 +758,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
for rows.Next() {
|
||||
var r staleTerminalCheckoutRow
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID, &r.BookingID); err != nil {
|
||||
log.Printf("Failed to scan stale terminal checkout row: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -644,7 +768,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
|
||||
// Till-sale card-machine checkouts are tracked on the till_sales row.
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT 'till_sale', id, square_checkout_id
|
||||
SELECT 'till_sale', id, square_checkout_id, ''
|
||||
FROM till_sales
|
||||
WHERE status = 'pending' AND square_checkout_id IS NOT NULL
|
||||
AND created_at < $1
|
||||
@@ -654,7 +778,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
for rows.Next() {
|
||||
var r staleTerminalCheckoutRow
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID, &r.BookingID); err != nil {
|
||||
log.Printf("Failed to scan stale terminal checkout row: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -697,21 +821,16 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
switch {
|
||||
case rErr == nil && recheck.Status == "COMPLETED":
|
||||
// The customer completed the payment during the cancel window.
|
||||
// The poll handler records it — mark the row COMPLETED (or
|
||||
// leave the till sale pending) instead of failed.
|
||||
// Record it if it was never polled/recorded — a COMPLETED
|
||||
// checkout must not stay an untracked charge (H4).
|
||||
if r.Kind == "terminal_checkout" {
|
||||
if tag, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, r.RowID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed after cancel re-check: %v", r.RowID, upErr)
|
||||
} else if int(tag.RowsAffected()) > 0 {
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, recheck) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
|
||||
}
|
||||
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — recorded as completed, payment handled by the poll handler", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
|
||||
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
|
||||
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
|
||||
// The cancel landed (CANCELED / cancel-requested / expired /
|
||||
// still-reporting-pending-but-now-cancelled) — it can never
|
||||
@@ -734,14 +853,11 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
case gErr == nil && pr.Status == "COMPLETED":
|
||||
if r.Kind == "terminal_checkout" {
|
||||
// The payment is recorded by the poll handler; release the
|
||||
// in-flight guard so a fresh charge can be created.
|
||||
if tag, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, r.RowID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", r.RowID, upErr)
|
||||
} else if int(tag.RowsAffected()) > 0 {
|
||||
// The checkout completed at Square but was never polled/recorded
|
||||
// (the frontend never called GetCheckoutStatus). Record the
|
||||
// payment rows now — otherwise the charge stays invisible to
|
||||
// refunds and TotalPaid (H4).
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
@@ -784,11 +900,14 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
// staleTerminalCheckoutRow is one live-checkout row the sweep reads from
|
||||
// either table so it can resolve the checkout at Square before touching the
|
||||
// row. Kind is "terminal_checkout" (booking, terminal_checkouts table) or
|
||||
// "till_sale" (till_sales.square_checkout_id).
|
||||
// "till_sale" (till_sales.square_checkout_id). BookingID is the booking the
|
||||
// terminal_checkouts row belongs to ("" for till sales) — needed to record an
|
||||
// untracked COMPLETED terminal charge (H4).
|
||||
type staleTerminalCheckoutRow struct {
|
||||
Kind string
|
||||
RowID string
|
||||
CheckoutID string
|
||||
BookingID string
|
||||
}
|
||||
|
||||
// markTerminalCheckoutRowFailed moves one tracked row to the terminal 'failed'
|
||||
@@ -813,6 +932,162 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR
|
||||
return int(tag.RowsAffected()) > 0
|
||||
}
|
||||
|
||||
// recordUntrackedTerminalPayment records the payments row(s) for a terminal
|
||||
// checkout that COMPLETED at Square but was never polled/recorded, then marks
|
||||
// the terminal_checkouts row COMPLETED. The stale sweep otherwise leaves a real
|
||||
// charge with NO payments row — invisible to refunds and TotalPaid (H4). It
|
||||
// reuses the exact insert convention of GetCheckoutStatus: the same advisory
|
||||
// lock key (serializes against a concurrent poll), the same dedup by
|
||||
// booking_id + square_payment_id, the same PaymentRecord shape and derived
|
||||
// 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
|
||||
// (payment recorded or already recorded); 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 {
|
||||
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)
|
||||
return false
|
||||
}
|
||||
service := NewPaymentService()
|
||||
|
||||
// Serialize with the poll handler (GetCheckoutStatus): both record the same
|
||||
// Square payment, so the advisory lock + dedup SELECT prevent a double
|
||||
// insert (the idempotency_key UNIQUE constraint is the backstop).
|
||||
pinConn, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for terminal-completion lock: %v", err)
|
||||
return false
|
||||
}
|
||||
defer pinConn.Release()
|
||||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:terminal:"+pr.SquarePayID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", pr.SquarePayID, err)
|
||||
return false
|
||||
}
|
||||
if !lockOK {
|
||||
log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", pr.SquarePayID)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1))`, pr.SquarePayID); err != nil {
|
||||
log.Printf("Failed to release terminal-completion serialization lock for %s: %v", pr.SquarePayID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin terminal-completion transaction: %v", err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
log.Printf("Failed to rollback terminal-completion transaction: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Dedup by Square payment ID: a concurrent poll (or a prior sweep run)
|
||||
// already recorded this charge — just release the in-flight guard.
|
||||
var existingID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM payments
|
||||
WHERE booking_id = $1 AND square_payment_id = $2
|
||||
`, bookingID, pr.SquarePayID).Scan(&existingID); err == nil {
|
||||
if _, upErr := tx.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, checkoutID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr)
|
||||
return false
|
||||
}
|
||||
if cErr := tx.Commit(ctx); cErr != nil {
|
||||
log.Printf("Failed to commit terminal-completion transaction: %v", cErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to check for existing terminal payment: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// The payment type the admin charged is recorded on the checkout row by
|
||||
// CreateTerminalPayment; fall back to 'full' for legacy rows.
|
||||
var checkoutPaymentType string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1
|
||||
`, checkoutID).Scan(&checkoutPaymentType); err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err)
|
||||
}
|
||||
checkoutPaymentType = "full"
|
||||
}
|
||||
|
||||
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(pr.Amount, 10) + "-" + pr.SquarePayID
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: checkoutPaymentType,
|
||||
PaymentMethod: "in_person_card",
|
||||
Status: "completed",
|
||||
Amount: float64(pr.Amount) / 100.0,
|
||||
SquarePaymentID: &pr.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: float64(pr.Fees) / 100.0,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
}
|
||||
|
||||
// M4 split: a terminal charge above the remaining booking value is a tip —
|
||||
// record it as its own record so only the booking portion is refundable.
|
||||
var records []PaymentRecord
|
||||
bookingInfo, bErr := service.GetBookingPaymentInfo(ctx, bookingID)
|
||||
if bErr == nil && bookingInfo != nil {
|
||||
charged := float64(pr.Amount) / 100.0
|
||||
remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid)
|
||||
bookingPortion := math.Min(charged, remainingBookingValue)
|
||||
bookingPortion = math.Round(bookingPortion*100) / 100
|
||||
tipAmount := math.Round((charged-bookingPortion)*100) / 100
|
||||
if tipAmount > 0.004 {
|
||||
records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
records = []PaymentRecord{record}
|
||||
}
|
||||
|
||||
primary := records[0]
|
||||
paymentID, err := service.CreatePaymentRecordTx(ctx, tx, primary, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record for untracked terminal charge %s: %v", pr.SquarePayID, err)
|
||||
return false
|
||||
}
|
||||
ApplyVATToBookingPayment(ctx, tx, paymentID)
|
||||
for _, rec := range records[1:] {
|
||||
pid, cErr := service.CreatePaymentRecordTx(ctx, tx, rec, nil)
|
||||
if cErr != nil {
|
||||
log.Printf("Failed to create terminal tip split record: %v", cErr)
|
||||
return false
|
||||
}
|
||||
ApplyVATToBookingPayment(ctx, tx, pid)
|
||||
}
|
||||
|
||||
// Release the in-flight guard: this checkout is now recorded.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, checkoutID); err != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err)
|
||||
return false
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit terminal-completion transaction: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// The booking may now be fully paid — complete it like the poll handler does.
|
||||
completeFullyPaidBooking(ctx, bookingID)
|
||||
log.Printf("CRITICAL: recorded untracked terminal charge %s (booking %s) from stale checkout %s — payment row %s created (never polled by the frontend)", pr.SquarePayID, bookingID, checkoutID, paymentID)
|
||||
return true
|
||||
}
|
||||
|
||||
// isTerminalCheckoutError reports whether a GetCheckout error proves the
|
||||
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
|
||||
// for a still-live checkout and surfaces a definitively CANCELED status as a
|
||||
|
||||
@@ -249,14 +249,14 @@ type staleReplayClient struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *staleReplayClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*square.PaymentResult, error) {
|
||||
func (c *staleReplayClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*square.PaymentResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
if c.result != nil {
|
||||
return c.result, nil
|
||||
}
|
||||
return c.SquareClient.ReplayPaymentByKey(ctx, idempotencyKey, amount)
|
||||
return c.SquareClient.ReplayPaymentByKey(ctx, snapshotJSON)
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued locks the
|
||||
@@ -289,7 +289,7 @@ func TestSweepStalePendingPayments_KeyedLostResponse_CompletedRescued(t *testing
|
||||
// still inside Square's 24h idempotency-key retention window (so the replay
|
||||
// returns the original payment instead of being blind-failed).
|
||||
const key = "key-lost-response-completed"
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1 WHERE id = $2", key, staleID); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -495,8 +495,8 @@ func TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued(t *tes
|
||||
|
||||
var saleID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '23 hours', NOW())
|
||||
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW())
|
||||
RETURNING id
|
||||
`, "key-lost-till-completed", adminID).Scan(&saleID)
|
||||
if err != nil {
|
||||
@@ -533,6 +533,164 @@ func TestSweepStalePendingPayments_KeyedTillLostResponse_CompletedRescued(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_LeavesPending
|
||||
// locks C6: a gift-card purchase payment row (payments table, NO booking) whose
|
||||
// charge COMPLETED at Square must NOT be rescued to 'completed' — completing it
|
||||
// would permanently block the same-key retry that delivers the card (customer
|
||||
// charged, no card). The row stays pending, a critical-payment admin
|
||||
// notification is inserted, and a same-key retry remains possible.
|
||||
func TestSweepStalePendingPayments_KeyedGiftCardPurchase_Completed_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)
|
||||
}
|
||||
|
||||
const key = "key-gc-purchase-completed"
|
||||
var payID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, square_source_id, created_by, created_at, updated_at)
|
||||
VALUES ('full', 'online_square', 'pending', 50.00, $1, 'cnon:test-card', $2, NOW() - INTERVAL '23 hours', NOW())
|
||||
RETURNING id
|
||||
`, key, userID).Scan(&payID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed gift-card purchase payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 5000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:test-card",
|
||||
IdempotencyKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed completed Square payment: %v", err)
|
||||
}
|
||||
SquareClient = mock
|
||||
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)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, payID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var status, sqPayID string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", payID).Scan(&status, &sqPayID); err != nil {
|
||||
t.Fatalf("failed to query payment: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected the gift-card purchase row left 'pending' (a same-key retry must still deliver the card), got %q", status)
|
||||
}
|
||||
if sqPayID != "" {
|
||||
t.Errorf("expected no square_payment_id written on the gift-card purchase row, got %q", sqPayID)
|
||||
}
|
||||
// The charge landed at Square (the mock still holds the payment).
|
||||
if _, err := mock.GetPayment(freshCtx, pay.SquarePayID); err != nil {
|
||||
t.Errorf("expected the Square payment to still exist (customer was charged): %v", err)
|
||||
}
|
||||
var notifCount int
|
||||
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
||||
t.Fatalf("failed to count admin notifications: %v", err)
|
||||
}
|
||||
if notifCount < 1 {
|
||||
t.Errorf("expected a critical-payment admin notification for the unresolved gift-card purchase, got %d", notifCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedSourceMismatch_LeavesPending locks C1: a
|
||||
// replay that hits IDEMPOTENCY_KEY_REUSED (the stored square_source_id differs
|
||||
// from the original charge's source — a data bug) must NEVER fail the row. The
|
||||
// original charge may well have landed at Square, so the row is left pending
|
||||
// for manual reconciliation instead of being marked failed (which would claw
|
||||
// back funding / block a same-key retry).
|
||||
func TestSweepStalePendingPayments_KeyedSourceMismatch_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)
|
||||
}
|
||||
|
||||
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stale pending payment: %v", err)
|
||||
}
|
||||
const key = "key-source-mismatch"
|
||||
// The stored source differs from what the charge actually used at Square —
|
||||
// the data-bug condition the identical-body replay surfaces as
|
||||
// IDEMPOTENCY_KEY_REUSED.
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:wrong-source' WHERE id = $2", key, staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient().(*square.MockClient)
|
||||
if _, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
|
||||
Amount: 200000,
|
||||
Currency: "GBP",
|
||||
SourceID: "cnon:original-source",
|
||||
IdempotencyKey: key,
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to seed completed Square payment: %v", err)
|
||||
}
|
||||
SquareClient = mock
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
freshCtx := context.Background()
|
||||
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
|
||||
t.Fatalf("sweep failed: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query payment: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("expected IDEMPOTENCY_KEY_REUSED to leave the row pending (never proof of no charge), got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedTillLostResponse_ProvenFailed_Clawbacks
|
||||
// locks the keyed clawback: a stale pending till sale with a stored idempotency
|
||||
// key whose charge Square PROVES never happened (no payment under the key) is
|
||||
@@ -858,7 +1016,7 @@ func (c *completingDuringCancelClient) GetCheckout(ctx context.Context, checkout
|
||||
if c.calls == 1 {
|
||||
return nil, square.ErrCheckoutPending
|
||||
}
|
||||
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_completed_during_cancel"}, nil
|
||||
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_completed_during_cancel", Amount: 5000}, nil
|
||||
}
|
||||
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
||||
}
|
||||
@@ -888,6 +1046,9 @@ func TestSweepStaleTerminalCheckouts_CompletedDuringCancel_MarkedCompleted(t *te
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
// The sweep records the untracked COMPLETED charge as a payments row
|
||||
// (H4) — clean it up before the booking so the FK delete order holds.
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE square_payment_id = 'sqp_completed_during_cancel'`)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
||||
|
||||
@@ -18,6 +18,12 @@ func TestMain(m *testing.M) {
|
||||
jwt.Init()
|
||||
square.Client = square.NewDevClient()
|
||||
SquareClient = square.Client
|
||||
// Default the package to an explicit dev/mock Square env so generic payment
|
||||
// tests run with the 2FA gate OFF. twoFactorEnforced() is fail-closed: an
|
||||
// empty SQUARE_ENVIRONMENT now means ENFORCED, so a dev default must be set
|
||||
// explicitly. Tests that assert enforcement flip these via t.Setenv.
|
||||
os.Setenv("REQUIRE_2FA", "")
|
||||
os.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
testdb.SeedBaseline(pool)
|
||||
code := m.Run()
|
||||
testdb.DestroyTestDatabase(pool, "crussell_test_handlers_payments")
|
||||
|
||||
@@ -603,6 +603,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
var needsSquarePayment bool
|
||||
var savedCardSqCardID string
|
||||
var savedCardCustomerID string
|
||||
// tillSquareSourceID records the exact source_id sent to CreatePayment
|
||||
// (the ccof: card id or the cnon: nonce) so the pending row stores it for
|
||||
// the sweep's identical-body replay.
|
||||
var tillSquareSourceID string
|
||||
|
||||
// Pending-retry for card_machine: the original Square checkout may still be
|
||||
// live at the terminal. If the pending till_sales row already recorded a
|
||||
@@ -741,10 +745,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2FA gating (C5): charging a customer's saved card requires 2FA when
|
||||
// the feature is enforced.
|
||||
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.IdempotencyKey == "" {
|
||||
req.IdempotencyKey = uniqueChargeKey("till-")
|
||||
}
|
||||
|
||||
tillSquareSourceID = savedCardSqCardID
|
||||
saleStatus = "pending"
|
||||
needsSquarePayment = true
|
||||
case "card_machine":
|
||||
@@ -786,6 +797,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
req.IdempotencyKey = uniqueChargeKey("till-")
|
||||
}
|
||||
|
||||
tillSquareSourceID = req.CardToken
|
||||
saleStatus = "pending"
|
||||
needsSquarePayment = true
|
||||
case "on_the_house":
|
||||
@@ -801,15 +813,24 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
var tillSaleID string
|
||||
if existingPendingID != "" {
|
||||
// Reusing the pending sale row from a failed prior attempt — the sale
|
||||
// was already inserted, so skip the insert and reuse its ID.
|
||||
// was already inserted, so skip the insert and reuse its ID. Refresh
|
||||
// the stored source: this attempt may charge a different token than the
|
||||
// failed attempt (one-time cnon: nonces are spent), and the sweep
|
||||
// replays the charge from the stored source.
|
||||
tillSaleID = existingPendingID
|
||||
if tillSquareSourceID != "" {
|
||||
if _, srcErr := tx.Exec(ctx, `UPDATE till_sales SET square_source_id = $1 WHERE id = $2`, tillSquareSourceID, tillSaleID); srcErr != nil {
|
||||
log.Printf("Failed to update square_source_id on reused till sale %s: %v", tillSaleID, srcErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||
square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at,
|
||||
square_source_id
|
||||
) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW(), $14)
|
||||
RETURNING id
|
||||
`,
|
||||
req.ItemType,
|
||||
@@ -825,6 +846,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
req.IdempotencyKey,
|
||||
"Admin till sale: "+req.Action+" gift card",
|
||||
adminID,
|
||||
tillSquareSourceID,
|
||||
).Scan(&tillSaleID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert till sale: %v", err)
|
||||
@@ -895,6 +917,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
Note: "Gift Card " + req.Action,
|
||||
BuyerEmail: buyerEmail,
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the
|
||||
// charge with an IDENTICAL body under the same key — Square
|
||||
// compares the whole request on key reuse, and a reconstructed body
|
||||
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||
} else if req.PaymentMethod == "online_square" {
|
||||
// PCI-DSS: raw PANs are never accepted. The admin till must supply a
|
||||
@@ -920,6 +952,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
BuyerEmail: buyerEmail,
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the
|
||||
// charge with an IDENTICAL body under the same key — Square
|
||||
// compares the whole request on key reuse, and a reconstructed body
|
||||
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
|
||||
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for till sale %s: %v", tillSaleID, mErr)
|
||||
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE till_sales SET square_request_snapshot = $1 WHERE id = $2`, string(snap), tillSaleID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for till sale %s: %v", tillSaleID, sErr)
|
||||
}
|
||||
|
||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/mw"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// twoFactorEnforced reports whether 2FA is required for online card payments.
|
||||
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
|
||||
// (REQUIRE_2FA=false) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
|
||||
// stack (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values
|
||||
// are treated as production-enforced, so a mistyped env var can never silently
|
||||
// disarm the gate — main.go logs a startup warning for that misconfiguration.
|
||||
func twoFactorEnforced() bool {
|
||||
if os.Getenv("REQUIRE_2FA") == "false" {
|
||||
return false
|
||||
}
|
||||
return !IsExplicitDevOrMockEnv()
|
||||
}
|
||||
|
||||
// IsExplicitDevOrMockEnv reports whether SQUARE_ENVIRONMENT explicitly selects
|
||||
// the dev/mock Square stack. Only these exact values are treated as dev; an
|
||||
// empty or unknown value is NOT dev (fail-closed), because in production an
|
||||
// unset/mistyped env var must never bypass the 2FA gate.
|
||||
func IsExplicitDevOrMockEnv() bool {
|
||||
switch os.Getenv("SQUARE_ENVIRONMENT") {
|
||||
case "mock", "dev", "development", "test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TwoFactorEnforced is the exported form of twoFactorEnforced, so the user
|
||||
// package (settings endpoints) and the profile handler can report whether 2FA
|
||||
// is currently required without re-implementing the env logic.
|
||||
func (s *PaymentService) TwoFactorEnforced() bool {
|
||||
return twoFactorEnforced()
|
||||
}
|
||||
|
||||
// UserTwoFactorEnabled reports whether the user has completed 2FA setup
|
||||
// (users.two_factor_enabled). It is the source of truth for the card-access
|
||||
// gate: an enforced environment blocks online card access for users who have
|
||||
// not enabled 2FA.
|
||||
func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string) (bool, error) {
|
||||
var enabled bool
|
||||
err := db.Conn.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
||||
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
|
||||
// request may proceed:
|
||||
//
|
||||
// - 2FA is not enforced (dev/mock), OR
|
||||
// - the user has completed 2FA setup (two_factor_enabled).
|
||||
//
|
||||
// When 2FA is enforced and the user has not enabled it, a 403 JSON error is
|
||||
// written (parseable by the frontend via extractErrorMessage) and false is
|
||||
// returned — the caller must abort the charge.
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID string) bool {
|
||||
if !twoFactorEnforced() {
|
||||
return true
|
||||
}
|
||||
if service == nil {
|
||||
service = &PaymentService{}
|
||||
}
|
||||
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
}
|
||||
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
|
||||
return false
|
||||
}
|
||||
if enabled {
|
||||
return true
|
||||
}
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
// Tests for the PSD2 SCA stand-in gate (twofa.go): the twoFactorEnforced() env
|
||||
// matrix, requireTwoFactorForCardAccess() gating, and the end-to-end
|
||||
// enforcement of the saved-card payment paths in CreateBookingPayment /
|
||||
// CreateTillSale. Tests that flip REQUIRE_2FA/SQUARE_ENVIRONMENT via t.Setenv
|
||||
// must stay sequential (no t.Parallel): os.Getenv is process-global and
|
||||
// t.Setenv panics under t.Parallel. Sequential tests run before this package's
|
||||
// parallel batch, so the enforced env never leaks into parallel tests.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"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"
|
||||
)
|
||||
|
||||
func helperEnvEnforce2FA(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("REQUIRE_2FA", "true")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
}
|
||||
|
||||
func TestTwoFactorEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
require2FA string
|
||||
squareEnv string
|
||||
wantEnforced bool
|
||||
}{
|
||||
// Fail-closed default: empty/unknown SQUARE_ENVIRONMENT is treated as
|
||||
// production-enforced, so a mistyped env var can never silently disarm
|
||||
// the gate.
|
||||
{"empty_env_fail_closed_enforced", "", "", true},
|
||||
{"unknown_env_fail_closed_enforced", "", "staging", true},
|
||||
{"require2fa_false_disables_prod", "false", "production", false},
|
||||
{"require2fa_false_disables_sandbox", "false", "sandbox", false},
|
||||
{"require2fa_false_disables_unknown_env", "false", "staging", false},
|
||||
{"production_enforced", "", "production", true},
|
||||
{"sandbox_enforced", "", "sandbox", true},
|
||||
{"require2fa_true_prod_enforced", "true", "production", true},
|
||||
{"mock_never_enforced", "", "mock", false},
|
||||
{"dev_never_enforced", "", "dev", false},
|
||||
{"development_never_enforced", "", "development", false},
|
||||
{"test_never_enforced", "", "test", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("REQUIRE_2FA", tt.require2FA)
|
||||
t.Setenv("SQUARE_ENVIRONMENT", tt.squareEnv)
|
||||
require.Equal(t, tt.wantEnforced, twoFactorEnforced())
|
||||
require.Equal(t, tt.wantEnforced, NewPaymentService().TwoFactorEnforced(), "exported wrapper must match twoFactorEnforced")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
|
||||
// allows every request without touching the DB (no user rows are consulted).
|
||||
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
|
||||
// (fail-closed).
|
||||
func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
|
||||
t.Setenv("REQUIRE_2FA", "")
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001"))
|
||||
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
|
||||
}
|
||||
|
||||
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
t.Run("user_not_enabled_writes_403_json", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
||||
require.NotEmpty(t, body["error"])
|
||||
})
|
||||
|
||||
t.Run("user_enabled_allows", func(t *testing.T) {
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000"))
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
||||
require.NotEmpty(t, body["error"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
|
||||
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 with
|
||||
// no payment row and no saved card (Square never called).
|
||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "2fa-save-card-blocked",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Contains(t, body["error"], "Two-factor")
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||
require.Zero(t, payCount, "blocked 2FA request must not create a payment row")
|
||||
var cardCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1", userID).Scan(&cardCount))
|
||||
require.Zero(t, cardCount, "blocked 2FA request must not persist a card")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked verifies the
|
||||
// gate on charging an existing saved card.
|
||||
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_Blocked(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-saved-card-blocked",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Contains(t, body["error"], "Two-factor")
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||
require.Zero(t, payCount, "blocked saved-card charge must not create a payment row")
|
||||
}
|
||||
|
||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_With2FA_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "2fa-save-card-ok",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
var payCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount))
|
||||
require.Equal(t, 1, payCount)
|
||||
}
|
||||
|
||||
func TestTwoFactorEnforced_CreateBookingPayment_SavedCard_With2FA_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
_, err := tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-saved-card-ok",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_NewCardCharge_NotGated verifies the gate applies ONLY
|
||||
// to saved-card paths: a new-card (nonce) charge is allowed without 2FA even
|
||||
// when enforced.
|
||||
func TestTwoFactorEnforced_NewCardCharge_NotGated(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
cardToken := "cnon:test-card-nonce"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: "2fa-new-card-not-gated",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked verifies the till's
|
||||
// saved-card charge path: an admin charging a customer's saved card while the
|
||||
// card's owner has no 2FA is blocked with 403 and no till_sale is created.
|
||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_Blocked(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: "2fa-till-saved-blocked",
|
||||
}
|
||||
|
||||
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")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Contains(t, body["error"], "Two-factor")
|
||||
|
||||
var saleCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount))
|
||||
require.Zero(t, saleCount, "blocked till saved-card sale must not create a till_sale row")
|
||||
}
|
||||
|
||||
func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET two_factor_enabled = true WHERE id = $1", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "saved_card",
|
||||
UserSavedCardID: &cardID,
|
||||
UserID: &userID,
|
||||
IdempotencyKey: "2fa-till-saved-ok",
|
||||
}
|
||||
|
||||
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")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
}
|
||||
Reference in New Issue
Block a user