fix: loop-B adversarial findings — tip-type double-charge, tip-refund capacity, loyalty stamp farming, gate ordering, auth amplification, admin audit log
Loop B restart (money/security/dup-mod adversarial) fixes: - CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows) - HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows - MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard - MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400 - MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued) - MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited - MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity - LOW-1: logout scoped to the presented token's family (no cross-session kill) - LOW-2: refresh-reuse grace widened for same-IP replays - LOW-4: squareEnvironmentMismatch enforced for empty env - LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID) - Cash/giftcard tip-enabled overflow mirrors the card-terminal carve 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -80,6 +80,16 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
|
||||
var newStampCount int
|
||||
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
|
||||
// Loop B MEDIUM (stamp farming via refund + re-charge): the stamp must
|
||||
// be awarded at most ONCE per booking, no matter how many times the
|
||||
// booking is re-completed. A refund never moves the booking out of
|
||||
// 'in_progress', so a re-payment re-completes it — without this guard
|
||||
// each in_progress→completed transition would re-award a stamp with no
|
||||
// net merchant cash flow. The bookings.loyalty_stamp_awarded_at marker
|
||||
// blocks a booking that already earned its stamp; the marker is written
|
||||
// (same tx) only when the award actually landed, so a daily-cap-blocked
|
||||
// completion does not permanently forfeit the booking's stamp. The
|
||||
// existing "no OTHER completed booking within a day" cap is kept.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET loyalty_stamps = loyalty_stamps + 1
|
||||
@@ -91,6 +101,10 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
|
||||
AND b.id != $2
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM bookings b
|
||||
WHERE b.id = $2 AND b.loyalty_stamp_awarded_at IS NOT NULL
|
||||
)
|
||||
RETURNING loyalty_stamps
|
||||
`, userID, bookingID).Scan(&newStampCount); err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -98,6 +112,14 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
|
||||
}
|
||||
}
|
||||
}
|
||||
if newStampCount > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE bookings SET loyalty_stamp_awarded_at = NOW()
|
||||
WHERE id = $1 AND loyalty_stamp_awarded_at IS NULL
|
||||
`, bookingID); err != nil {
|
||||
log.Printf("Failed to mark loyalty stamp awarded for booking %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create pending redemption when stamps reach LoyaltyStampCost
|
||||
if newStampCount == LoyaltyStampCost {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
|
||||
@@ -1468,8 +1469,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// and SAVING a new card during this purchase (SaveCard), mirroring
|
||||
// CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge
|
||||
// that is not saved is not gated.
|
||||
// consume=false (MEDIUM-2): the code is verified here but only NULLed
|
||||
// inside the completed-charge transaction below (ConsumePendingCode), so a
|
||||
// failed/ambiguous Square charge does NOT burn the operator-relayed code
|
||||
// and a same-key retry can re-verify the SAME code.
|
||||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
||||
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode) {
|
||||
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1720,6 +1725,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// MEDIUM-2: a saved-card gift-card purchase reached its terminal SUCCESS
|
||||
// state — consume the verified 2FA code now, inside the transaction that
|
||||
// records the completed charge (the gate verified without consuming, so a
|
||||
// failed/ambiguous Square charge did not burn the code and a same-key
|
||||
// retry could re-verify the SAME code).
|
||||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
||||
if consErr := twofa.ConsumePendingCode(ctx, issueTx, userID); consErr != nil {
|
||||
log.Printf("CRITICAL: Square payment succeeded (ID=%s) but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, consErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cardID string
|
||||
|
||||
expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crypto/rand"
|
||||
@@ -25,6 +26,45 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// insertAdminAuditCharge records an admin-initiated saved-card charge in
|
||||
// admin_audit_log (MEDIUM-3a). Mirrors the balance_check audit in
|
||||
// giftcards.go:1239-1243 — same table, same columns, same best-effort
|
||||
// non-fatal failure handling. The insert runs in its OWN transaction (a
|
||||
// savepoint in the test harness) so an audit-write failure — e.g. a synthetic
|
||||
// admin id in tests violating the admin_id FK — rolls back only the audit
|
||||
// write and can never abort the caller's transaction or a completed charge.
|
||||
func insertAdminAuditCharge(ctx context.Context, adminID, targetUserID, action string, details map[string]any) {
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal admin_audit_log details (non-critical): %v", err)
|
||||
return
|
||||
}
|
||||
var target any
|
||||
if targetUserID != "" {
|
||||
target = targetUserID
|
||||
}
|
||||
auditTx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := auditTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
slog.Error("failed to rollback admin audit transaction", "err", err)
|
||||
}
|
||||
}()
|
||||
if _, err := auditTx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
`, adminID, action, target, string(detailsJSON)); err != nil {
|
||||
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
|
||||
return
|
||||
}
|
||||
if err := auditTx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateTerminalPaymentRequest struct {
|
||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||
PaymentType string `json:"payment_type" validate:"required"`
|
||||
@@ -365,6 +405,23 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
|
||||
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
|
||||
// enforces the M4 "tips only after the service starts" gate, and the
|
||||
// tip-enabled terminal overflow carve (B3, tip_enabled) records explicit
|
||||
// gratuity as its own payment_type='tip' row. A bare payment_type='tip'
|
||||
// here would record the ENTIRE charge as a tip — and every "is paid"
|
||||
// computation excludes tip rows (paid_total, GetBookingPaymentInfo,
|
||||
// bookingIsFullyPaid, GetBookingRefundableAmountPence) — so the booking
|
||||
// would never be credited and a later legitimate charge would double-collect.
|
||||
// Reject it BEFORE any charge-path branch (cash/giftcard, saved_card,
|
||||
// terminal checkout) so all four sub-paths are closed at once.
|
||||
if req.PaymentType == "tip" {
|
||||
log.Printf("Payment rejected: booking %s payment_type 'tip' is not allowed via /payment — tips use the dedicated /tip endpoint", bookingID)
|
||||
http.Error(w, "Tips can only be added via the dedicated tip endpoint after the booking has started", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
amount := req.Amount
|
||||
@@ -430,47 +487,111 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// B3: clamp the recorded amount to the booking's remaining obligation.
|
||||
// The booking row FOR UPDATE lock above serializes concurrent cash/
|
||||
// giftcard payments on this booking, so this read races no same-method
|
||||
// payment. A fully-paid booking is rejected below (nothing left to
|
||||
// record).
|
||||
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
|
||||
if cErr != nil {
|
||||
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if clamped && effectiveAmount <= 0 {
|
||||
// B3: the clamp zeroed the amount because the booking is fully paid
|
||||
// (remaining <= 0). Reject rather than record a phantom £0 payment —
|
||||
// an overpayment is handled manually at the counter, not minted
|
||||
// into the ledger.
|
||||
log.Printf("Terminal %s payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", *req.PaymentMethod, bookingID, remaining, amount)
|
||||
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if clamped {
|
||||
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
|
||||
amount = effectiveAmount
|
||||
}
|
||||
|
||||
amountPounds := float64(amount) / 100.0
|
||||
var paymentID string
|
||||
|
||||
if *req.PaymentMethod == "cash" {
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID).Scan(&paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create cash payment record: %v", err)
|
||||
// B3: clamp the recorded amount to the booking's remaining obligation
|
||||
// unless the customer explicitly requested a tip (tip_enabled) — mirror
|
||||
// the card-terminal tip bound below: the booking portion can never
|
||||
// exceed what is owed, and the tip portion can never exceed
|
||||
// maxTerminalTipPence. The booking row FOR UPDATE lock above serializes
|
||||
// concurrent cash/giftcard payments on this booking, so this read races
|
||||
// no same-method payment. A fully-paid no-tip booking is rejected below
|
||||
// (nothing left to record).
|
||||
var remaining int64
|
||||
if req.TipEnabled {
|
||||
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
|
||||
if remErr != nil {
|
||||
log.Printf("Failed to compute remaining balance for tip-enabled terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, remErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||
remaining = remainingPence
|
||||
maxChargePence := remainingPence + maxTerminalTipPence
|
||||
if amount > maxChargePence {
|
||||
log.Printf("Terminal %s payment for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", *req.PaymentMethod, bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
|
||||
amount = maxChargePence
|
||||
}
|
||||
} else {
|
||||
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
|
||||
if cErr != nil {
|
||||
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if clamped && effectiveAmount <= 0 {
|
||||
// B3: the clamp zeroed the amount because the booking is fully paid
|
||||
// (remaining <= 0). Reject rather than record a phantom £0 payment —
|
||||
// an overpayment is handled manually at the counter, not minted
|
||||
// into the ledger.
|
||||
log.Printf("Terminal %s payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", *req.PaymentMethod, bookingID, remaining, amount)
|
||||
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if clamped {
|
||||
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
|
||||
amount = effectiveAmount
|
||||
}
|
||||
}
|
||||
|
||||
// M4 (mirror of the card-terminal carve at sweep.go): when the customer
|
||||
// explicitly requested a tip, any part of the charged amount beyond the
|
||||
// remaining booking value is gratuity and must be recorded as its own
|
||||
// payment_type='tip' row — never absorbed into the booking payment
|
||||
// (which would over-credit the booking) nor rejected. The booking
|
||||
// portion keeps the requested payment type, exactly as the non-tip
|
||||
// cash/giftcard flow records it.
|
||||
tipPortion := int64(0)
|
||||
bookingPortion := amount
|
||||
if req.TipEnabled && remaining < amount {
|
||||
tipPortion = amount - remaining
|
||||
bookingPortion = remaining
|
||||
}
|
||||
|
||||
amountPounds := float64(amount) / 100.0
|
||||
bookingPortionPounds := float64(bookingPortion) / 100.0
|
||||
tipPounds := float64(tipPortion) / 100.0
|
||||
var paymentID string
|
||||
|
||||
if *req.PaymentMethod == "cash" {
|
||||
if bookingPortionPounds > 0.004 {
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, 'cash', 'completed', $3, $4, $5, NOW(), NOW())
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID).Scan(&paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create cash payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ApplyVATToBookingPayment(r.Context(), tx, paymentID)
|
||||
}
|
||||
// The tip carve is recorded as its own 'tip' row so the booking
|
||||
// portion is the only money that counts toward the obligation.
|
||||
// When the charge is tip-only (fully-paid booking), the tip row is
|
||||
// the ONLY record and its id is returned as the checkout id,
|
||||
// mirroring the card-terminal carve (primary := records[0]).
|
||||
if tipPounds > 0.004 {
|
||||
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
|
||||
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "cash",
|
||||
Status: "completed",
|
||||
Amount: tipPounds,
|
||||
IdempotencyKey: &tipKey,
|
||||
CreatedBy: &adminID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
}, nil)
|
||||
if tipErr != nil {
|
||||
log.Printf("Failed to create cash tip payment record: %v", tipErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if paymentID == "" {
|
||||
paymentID = tipID
|
||||
}
|
||||
}
|
||||
} else { // giftcard
|
||||
var customerID sql.NullString
|
||||
err = tx.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&customerID)
|
||||
@@ -567,29 +688,63 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
giftCardPaymentID = &cleanCardID
|
||||
}
|
||||
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id
|
||||
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW(), $6)
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, amountPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&paymentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create giftcard payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
// The gift-card source funds the full charged amount (booking
|
||||
// portion + tip). The primary row records the booking portion; the
|
||||
// tip carve is recorded as its own 'tip' row sourced from the same
|
||||
// gift card (C3 source-of-funds tracking), so the booking portion
|
||||
// is the only money that counts toward the obligation.
|
||||
bookingPayID := ""
|
||||
if bookingPortionPounds > 0.004 {
|
||||
err = tx.QueryRow(r.Context(), `
|
||||
INSERT INTO payments (
|
||||
booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id
|
||||
) VALUES ($1, $2, 'giftcard', 'completed', $3, $4, $5, NOW(), NOW(), $6)
|
||||
RETURNING id
|
||||
`, bookingID, req.PaymentType, bookingPortionPounds, idempotencyKey, adminID, giftCardPaymentID).Scan(&bookingPayID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create giftcard payment record: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
paymentID = bookingPayID
|
||||
}
|
||||
if tipPounds > 0.004 {
|
||||
tipKey := splitIdempotencyKey(idempotencyKey, "-split-tip")
|
||||
tipID, tipErr := service.CreatePaymentRecordTx(r.Context(), tx, PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: "tip",
|
||||
PaymentMethod: "giftcard",
|
||||
Status: "completed",
|
||||
Amount: tipPounds,
|
||||
IdempotencyKey: &tipKey,
|
||||
CreatedBy: &adminID,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
}, giftCardPaymentID)
|
||||
if tipErr != nil {
|
||||
log.Printf("Failed to create giftcard tip payment record: %v", tipErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if paymentID == "" {
|
||||
paymentID = tipID
|
||||
}
|
||||
}
|
||||
|
||||
// Apply VAT at redemption only if the gift card was purchased as MPV
|
||||
// (VAT deferred to redemption). For SPV, VAT was already paid at sale.
|
||||
// For account balance payments (usedBalance=true), VAT was already paid
|
||||
// when the original card was purchased.
|
||||
if usedBalance {
|
||||
// VAT already paid at purchase time — nothing to do here.
|
||||
} else if cardVoucherType == "MPV" {
|
||||
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||||
if vatErr == nil && vatCfg.IsVATRegistered {
|
||||
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", paymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||
log.Printf("Failed to apply VAT to giftcard payment %s: %v", paymentID, vatExecErr)
|
||||
// when the original card was purchased. Applied to the booking-portion
|
||||
// payment only — a tip record is never VAT-applicable.
|
||||
if bookingPayID != "" {
|
||||
if usedBalance {
|
||||
// VAT already paid at purchase time — nothing to do here.
|
||||
} else if cardVoucherType == "MPV" {
|
||||
vatCfg, vatErr := GetVATConfig(r.Context(), tx)
|
||||
if vatErr == nil && vatCfg.IsVATRegistered {
|
||||
if _, vatExecErr := tx.Exec(r.Context(), "SELECT apply_vat_to_payment($1, $2)", bookingPayID, vatCfg.DefaultVATRate); vatExecErr != nil {
|
||||
log.Printf("Failed to apply VAT to giftcard payment %s: %v", bookingPayID, vatExecErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,7 +949,11 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// existing result WITHOUT demanding a fresh code — no new money moves,
|
||||
// so no new authorization is needed. Pending-reuse retries and fresh
|
||||
// charges still pass through the gate.
|
||||
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode) {
|
||||
// consume=false (MEDIUM-2): the code is verified here but only NULLed
|
||||
// inside the completed-charge transaction below (ConsumePendingCode),
|
||||
// so a failed/ambiguous Square charge does NOT burn the operator-relayed
|
||||
// code and a same-key retry can re-verify the SAME code.
|
||||
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, false) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -993,6 +1152,19 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// MEDIUM-2: the charge reached its terminal SUCCESS state — consume the
|
||||
// verified 2FA code now, INSIDE the transaction that records the
|
||||
// completed charge (the gate verified without consuming so a failed
|
||||
// charge would not burn the code). A failure here fails the whole
|
||||
// transaction (the row stays pending and the sweep reconciles), which is
|
||||
// the same known failure mode as any other post-charge tx error.
|
||||
if bookingUserID.Valid {
|
||||
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, bookingUserID.String); consErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, bookingUserID.String, consErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
// B14: apply VAT to the saved-card terminal charge, inside the same
|
||||
// transaction as the completed flip (like the booking path at 2021-2028
|
||||
// and the cash path at 397). Without this the saved-card branch never
|
||||
@@ -1008,6 +1180,20 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// MEDIUM-3a: record the admin-initiated saved-card charge in
|
||||
// admin_audit_log (mirroring giftcards.go's balance_check audit). Runs
|
||||
// best-effort AFTER the money transaction commits so an audit-write
|
||||
// failure can never roll back a completed charge.
|
||||
if bookingUserID.Valid {
|
||||
insertAdminAuditCharge(r.Context(), adminID, bookingUserID.String, "saved_card_charge", map[string]any{
|
||||
"booking_id": bookingID,
|
||||
"payment_id": paymentID,
|
||||
"amount": float64(amount) / 100.0,
|
||||
"card_last4": paymentResult.CardLast4,
|
||||
"square_payment_id": paymentResult.SquarePayID,
|
||||
})
|
||||
}
|
||||
|
||||
// F6: a fully-paid saved-card charge completes the booking exactly like
|
||||
// the terminal path (recordTerminalPaymentTx → completeFullyPaidBooking,
|
||||
// sweep.go:1632). Runs in its OWN transaction after the status commit
|
||||
@@ -1563,11 +1749,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
|
||||
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 {
|
||||
@@ -1872,6 +2053,18 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to check idempotency: %v", err)
|
||||
}
|
||||
|
||||
// 2FA gating (C5): persisting a card requires 2FA when the feature is
|
||||
// enforced. This runs AFTER the idempotency dedup's completed
|
||||
// short-circuit (Loop B MEDIUM): a same-key lost-response retry returns the
|
||||
// already-completed payment above without re-entering the gate, so its
|
||||
// single-use code (already consumed by the original attempt) is never
|
||||
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
|
||||
// new charge may move at Square. The gate also runs before
|
||||
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// After the idempotency check (which handles same-key retries), verify
|
||||
// that no completed payment of the same non-partial type already exists.
|
||||
// buildSplitRecords converts 'full' and 'deposit' input types into a
|
||||
@@ -2005,9 +2198,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// enforced. New-card (nonce) charges are not gated. consume=false
|
||||
// (MEDIUM-2): the code is verified here but only NULLed inside the
|
||||
// completed-charge transaction below (ConsumePendingCode), so a
|
||||
// failed/ambiguous Square charge does NOT burn the operator-relayed code
|
||||
// and a same-key retry can re-verify the SAME code.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2268,6 +2465,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// MEDIUM-2: a saved-card charge reached its terminal SUCCESS state —
|
||||
// consume the verified 2FA code now, inside the same transaction that
|
||||
// records the completed charge (the gate verified without consuming, so a
|
||||
// failed charge would not burn the code and a same-key retry could reuse
|
||||
// it). Only runs for saved-card (CardID) charges — the gate only ran for
|
||||
// those, and new-card charges have no code to consume.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if consErr := twofa.ConsumePendingCode(r.Context(), tx2, userID); consErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the additional split records. They carry the derived -split-N
|
||||
// idempotency keys, which are new rows; if the split produced only one
|
||||
// record, there is nothing more to insert.
|
||||
@@ -2825,8 +3037,10 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
// requires 2FA when the feature is enforced — the same gate the booking
|
||||
// and tip flows apply to req.SaveCard. Persisting a stored credential is
|
||||
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
|
||||
// endpoint must not be the un-gated side door.
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
|
||||
// endpoint must not be the un-gated side door. consume=true: saving a card
|
||||
// is a terminal operation with no downstream charge to attach consumption
|
||||
// to (MEDIUM-2).
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2962,6 +3176,21 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A tip payment is gratuity, not booking money: every refund computation
|
||||
// excludes tip rows (GetBookingRefundableAmountPence, refunds.go, and the
|
||||
// AdminRefundBooking query at handlers.go:3597). Refunding a tip here would
|
||||
// pay the gratuity back while GetBookingRemainingBalancePence's refunded
|
||||
// total re-opens booking charge capacity (a tip refund counts as "returned
|
||||
// money") — a fully-paid booking would accept a second legitimate charge,
|
||||
// double-collecting the balance. Tips are deliberately not refundable via
|
||||
// this handler. Tip split rows share the charge's square_payment_id, so the
|
||||
// Square reference guard below cannot catch them — this explicit check must
|
||||
// run before it.
|
||||
if payment.PaymentType == "tip" {
|
||||
http.Error(w, "Cannot refund a tip payment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Money-safety guard (M7): a payments row with NO booking is a gift-card
|
||||
// purchase (BuyGiftCard inserts without a booking — the same discriminator
|
||||
// the sweep uses in sweep.go). Refunding such a payment at Square returns
|
||||
@@ -3910,7 +4139,11 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
service := NewPaymentService()
|
||||
|
||||
// 2FA gating (C5): persisting a card requires 2FA when the feature is enforced.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode) {
|
||||
// consume=true: saving a card is a terminal operation (the card row is
|
||||
// created right here), so the verified code is single-use immediately —
|
||||
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
||||
// charge's terminal success (MEDIUM-2).
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4008,13 +4241,6 @@ func CreateTipPayment(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, req.VerificationCode) {
|
||||
return
|
||||
}
|
||||
}
|
||||
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(r.Context(), w, service, userID, req.NewCardToken, req.CardID, req.SaveCard, "Card not found")
|
||||
if !sourceOK {
|
||||
return
|
||||
@@ -4144,6 +4370,19 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to check tip idempotency: %v", err)
|
||||
}
|
||||
|
||||
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
|
||||
// enforced. New-card (nonce) charges are not gated. This runs AFTER the
|
||||
// idempotency dedup's completed short-circuit (Loop B MEDIUM): a same-key
|
||||
// lost-response retry returns the already-completed payment above without
|
||||
// re-entering the gate, so its single-use code (already consumed by the
|
||||
// original attempt) is never re-rejected as "expired". Pending-reuse and
|
||||
// fresh paths still gate — a new charge may move at Square.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !reusePendingRecord {
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
@@ -4314,6 +4553,18 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// MEDIUM-2: a saved-card tip charge reached its terminal SUCCESS state —
|
||||
// consume the verified 2FA code now, inside the transaction that records
|
||||
// the completed charge (the gate verified without consuming, so a failed
|
||||
// charge did not burn the code and a same-key retry could reuse it).
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if consErr := twofa.ConsumePendingCode(r.Context(), recheckTx, userID); consErr != nil {
|
||||
log.Printf("CRITICAL: Square tip payment %s (ID=%s) was processed but consuming the 2FA code for user %s failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, userID, consErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if cErr := recheckTx.Commit(r.Context()); cErr != nil {
|
||||
log.Printf("CRITICAL: Square tip payment %s (ID=%s) succeeded but committing the post-charge status update for payment %s failed: %v — manual reconciliation required",
|
||||
paymentResult.Status, paymentResult.SquarePayID, paymentID, cErr)
|
||||
|
||||
@@ -483,6 +483,10 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
|
||||
-- Tips are excluded from TotalPaid above, so a tip refund must
|
||||
-- equally be excluded here — otherwise a refunded tip would
|
||||
-- subtract from the paid total and re-open booking capacity.
|
||||
AND p.payment_type <> 'tip'
|
||||
GROUP BY p.booking_id
|
||||
) rr ON b.id = rr.booking_id
|
||||
WHERE b.id = $1
|
||||
@@ -521,6 +525,10 @@ func (s *PaymentService) GetBookingRemainingBalancePence(ctx context.Context, bo
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status = 'completed'
|
||||
-- A tip refund returns gratuity, not booking money — it must not
|
||||
-- re-open booking charge capacity (mirror of the paid_total tip
|
||||
-- exclusion above).
|
||||
AND p.payment_type <> 'tip'
|
||||
)
|
||||
-- Money-safety (M-cap): refunds return money, so they re-open booking
|
||||
-- capacity — remaining = total - paid + refunded. LEAST clamps the cap
|
||||
|
||||
@@ -691,11 +691,12 @@ const replayLegitimateRetryWindow = 22 * time.Hour
|
||||
// which makes a retained-key dedup return the ORIGINAL charge with created <
|
||||
// r.CreatedAt. Without this tolerance the sweep would declare that original
|
||||
// payment a "new charge" and auto-refund a charge the customer legitimately
|
||||
// authorized. Anything at or before row.CreatedAt is therefore treated as
|
||||
// AMBIGUOUS (never auto-refunded, never rescued) — see
|
||||
// reconcileStalePaymentByKey. A payment created AFTER row.CreatedAt +
|
||||
// replayLegitimateRetryWindow remains the provable expired-key duplicate.
|
||||
const replayRescueLowerBoundSkew = time.Minute
|
||||
// authorized. A payment created within the skew BEFORE the row is therefore the
|
||||
// ORIGINAL and is rescued (Loop B MEDIUM — a >1min-ahead DB clock previously
|
||||
// stranded such a charge pending until the 24h blind-fail); only a payment
|
||||
// created more than the skew before the row, or after row.CreatedAt +
|
||||
// replayLegitimateRetryWindow, is ambiguous / a provable expired-key duplicate.
|
||||
const replayRescueLowerBoundSkew = 5 * time.Minute
|
||||
|
||||
// replayMatchesRowAmount reports whether the replayed payment charged the same
|
||||
// amount the pending row records — the amount the sweep's replay body repeats
|
||||
@@ -728,7 +729,14 @@ func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool {
|
||||
if !ok || r.CreatedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !created.Before(r.CreatedAt) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
|
||||
// The lower bound is replayRescueLowerBoundSkew BEFORE the row: a DB clock
|
||||
// running ahead of Square's (independent NTP drift, VM pause/resume) can
|
||||
// make a retained-key dedup return the ORIGINAL charge with created_at
|
||||
// slightly before the pending row. Such a payment cannot be a provably-new
|
||||
// expired-key replay (those land ~22h AFTER the row), so within the skew
|
||||
// tolerance it is the legitimate original and must be rescued — not left
|
||||
// pending to strand the customer's authorized charge (Loop B MEDIUM).
|
||||
return !created.Before(r.CreatedAt.Add(-replayRescueLowerBoundSkew)) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
|
||||
}
|
||||
|
||||
// isSavedCardSource reports whether a Square source id is a card-on-file
|
||||
@@ -769,10 +777,10 @@ func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
|
||||
// replayed payment that cannot be proven to be the original (or a retry
|
||||
// within the legitimate window) is never rescued (the row stays pending, a
|
||||
// CRITICAL log is raised and an admin notification inserted), so a hidden
|
||||
// second charge can never masquerade as the original one. The caller further
|
||||
// refuses to auto-refund a payment created BEFORE the row (replayRescueLowerBoundSkew
|
||||
// tolerance — a DB clock ahead of Square's can make the retained-key original
|
||||
// look slightly older): such a payment is not provably a new charge.
|
||||
// second charge can never masquerade as the original one. The lower bound of
|
||||
// the legitimate window is replayRescueLowerBoundSkew BEFORE the row: a DB
|
||||
// clock ahead of Square's can make the retained-key original look slightly
|
||||
// older, and such a payment is the legitimate original, not a new charge.
|
||||
//
|
||||
// The check runs ONLY against real Square timestamps: it is gated off in an
|
||||
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
|
||||
@@ -973,20 +981,15 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
||||
}
|
||||
// B1 (MEDIUM 2): a replayed COMPLETED payment created BEFORE the
|
||||
// pending row is NOT provably a new expired-key replay — those land
|
||||
// ~22h AFTER the row. A retained-key dedup returns the ORIGINAL
|
||||
// charge, and a DB clock running AHEAD of Square's (rows are
|
||||
// inserted pending-first; independent NTP drift, VM pause/resume)
|
||||
// can make that original's created_at lag the row's by up to
|
||||
// replayRescueLowerBoundSkew. Auto-refunding it would reverse a
|
||||
// legitimate payment the customer authorized, so any before-row
|
||||
// created_at is ambiguous: the row is left PENDING with a CRITICAL
|
||||
// notification for manual reconciliation.
|
||||
// ~22h AFTER the row. Only genuinely-before payments reach this
|
||||
// line: a payment within replayRescueLowerBoundSkew of the row is
|
||||
// already treated as the ORIGINAL retained-key charge by
|
||||
// replayWithinLegitimateWindow and rescued. This one is far enough
|
||||
// before the row that no clock skew can explain it — the row is
|
||||
// left PENDING with a CRITICAL notification for manual
|
||||
// reconciliation, never auto-refunded.
|
||||
if created.Before(r.CreatedAt) {
|
||||
skew := "well before"
|
||||
if !created.Before(r.CreatedAt.Add(-replayRescueLowerBoundSkew)) {
|
||||
skew = "slightly before (within the clock-skew tolerance)"
|
||||
}
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created %s the pending row %s — cannot prove it is a NEW expired-key replay (a DB clock ahead of Square's can make a retained-key original look slightly older) — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, skew, r.ID, r.ID)
|
||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created %s before the pending row %s (beyond the %s clock-skew tolerance) — cannot prove it is a NEW expired-key replay nor the ORIGINAL charge — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, created.Sub(r.CreatedAt).Round(time.Minute), r.ID, replayRescueLowerBoundSkew, r.ID)
|
||||
}
|
||||
lag := created.Sub(r.CreatedAt).Round(time.Minute).String()
|
||||
// B1: the replayed COMPLETED payment is a REAL charge the customer
|
||||
|
||||
@@ -1263,15 +1263,16 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending locks
|
||||
// the B1 lower-bound clock-skew tolerance: a replayed COMPLETED payment created
|
||||
// slightly BEFORE the pending row must NOT be auto-refunded as a "new charge".
|
||||
// Rows are inserted pending-first (row.CreatedAt precedes Square's created_at by
|
||||
// BEFORE the pending row must NOT be auto-refunded as a "new charge". Rows are
|
||||
// inserted pending-first (row.CreatedAt precedes Square's created_at by
|
||||
// ~0.5-1s), and a DB clock running AHEAD of Square's (independent NTP drift, VM
|
||||
// pause/resume) makes a retained-key dedup return the ORIGINAL charge with
|
||||
// created < row.CreatedAt. Such a payment cannot be proven to be a new
|
||||
// expired-key replay (those land ~22h after the row), so auto-refunding it would
|
||||
// reverse a legitimate charge — the row is left PENDING with a CRITICAL
|
||||
// notification instead. Sequential (flips SQUARE_ENVIRONMENT), like the sibling
|
||||
// B1/A1 tests.
|
||||
// created < row.CreatedAt. A payment within replayRescueLowerBoundSkew of the
|
||||
// row is that clock-skewed ORIGINAL and is rescued (locked by
|
||||
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues); this
|
||||
// fixture sits BEYOND the 5-minute tolerance, where no clock skew can explain
|
||||
// it — the row is left PENDING with a CRITICAL notification instead. Sequential
|
||||
// (flips SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
|
||||
func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
@@ -1298,15 +1299,15 @@ func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
|
||||
// key whose created_at lags the DB row's by 2s — the DB clock running ahead
|
||||
// of Square's. Seeding from the row's own timestamp (minus 2s) keeps the
|
||||
// lag deterministic.
|
||||
// The replayed COMPLETED payment is created 10 minutes BEFORE the row —
|
||||
// beyond the 5-minute clock-skew tolerance, so it cannot be a clock-skewed
|
||||
// retained-key original and must remain ambiguous. Seeding from the row's
|
||||
// own timestamp keeps the lag deterministic.
|
||||
var rowCreatedAt time.Time
|
||||
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
||||
t.Fatalf("failed to read aged payment created_at: %v", err)
|
||||
}
|
||||
skewedCreated := rowCreatedAt.Add(-2 * time.Second)
|
||||
skewedCreated := rowCreatedAt.Add(-10 * time.Minute)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient()
|
||||
@@ -1370,6 +1371,107 @@ func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(t *
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues locks the
|
||||
// Loop B MEDIUM lower-bound fix: a replayed COMPLETED payment created slightly
|
||||
// BEFORE the pending row — within the 5-minute replayRescueLowerBoundSkew — is
|
||||
// the clock-skewed ORIGINAL charge under a retained key (a DB clock ahead of
|
||||
// Square's makes a retained-key dedup return the original with created <
|
||||
// row.CreatedAt), NOT a provably-new duplicate. It must be RESCUED to
|
||||
// 'completed'; stranding it pending (the pre-fix behavior for any before-row
|
||||
// payment) would leave the customer's legitimately-authorized charge to resolve
|
||||
// only via the 24h blind-fail as a WARN'd failed payment. Sequential (flips
|
||||
// SQUARE_ENVIRONMENT), like the sibling B1/A1 tests.
|
||||
func TestSweepStalePendingPayments_KeyedReplaySlightlyBeforeRow_Rescues(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-retained-within-skew"
|
||||
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
|
||||
t.Fatalf("failed to age the stale payment: %v", err)
|
||||
}
|
||||
|
||||
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
|
||||
// key whose created_at lags the DB row's by 2 minutes — inside the 5-minute
|
||||
// clock-skew tolerance. Seeding from the row's own timestamp keeps the lag
|
||||
// deterministic.
|
||||
var rowCreatedAt time.Time
|
||||
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
||||
t.Fatalf("failed to read aged payment created_at: %v", err)
|
||||
}
|
||||
skewedCreated := rowCreatedAt.Add(-2 * time.Minute)
|
||||
|
||||
origClient := SquareClient
|
||||
mock := square.NewDevClient()
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
||||
Status: "COMPLETED",
|
||||
ID: "pay_original_within_skew",
|
||||
SquarePayID: "pay_original_within_skew",
|
||||
CreatedAt: skewedCreated.Format(time.RFC3339Nano),
|
||||
}}
|
||||
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 refunds WHERE payment_id = $1`, staleID)
|
||||
_, _ = 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`, 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)
|
||||
}
|
||||
|
||||
// Within-tolerance before-row: the clock-skewed ORIGINAL is rescued, never
|
||||
// auto-refunded and never left pending.
|
||||
var status, sqPayID string
|
||||
if err := db.Conn.QueryRow(freshCtx, "SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1", staleID).Scan(&status, &sqPayID); err != nil {
|
||||
t.Fatalf("failed to query payment: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected a within-tolerance before-row replay rescued to 'completed', got %q", status)
|
||||
}
|
||||
if sqPayID != "pay_original_within_skew" {
|
||||
t.Errorf("expected square_payment_id %s written back on the rescue, got %q", "pay_original_within_skew", sqPayID)
|
||||
}
|
||||
|
||||
var refundCount int
|
||||
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
||||
t.Fatalf("failed to count refunds: %v", err)
|
||||
}
|
||||
if refundCount != 0 {
|
||||
t.Errorf("expected NO auto-refund of a within-tolerance before-row original, got %d refund rows", refundCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2
|
||||
// dead-zone fix: a same-key retry whose charge landed 21.5h after the pending
|
||||
// row (between the old 21h window and the 22h sweep cutoff) is the REAL charge
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/internal/twofa"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
|
||||
@@ -888,6 +889,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// cardUserID is the owner of the charged saved card (till sales are not
|
||||
// user-scoped). Resolved in the saved_card case below; hoisted here because
|
||||
// the post-charge 2FA consumption + audit after the Square call need it.
|
||||
var cardUserID sql.NullString
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case "cash":
|
||||
saleStatus = "completed"
|
||||
@@ -909,13 +915,14 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// The till path is not user-scoped, so fetch the card's owner along with
|
||||
// the charge details — the owner is needed to lazily provision a Square
|
||||
// customer if the row predates P14 (R6).
|
||||
var cardUserID sql.NullString
|
||||
// customer if the row predates P14 (R6). cardUserID is declared at
|
||||
// function scope (before the switch) because the post-charge 2FA
|
||||
// consumption + audit need it after the Square call.
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT user_id, COALESCE(square_card_id, ''), COALESCE(square_customer_id, '')
|
||||
FROM user_saved_cards
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, *req.UserSavedCardID).Scan(&cardUserID, &savedCardSqCardID, &savedCardCustomerID)
|
||||
SELECT user_id, COALESCE(square_card_id, ''), COALESCE(square_customer_id, '')
|
||||
FROM user_saved_cards
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, *req.UserSavedCardID).Scan(&cardUserID, &savedCardSqCardID, &savedCardCustomerID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get saved card details: %v", err)
|
||||
http.Error(w, "Card not found", http.StatusNotFound)
|
||||
@@ -947,8 +954,12 @@ 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, req.VerificationCode) {
|
||||
// the feature is enforced. consume=false (MEDIUM-2): the code is
|
||||
// verified here but only NULLed once the charge reaches its terminal
|
||||
// success state below (ConsumePendingCode), so a failed/ambiguous
|
||||
// Square charge does NOT burn the operator-relayed code and a same-key
|
||||
// retry can re-verify the SAME code.
|
||||
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, false) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1274,6 +1285,28 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// MEDIUM-2: a saved-card till charge reached its terminal SUCCESS state
|
||||
// — consume the verified 2FA code now (the gate verified without
|
||||
// consuming, so a failed/ambiguous charge did not burn the code and a
|
||||
// same-key retry could reuse it). Best-effort after the completion
|
||||
// write: a consume failure cannot undo the completed sale, it only
|
||||
// leaves the code valid until its 10-minute expiry.
|
||||
if req.PaymentMethod == "saved_card" && cardUserID.Valid {
|
||||
if consErr := twofa.ConsumePendingCode(ctx, db.Conn, cardUserID.String); consErr != nil {
|
||||
log.Printf("CRITICAL: Square payment %s succeeded but consuming the 2FA code for user %s failed: %v — MANUAL RECONCILIATION REQUIRED", paymentResult.SquarePayID, cardUserID.String, consErr)
|
||||
}
|
||||
// MEDIUM-3a: record the admin-initiated saved-card till charge in
|
||||
// admin_audit_log (mirroring giftcards.go's balance_check audit).
|
||||
insertAdminAuditCharge(ctx, adminID, cardUserID.String, "till_saved_card_charge", map[string]any{
|
||||
"till_sale_id": tillSaleID,
|
||||
"item_type": req.ItemType,
|
||||
"gift_card_id": giftCardID,
|
||||
"amount": req.Amount,
|
||||
"card_last4": paymentResult.CardLast4,
|
||||
"square_payment_id": paymentResult.SquarePayID,
|
||||
})
|
||||
}
|
||||
|
||||
saleStatus = "completed"
|
||||
}
|
||||
|
||||
|
||||
@@ -75,14 +75,18 @@ func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string
|
||||
// stored pending 2FA code. It is a thin delegation shim over
|
||||
// twofa.VerifyForUser — the single source of truth for the verification core
|
||||
// (per-user brute-force lockout, constant-time compare, legacy pre-pepper
|
||||
// hash fallback, code lifetime). consume=true is passed so a verified code is
|
||||
// SINGLE-USE: the gate NULLs the pending code on success, so one code
|
||||
// authorizes exactly one saved-card charge (not unlimited charges for its
|
||||
// 10-minute lifetime). It returns nil on a valid code, or a classified
|
||||
// twofa.ErrIncorrect / twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a
|
||||
// wrapped DB error) for the caller to map to the correct HTTP status.
|
||||
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error {
|
||||
return twofa.VerifyForUser(ctx, userID, code, true)
|
||||
// hash fallback, code lifetime). consume=true makes a verified code SINGLE-USE
|
||||
// immediately (the pending code is NULLed on success); consume=false verifies
|
||||
// WITHOUT consuming (MEDIUM-2 — the saved-card CHARGE gates pass false and
|
||||
// defer consumption to the completed-charge transaction via
|
||||
// twofa.ConsumePendingCode, so a failed Square charge does not burn the code;
|
||||
// the save-card SAVE gate passes true because saving a card is a terminal
|
||||
// operation with no downstream charge to attach consumption to). It returns nil
|
||||
// on a valid code, or a classified twofa.ErrIncorrect / twofa.ErrLockedOut /
|
||||
// twofa.ErrMissingOrExpired (or a wrapped DB error) for the caller to map to
|
||||
// the correct HTTP status.
|
||||
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consume bool) error {
|
||||
return twofa.VerifyForUser(ctx, userID, code, consume)
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
||||
@@ -100,6 +104,13 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error
|
||||
// operator relays (delivery is the user package's build-dependent [2FA] log /
|
||||
// email-SMS channel).
|
||||
//
|
||||
// consume controls whether a verified code is NULLed immediately (consume=true
|
||||
// — the save-card SAVE gate) or left intact for the caller to consume when its
|
||||
// operation reaches a terminal success state (consume=false — the saved-card
|
||||
// CHARGE gates; see verifyPendingTwoFactorCode / twofa.ConsumePendingCode,
|
||||
// MEDIUM-2). In every case the 5-attempt lockout and the
|
||||
// code-destroy-on-lockout semantics are unchanged (twofa.Check).
|
||||
//
|
||||
// The code check is delegated to crussell/internal/twofa via
|
||||
// verifyPendingTwoFactorCode, so this gate participates in the SAME per-user
|
||||
// brute-force lockout (5 failed attempts invalidate the pending code) as the
|
||||
@@ -109,7 +120,7 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error
|
||||
//
|
||||
// On any denial an error JSON 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, verificationCode string) bool {
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
|
||||
if !twoFactorEnforced() {
|
||||
return true
|
||||
}
|
||||
@@ -136,7 +147,7 @@ func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, servi
|
||||
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
|
||||
return false
|
||||
}
|
||||
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode); {
|
||||
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
||||
case err == nil:
|
||||
return true
|
||||
case errors.Is(err, twofa.ErrIncorrect):
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", ""))
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false))
|
||||
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456")
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]string
|
||||
@@ -137,7 +137,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
// B10: the enabled setup flag alone must NOT unlock the gate.
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "")
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
@@ -148,7 +148,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"))
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000")
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var body map[string]string
|
||||
@@ -169,7 +169,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
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", "123456"))
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", false))
|
||||
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")
|
||||
@@ -177,12 +177,14 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestRequireTwoFactorForCardAccess_CodeIsSingleUse pins the finding-1 fix: a
|
||||
// code verified through the gate is CONSUMED (the pending code is NULLed), so
|
||||
// the same code cannot authorize a second saved-card charge within its
|
||||
// 10-minute lifetime. The second attempt with the same code is denied with the
|
||||
// documented "expired — request a new one" 400.
|
||||
func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) {
|
||||
// TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume pins the MEDIUM-2
|
||||
// contract: the charge gate verifies the code WITHOUT consuming it (consume
|
||||
// happens later, at the charge's terminal SUCCESS state via
|
||||
// twofa.ConsumePendingCode), so a failed/ambiguous Square charge does NOT burn
|
||||
// the operator-relayed code — a same-key retry can re-verify the SAME code.
|
||||
// Only an explicit ConsumePendingCode (the completed-charge path) NULLs it,
|
||||
// after which the code is dead ("expired").
|
||||
func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
@@ -191,17 +193,27 @@ func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "first use of the code must pass the gate")
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "first gate pass must succeed")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// The verified code must now be consumed (NULLed) in the DB.
|
||||
// The code must still be present — the gate verified WITHOUT consuming.
|
||||
var pendingHash sql.NullString
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
||||
require.False(t, pendingHash.Valid, "a verified gate code must be consumed (NULLed)")
|
||||
require.True(t, pendingHash.Valid, "the gate must NOT consume the code (MEDIUM-2)")
|
||||
|
||||
// A second charge attempt with the same code must be denied as expired.
|
||||
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
|
||||
w = httptest.NewRecorder()
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "a consumed code must not pass the gate twice")
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a not-yet-consumed code must pass the gate again on retry")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Consumption happens at the charge's terminal SUCCESS state.
|
||||
require.NoError(t, twofa.ConsumePendingCode(ctx, tx, userID))
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
|
||||
require.False(t, pendingHash.Valid, "ConsumePendingCode must NULL the pending code")
|
||||
|
||||
// A further attempt with the consumed code is denied as expired.
|
||||
w = httptest.NewRecorder()
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a consumed code must not pass the gate")
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
@@ -427,3 +439,83 @@ func TestTwoFactorEnforced_CreateTillSale_SavedCard_With2FA_Succeeds(t *testing.
|
||||
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted
|
||||
// pins the Loop B MEDIUM gate-ordering fix: the save-card 2FA gate runs AFTER
|
||||
// the idempotency dedup's completed short-circuit. A same-key lost-response
|
||||
// retry re-sends the SAME single-use verification code that the original
|
||||
// attempt already consumed; if the gate ran first it would 400 "Verification
|
||||
// code expired". With the gate below the dedup, the retry returns the
|
||||
// already-completed payment instead of re-entering the gate.
|
||||
func TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Retry_ReturnsCompleted(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "778899")
|
||||
|
||||
cardToken := "cnon:2fa-save-card-retry"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 2500,
|
||||
PaymentType: "deposit",
|
||||
NewCardToken: &cardToken,
|
||||
SaveCard: true,
|
||||
IdempotencyKey: "2fa-save-card-retry",
|
||||
VerificationCode: "778899",
|
||||
}
|
||||
|
||||
handler := withNonGuest(CreateBookingPayment)
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
// Same-key retry re-sends the identical request, whose code is now
|
||||
// consumed. The completed-dedup must return the payment before the gate.
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed payment, not re-run the gate: %s", w2.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, "the retry must not create a second payment")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted pins
|
||||
// the Loop B MEDIUM gate-ordering fix on the tip endpoint: the saved-card
|
||||
// charge 2FA gate runs AFTER the tip idempotency dedup's completed
|
||||
// short-circuit. A same-key lost-response retry re-sends the SAME single-use
|
||||
// verification code the original attempt consumed; with the gate first it would
|
||||
// 400 "expired", with the gate below the dedup the retry returns the completed
|
||||
// tip instead.
|
||||
func TestTwoFactorEnforced_CreateTipPayment_SavedCard_Retry_ReturnsCompleted(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "667788")
|
||||
|
||||
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
|
||||
require.NoError(t, err)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_tip_retry", "VISA", "4321")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-tip-saved-card-retry",
|
||||
VerificationCode: "667788",
|
||||
}
|
||||
|
||||
handler := withNonGuest(CreateTipPayment)
|
||||
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
// Same-key retry re-sends the identical request, whose code is now
|
||||
// consumed. The completed-dedup must return the tip before the gate.
|
||||
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w2.Code, "same-key retry must dedup to the completed tip, not re-run the gate: %s", w2.Body.String())
|
||||
|
||||
var tipCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&tipCount))
|
||||
require.Equal(t, 1, tipCount, "the retry must not create a second tip")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user