fix: review-loop A — discount credit on admin payments, campaign over-credit cap, sweep replay window, dedup refund revalidation, duplication/modularisation, GBP pence naming

Round-A fresh review (6 agents) + fix + secondary cross-cutting + verification rounds:
- F1: campaign discounts reduce the charged amount (deposit credit + admin PaymentModal discounted total); capDiscountToRemainingObligation prevents over-credit at completion in all four campaign blocks
- F2: sweep replay rescue distinguishes legitimate same-key retries (21h window) from expired-key new charges; ccof blind-fails leave pending + CRITICAL instead of clawing back
- F3: post-start online overflow carved as a tip record (mirrors terminal split builder)
- A1: single-source Square decline-code classification (till delegates to square.IsDefinitivePaymentError)
- A2/A5: refund attempt-cap literals consolidated; refund-failure counter capped + reset on terminal resolutions + admin notifications
- A3/A9: idempotency helpers adopted across derivations; IsExplicitDevOrMockEnv relocated + all gates unified (incl. health-check)
- A7: 2FA user+IP limiter + TRUST_PROXY_HEADERS startup warning; SNAPSHOT_ENC_KEY startup validation; TWO_FACTOR_PEPPER docs corrected
- A8: snapshot encryption on all 6 write sites + marker-aware reuse paths; MPV->SPV effective voucher type (single VAT point)
- A10/A11/A12/A16: gift-card slot scan advances past failed; amount-aware refund reconciliation; completed-booking refund re-check; PaymentWasRefunded on SquareClient interface
- Dedup refund revalidation on tip/terminal/gift-card paths; sweep acknowledged_at IS NULL parity; refund-notification single source (exported payments.InsertRefundFailedNotifications)
- Duplication/modularisation round: shared frontend helpers (sanitizeDecimalInput, campaignDiscountCents, twoFactorBlocksSavedCards getter, generateUUID), single-source MaxIdempotencyKeyLength, notification-helper consolidation, snapshot-guard comments
- Cross-cutting GBP rename: Cents->Pence across backend + frontend + tests (26 identifiers, 16 files)
- Tests: 11 behavior-change tests updated to new invariants; coverage for fixed functions; frontend vitest 55 tests; docs corrected (test counts, 2FA delivery, pre-launch checklist, resolution status)
- gitleaks: allowlist backend/internal/square test fixtures (mock idempotency keys)

All 25 backend packages pass; frontend 55/55 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6d82535780
commit faceb9809c
49 changed files with 2006 additions and 1074 deletions
+158 -64
View File
@@ -5,6 +5,7 @@ import (
"crussell/db"
"errors"
"log"
"math"
"sort"
"time"
@@ -126,24 +127,35 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
// F1: never over-credit at completion. The admin "Take Payment"
// flow can charge the FULL amount while a campaign is still
// eligible — the discount must be capped (or skipped when real
// money already covers the total) so paid + discounts never exceed
// the booking total.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil {
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil {
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
}
} else {
log.Printf("Skipping time_based campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", campaignID, bookingID)
}
}
}
@@ -167,22 +179,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
if milestoneCampaignID != "" {
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
} else {
log.Printf("Skipping per-user milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", milestoneCampaignID, bookingID)
}
}
@@ -217,22 +235,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
} else {
log.Printf("Skipping global milestone campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", globalCampaignID, bookingID)
}
}
}
@@ -282,22 +306,28 @@ func ApplyBookingCompletionSideEffects(ctx context.Context, tx pgx.Tx, bookingID
}
if matches {
discountAmount := roundTo2(bookingTotal * c.pct / 100)
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
// F1 over-credit guard — see the time-based block above.
if capped, ok := capDiscountToRemainingObligation(ctx, tx, bookingID, discountAmount); ok {
discountAmount = capped
if _, err := tx.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, userID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
} else {
log.Printf("Skipping anniversary campaign %s at completion for booking %s — obligation already covered by real money (would over-credit)", c.id, bookingID)
}
break // apply longest matching only
}
@@ -369,6 +399,70 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo
return fullyPaid
}
// discountHeadroomPence returns how much of the booking's total obligation is
// still uncovered — the largest a NEW discount row may carry before the ledger
// over-credits the customer (F1). Over-credit records real money + discounts
// beyond the booking total, minting an orphaned credit the refund system can
// never return: the admin "Take Payment" flow (frontend PaymentModal) sends
// payment_type='full' with the FULL amount (subtotal minus discounts already
// applied client-side), while applyEligibleCampaignsAtPayment auto-applies any
// eligible campaign — without this guard the ledger would record £55 against a
// £50 total. The correct fix is the frontend sending the discounted amount
// (as the customer modal already does); this headroom computation is the
// server-side money-safety half that caps/skips the discount instead.
//
// Headroom is:
//
// total - (completed real payments + completed discount rows + pending charge)
//
// where "real" excludes tip / discount / on_the_house rows (the same
// classification bookingIsFullyPaid uses). The pending charge is the payment
// completing in the caller's transaction, whose amount is not yet a completed
// row when applyEligibleCampaignsAtPayment runs — it is read from the pending
// row's stored amount (the amount the charge is being recorded at, i.e.
// req.Amount, which is what the charge will settle for). A failed read returns
// 0 (conservative: skip rather than over-credit).
func discountHeadroomPence(ctx context.Context, q db.Querier, bookingID string) int64 {
var totalPence, realPaidPence, discountPence, pendingPence int64
err := q.QueryRow(ctx, `
SELECT
COALESCE(ROUND((SELECT total_amount FROM bookings WHERE id = $1) * 100), 0),
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
AND payment_type != 'tip' AND payment_method NOT IN ('discount', 'on_the_house')) * 100), 0),
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'completed'
AND payment_method = 'discount') * 100), 0),
COALESCE(ROUND((SELECT SUM(amount) FROM payments WHERE booking_id = $1 AND status = 'pending') * 100), 0)
`, bookingID).Scan(&totalPence, &realPaidPence, &discountPence, &pendingPence)
if err != nil {
log.Printf("Failed to compute discount headroom for booking %s: %v", bookingID, err)
return 0
}
headroom := totalPence - realPaidPence - discountPence - pendingPence
if headroom < 0 {
return 0
}
return headroom
}
// capDiscountToRemainingObligation caps a discount amount (pounds) so the
// booking's ledger never over-credits: real money paid + discounts recorded +
// the charge in flight must never exceed the booking total. Returns the capped
// amount and whether the discount may still be applied; a false second return
// means real money already covers the obligation and the discount must be
// skipped entirely (applying it would mint a phantom credit). The capped value
// is the headroom in pence, so it can never round up past the obligation.
func capDiscountToRemainingObligation(ctx context.Context, q db.Querier, bookingID string, discountAmount float64) (float64, bool) {
discountPence := int64(math.Round(discountAmount * 100))
headroom := discountHeadroomPence(ctx, q, bookingID)
if discountPence <= headroom {
return discountAmount, true
}
if headroom <= 0 {
return 0, false
}
return float64(headroom) / 100.0, true
}
// completeActiveBookingFromPayment transitions an active booking to
// 'completed' and runs the completion side-effects, all within tx. It is a
// no-op if the booking is not in an active (completable) status, so cancelled,
@@ -363,13 +363,13 @@ func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
// The fixture service costs £50, so the booking's remaining balance is 5000
// pence. Two £30 partials sum to £60 > £50 — only one may succeed.
var remainingCents int64
var remainingPence int64
if err := db.Conn.QueryRow(context.Background(),
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingCents); err != nil {
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingPence); err != nil {
t.Fatalf("failed to read booking total: %v", err)
}
if remainingCents != 5000 {
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingCents)
if remainingPence != 5000 {
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingPence)
}
origClient := SquareClient
@@ -443,8 +443,8 @@ func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
bookingID).Scan(&paidPence); err != nil {
t.Fatalf("failed to sum paid amount: %v", err)
}
if paidPence > remainingCents {
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingCents)
if paidPence > remainingPence {
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingPence)
}
}
@@ -513,10 +513,12 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
t.Fatalf("failed to create campaign: %v", err)
}
// First payment — discount should be applied
// First payment — discount should be applied. A £10 deposit on the £50
// booking leaves enough headroom for the 10% (£5) discount to pass the F1
// over-credit cap (a full/overpaid fixture would now correctly skip it).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 1000, 'completed', NOW(), NOW())
VALUES ($1, 'deposit', 'online_square', 10.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create first payment: %v", err)
@@ -531,10 +533,12 @@ func TestDiscountPreview_PaymentLock(t *testing.T) {
t.Errorf("expected 1 discount after first payment, got %d", discountCount)
}
// Second payment — NO new discounts should be added (lock active)
// Second payment — NO new discounts should be added (lock active). The £40
// balance completes the £50 booking; the 2-completed-payment lock in
// ComputeEligibleDiscounts is what refuses the second application.
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 4000, 'completed', NOW(), NOW())
VALUES ($1, 'full', 'online_square', 40.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create second payment: %v", err)
+26 -2
View File
@@ -652,7 +652,31 @@ func TestBookingPayment_Overflow_PostStart_Succeeds(t *testing.T) {
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 completed payment after the post-start overflow, got %d", payCount)
if payCount != 2 {
t.Errorf("expected 2 completed payments after the post-start overflow (booking portion + F3 tip carve), got %d", payCount)
}
// F3: the £10 overflow beyond the £50 booking is carved as its own
// payment_type='tip' record (gratuity), mirroring buildTerminalSplitRecords.
var tipCount int
var tipAmount float64
if err := tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'tip'`, bookingID).Scan(&tipCount, &tipAmount); err != nil {
t.Fatalf("failed to query tip records: %v", err)
}
if tipCount != 1 {
t.Errorf("expected exactly 1 tip record for the post-start overflow, got %d", tipCount)
}
if tipAmount < 9.995 || tipAmount > 10.005 {
t.Errorf("expected the tip to equal the £10 overflow, got %.2f", tipAmount)
}
// The booking portion is the remaining £50 (payment_type='full', the
// original request type — no deposit/balance split post-start).
var bookingPortion float64
if err := tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'full'`, bookingID).Scan(&bookingPortion); err != nil {
t.Fatalf("failed to query booking portion: %v", err)
}
if bookingPortion < 49.995 || bookingPortion > 50.005 {
t.Errorf("expected the booking portion to be £50.00, got %.2f", bookingPortion)
}
}
+37
View File
@@ -1278,6 +1278,20 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
}
if existing != nil {
if existing.Status == "completed" {
// RE-VALIDATE the matched purchase payment's refund state before
// reporting it as success (same guard as the CreateBookingPayment
// completed-dedup branches): a refunded purchase's money is no
// longer live, and a same-key retry must not claim the purchase
// succeeded when the money was already returned.
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
log.Printf("Failed to re-validate gift-card dedup hit %s against refunds: %v", existing.ID, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Gift card retry rejected: purchase payment %s (key %q) was refunded — refusing to report a refunded purchase as success", existing.ID, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(existing); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
@@ -1292,6 +1306,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Amount does not match the pending gift card payment", http.StatusBadRequest)
return
}
// Defense-in-depth refund guard on the pending-reuse path too: a
// pending purchase cannot normally carry a refund (refunds attach
// to completed charges), but if one ever exists the money is in
// flight/returned and re-attempting the charge must not proceed.
if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil {
log.Printf("Failed to re-validate pending gift-card reuse %s against refunds: %v", existing.ID, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Gift card retry rejected: pending purchase payment %s (key %q) was refunded — refusing to reuse a refunded payment", existing.ID, req.IdempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
reusePendingID = existing.ID
log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey)
}
@@ -1522,6 +1549,16 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
// 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.
//
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
// snapshot-is-null guard like the booking/tip/terminal flows): the reuse
// branch above (B6) already refreshed square_request_snapshot in the SAME
// transaction as the square_source_id refresh, and this post-commit write
// stores the fresh full body for THIS attempt. Both paths converge on a
// correct snapshot, so a guard would either be dead (first attempt) or
// wrongly skip this write on the reuse path when the in-tx refresh failed
// best-effort. Do NOT "fix" this into the guarded form without reworking
// the reuse-branch refresh.
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 stored, eErr := encryptSnapshot(snap); eErr != nil {
+145 -50
View File
@@ -612,7 +612,13 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// deterministic key).
scKey := req.IdempotencyKey
if scKey == "" {
scKey = bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
// The candidate is built verbatim, then routed through
// truncateIdempotencyKey so it can never exceed Square's 45-char
// /v2/payments limit (a 400 would strand the payment). The truncation
// is deterministic, so identical inputs still derive the SAME key and
// the dedup SELECT below keeps working; candidates at or under 45
// chars (the current bookingID+cardID shape) pass through byte-identical.
scKey = truncateIdempotencyKey("sc", bookingID+"-sc-"+req.PaymentType+"-"+strconv.FormatInt(amount, 10)+"-"+*req.UserSavedCardID)
}
// Idempotency switch inside the lock: completed → dedup; pending →
@@ -627,7 +633,20 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
paymentID := ""
switch {
case err == nil && existingStatus.String == "completed":
// Dedup — return the existing completed payment.
// Dedup — return the existing completed payment. First RE-VALIDATE
// the matched row's refund state (same guard as the CreateBookingPayment
// completed-dedup branches): a refunded payment's money is no longer
// live, so reporting it as "success" would let a same-key retry claim
// a payment that was already returned to the customer.
if refunded, rErr := paymentHasLiveRefund(r.Context(), db.Conn, existingID.String); rErr != nil {
log.Printf("Failed to re-validate saved-card dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Payment retry rejected: saved-card payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, scKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingID.String,
Status: "COMPLETED",
@@ -812,6 +831,16 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// 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
// above, so the completion side-effects (loyalty, campaign discounts,
// deposits_required) are atomic and a booking paid in full by a
// saved-card charge leaves the admin's Current Appointment view. The
// eligible-discount application happens inside the completion
// side-effects, guarded by the same over-credit cap as every other path.
completeFullyPaidBooking(r.Context(), bookingID)
// Return the card details the frontend reads for the success state
// (MINOR-R2) — CheckoutResponse alone leaves card_brand/card_last4 blank.
if err := json.NewEncoder(w).Encode(map[string]any{
@@ -1349,13 +1378,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// for how the fallback distinguishes "same live operation retried" (dedup)
// from "new operation that happens to have equal amount" (new charge).
if req.PaymentType == "partial" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
@@ -1512,13 +1541,13 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// already committed — reject before any pending record is inserted or
// Square is hit.
if req.PaymentType == "partial" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
if err := ValidatePartialAmount(req.Amount, remainingPence); err != nil {
log.Printf("Payment rejected: %v", err)
http.Error(w, "Partial amount exceeds remaining balance", http.StatusConflict)
return
@@ -1638,9 +1667,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
if err := tx.QueryRow(r.Context(), `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to load booking total for discount computation: %v", err)
}
var eligibleDiscountCents int64
var eligibleDiscountPence int64
for _, d := range ComputeEligibleDiscounts(r.Context(), tx, bookingID, userID, bookingTotal) {
eligibleDiscountCents += int64(math.Round(d.Amount * 100))
eligibleDiscountPence += int64(math.Round(d.Amount * 100))
}
// M4/M7: cap pay-early at 100%. A payment that exceeds the booking's
@@ -1659,14 +1688,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// within the discounted remaining is covered by the discount — it is NOT an
// overflow into tip territory.
if req.PaymentType != "tip" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
remainingPence, err := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
discountedRemainingCents := remainingCents + eligibleDiscountCents
if req.Amount > discountedRemainingCents {
discountedRemainingPence := remainingPence + eligibleDiscountPence
if req.Amount > discountedRemainingPence {
var bookingStartTime time.Time
if sErr := db.Conn.QueryRow(r.Context(), `SELECT start_time FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStartTime); sErr != nil {
log.Printf("Failed to get booking start time: %v", sErr)
@@ -1674,14 +1703,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
if !req.ConfirmOverflowTip && !bookingStartTime.Before(clock.Now()) {
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingCents, bookingID)
log.Printf("Overflow requires confirmation: amount %d exceeds discounted remaining %d for booking %s (not started, not confirmed)", req.Amount, discountedRemainingPence, bookingID)
mw.RespondJSON(w, http.StatusBadRequest, map[string]string{
"error": "The extra amount will be recorded as a tip. Confirm to continue.",
"code": "overflow_tip_confirmation_required",
})
return
}
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingCents, bookingID, req.ConfirmOverflowTip)
log.Printf("Overflow accepted as tip: amount %d exceeds discounted remaining %d for booking %s (confirmed=%v)", req.Amount, discountedRemainingPence, bookingID, req.ConfirmOverflowTip)
}
}
@@ -1696,9 +1725,28 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// charge here: the deposit is charged at req.Amount minus the discount and
// the residual balance payment settles the rest, so the total across the
// deposit→balance flow is the discounted price.
//
// ADMIN-FLOW ASYMMETRY (F1): the admin "Take Payment" PaymentModal sends
// payment_type='full' with the FULL amount (subtotal minus discounts
// ALREADY applied, no client-side campaign preview) — it does NOT
// pre-subtract an eligible campaign. That means a full admin charge is NOT
// reduced below, and applyEligibleCampaignsAtPayment would auto-apply the
// campaign → ledger £55 vs £50 total, orphaned £5 credit. The frontend
// PaymentModal MUST therefore send the discounted amount exactly like the
// customer modal (amount_due minus the eligible-campaign preview) so the
// full ledger reconciles to the booking total; the server-side over-credit
// guard (capDiscountToRemainingObligation in completion.go) protects
// against a client that does not.
chargeAmount := req.Amount
if req.PaymentType == "deposit" && eligibleDiscountCents > 0 {
chargeAmount = req.Amount - eligibleDiscountCents
if req.PaymentType == "deposit" && eligibleDiscountPence > 0 {
chargeAmount = req.Amount - eligibleDiscountPence
// A6: when the eligible discount is >= the deposit itself, chargeAmount
// clamps UP to the full (undiscounted) deposit. The customer still pays
// the full deposit up front — the discount credit applies to the
// residual balance via the discount row created by
// applyEligibleCampaignsAtPayment (which runs regardless of
// chargeAmount), so no discount is ever lost and the ledger can never
// charge a negative amount.
if chargeAmount <= 0 {
chargeAmount = req.Amount
}
@@ -1987,7 +2035,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// Tip rows are excluded (they are gratuity, not payment toward the booking)
// as are discount/on_the_house rows (no real money moved).
var depositMet bool
if err := tx2.QueryRow(r.Context(), `
if err := tx2.QueryRow(r.Context(), fmt.Sprintf(`
WITH booking_total AS (
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
),
@@ -1998,9 +2046,9 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
AND payment_type != 'tip'
AND payment_method NOT IN ('discount', 'on_the_house')
)
SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2)
SELECT pt.paid_cents >= ROUND(bt.total_cents * %f)
FROM booking_total bt, paid_total pt
`, bookingID).Scan(&depositMet); err != nil {
`, depositPromotionMinPct), bookingID).Scan(&depositMet); err != nil {
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
}
@@ -2061,6 +2109,22 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
}
for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) {
// F1: never over-credit. This runs inside the post-charge transaction
// BEFORE the charge's split records are written, so the paid ledger
// visible here is real money + discounts already on the booking plus
// the in-flight charge (the pending row read inside
// discountHeadroomPence). Capping each discount to the uncovered
// obligation keeps the admin "Take Payment" full-amount flow from
// creating an orphaned credit when a campaign is eligible: the correct
// fix is the frontend PaymentModal sending the discounted amount (like
// the customer modal does); this guard is the server-side money-safety
// half.
capped, ok := capDiscountToRemainingObligation(ctx, q, bookingID, d.Amount)
if !ok {
log.Printf("Skipping %s discount %s for booking %s — booking obligation already covered by real money (would over-credit)", d.Source, d.SourceID, bookingID)
continue
}
d.Amount = capped
ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d)
}
}
@@ -2095,9 +2159,32 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
// simplification, not an overcharge) — the partition still equals the charged
// amount. See TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge.
func buildSplitRecords(primary PaymentRecord, reqPaymentType string, info *BookingPaymentInfo, paymentAmount float64) []PaymentRecord {
// After the booking starts there is no deposit protection window
// record the payment as a single entry with its original type.
// After the booking starts there is no deposit protection window, but an
// overpayment beyond the remaining booking value is still gratuity and must
// be carved out as its own payment_type='tip' record (F3) — mirroring
// buildTerminalSplitRecords' post-start carve. A post-start charge AT OR
// BELOW the remaining value records as a single entry with its original
// type, exactly as before; only the overflow becomes a tip. The tip record
// zeroes fees and derives a -split-tip idempotency key, and the records
// still partition paymentAmount exactly (booking portion + tip).
if clock.Now().After(info.StartTime) {
remaining := math.Max(0, info.TotalAmount-info.TotalPaid)
bookingPortion := math.Min(paymentAmount, remaining)
bookingPortion = math.Round(bookingPortion*100) / 100
tipPortion := math.Round((paymentAmount-bookingPortion)*100) / 100
if tipPortion > 0.004 {
records := []PaymentRecord{primary}
records[0].Amount = bookingPortion
tip := primary
tip.PaymentType = "tip"
tip.Amount = tipPortion
tip.Fees = 0
if primary.IdempotencyKey != nil {
k := *primary.IdempotencyKey + "-split-tip"
tip.IdempotencyKey = &k
}
return append(records, tip)
}
return []PaymentRecord{primary}
}
@@ -2470,7 +2557,7 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
// This handler decodes into RefundRequest without running the struct
// validator, so enforce the limit explicitly — a longer key would 400 at
// Square and be misclassified as a definitive refund decline.
if len(req.IdempotencyKey) > 45 {
if len(req.IdempotencyKey) > maxIdempotencyKeyLength {
http.Error(w, "Invalid request: idempotency_key exceeds 45 characters", http.StatusBadRequest)
return
}
@@ -3118,14 +3205,14 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
// Cap: the refund amount must not exceed the refundable total (completed
// non-tip payments minus already refunded). Tips are not refundable.
refundableCents, err := service.GetBookingRefundableAmountCents(r.Context(), bookingID)
refundablePence, err := service.GetBookingRefundableAmountPence(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get refundable amount for booking %s: %v", bookingID, err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if req.Amount > refundableCents {
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundableCents, bookingID)
if req.Amount > refundablePence {
log.Printf("Admin refund rejected: amount %d exceeds refundable %d for booking %s", req.Amount, refundablePence, bookingID)
http.Error(w, "Refund amount exceeds the refundable amount for this booking", http.StatusBadRequest)
return
}
@@ -3208,7 +3295,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
// cardRefunds tracks card refunds that need a post-commit Square call.
type cardRefund struct {
refundID string
amountCents int64
amountPence int64
squareID string
reason string
key string
@@ -3344,7 +3431,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
if refundKey != nil && p.SquarePaymentID != nil {
cardRefunds = append(cardRefunds, cardRefund{
refundID: refundID,
amountCents: int64(math.Round(portion * 100)),
amountPence: int64(math.Round(portion * 100)),
squareID: *p.SquarePaymentID,
reason: req.Reason,
key: *refundKey,
@@ -3366,7 +3453,7 @@ func AdminRefundBooking(w http.ResponseWriter, r *http.Request) {
status := "completed"
result, rErr := SquareClient.RefundPayment(r.Context(), square.RefundPaymentReq{
PaymentID: cf.squareID,
Amount: cf.amountCents,
Amount: cf.amountPence,
IdempotencyKey: cf.key,
Reason: cf.reason,
})
@@ -3607,13 +3694,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
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])
}
idempotencyKey = truncateIdempotencyKey("tip", fmt.Sprintf("tip-%s-%d-%s-%d", bookingID, req.Amount, cardPart, completedTips+1))
}
// Check idempotency inside the transaction.
@@ -3639,7 +3720,20 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
reusePendingRecord := false
switch {
case err == nil && existingStatus.String == "completed":
// Idempotent dedup — return the already-completed payment.
// Idempotent dedup — return the already-completed payment. First
// RE-VALIDATE the matched row's refund state (same guard as the
// CreateBookingPayment completed-dedup branches): a refunded payment's
// money is no longer live, so reporting it as success would let a
// same-key retry claim money that was already returned.
if refunded, rErr := paymentHasLiveRefund(r.Context(), tx, existingID.String); rErr != nil {
log.Printf("Failed to re-validate tip dedup hit %s against refunds: %v", existingID.String, rErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
} else if refunded {
log.Printf("Tip retry rejected: tip payment %s (key %q) was refunded — refusing to report a refunded payment as success", existingID.String, idempotencyKey)
http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict)
return
}
if err := json.NewEncoder(w).Encode(PaymentResponse{
ID: existingID.String,
BookingID: existingBookingID.String,
@@ -4115,13 +4209,16 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
}
// uniqueChargeKey generates a unique idempotency key under the given prefix
// (e.g. "tip-", "till-") where the client did not supply one. Client-supplied
// keys handle retry dedup; this fallback only needs uniqueness so two
// legitimate identical requests never collapse on the same key. Deliberately
// NOT derived from request fields — two identical requests would hash to the
// same key (the "tip" fallback must not dedupe two distinct equal tips on one
// booking). Shared by the tip/till flows, which used to carry two identical
// copies (uniqueTipKey/uniqueTillKey) differing only in the prefix string.
// (e.g. "till-") where the client did not supply one. Client-supplied keys
// handle retry dedup; this fallback only needs uniqueness so two legitimate
// identical requests never collapse on the same key. Deliberately NOT derived
// from request fields — two identical requests would hash to the same key.
// Current callers (A7): the terminal-payment idempotency key in
// CreateTerminalPayment (handlers.go:343, fresh admin actions never
// network-retried) and the till cash/on_the_house no-client-key fallback
// (till.go:406, two identical keyless cash gift-card sales are distinct
// operations). The tip flow no longer uses it — its no-client-key fallback is
// derived deterministically from the completed-tip count (see CreateTipPayment).
func uniqueChargeKey(prefix string) string {
return prefix + rand.Text()
}
@@ -4162,14 +4259,12 @@ func uniqueChargeKey(prefix string) string {
func deriveBookingPaymentIdempotencyKey(ctx context.Context, q db.Querier, bookingID, paymentType string, amount int64, cardPart string) (string, error) {
baseKey := fmt.Sprintf("pay-%s-%s-%d-%s", bookingID, paymentType, amount, cardPart)
for seq := 0; ; seq++ {
candidate := baseKey
if seq > 0 {
candidate = fmt.Sprintf("%s-%d", baseKey, seq)
}
if len(candidate) > 45 {
hash := sha256.Sum256([]byte(candidate))
candidate = fmt.Sprintf("pay-%x", hash[:16])
}
// nextIdempotencyCandidate (idempotency_helpers.go) reproduces the
// historical candidate exactly: the base key at seq 0, "base-seq" at
// seq ≥ 1, sha256-truncated to the 45-char limit under the "pay-"
// prefix when the verbatim form overflows — the key stays deterministic
// so a same-key retry still dedups (A3).
candidate := nextIdempotencyCandidate(baseKey, seq)
var completedID string
err := q.QueryRow(ctx, `
SELECT id FROM payments
@@ -4,7 +4,10 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strings"
"crussell/internal/square"
)
// maxIdempotencyKeyLength caps idempotency keys at Square's /v2/payments limit
@@ -12,7 +15,9 @@ import (
// 45-char cap applies even where a destination (e.g. CreateCheckout) allows 64.
// Client-supplied keys are validated against it ("omitempty,max=45") and
// server-derived keys are truncated to it via truncateIdempotencyKey.
const maxIdempotencyKeyLength = 45
// Aliased from the square package — the client to Square, whose limit this is —
// so there is a single source of the constant, not a per-package drift surface.
const maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength
// truncateIdempotencyKey applies the deterministic >45-char sha256 truncation
// shared by the derive* idempotency-key helpers: a candidate longer than
@@ -49,3 +54,22 @@ func nextIdempotencyCandidate(base string, seq int) string {
}
return truncateIdempotencyKey(prefix, candidate)
}
// 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 or decrypt/encrypt
// snapshot expectations (A9). It lives here — the neutral idempotency helper
// file — because it gates far more than 2FA: snapshot encryption
// (charge_helpers.go), the sweep's replay checks and snapshot decryption
// (sweep.go), the till snapshot refresh (till.go), the gift-card reuse
// snapshot handling (giftcards.go), and main.go's startup warnings. The
// exported name is stable for main.go; in-package callers use it directly.
func IsExplicitDevOrMockEnv() bool {
switch os.Getenv("SQUARE_ENVIRONMENT") {
case "mock", "dev", "development", "test":
return true
default:
return false
}
}
+11 -8
View File
@@ -249,10 +249,12 @@ func TestCampaignAutoApply_TimeBased(t *testing.T) {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a deposit payment to trigger campaign auto-apply
// Insert a deposit payment to trigger campaign auto-apply (£25 on the £50
// booking — leaves headroom so the F1 over-credit cap still lets the 10%
// discount through).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
@@ -302,10 +304,10 @@ func TestCampaignAutoApply_UserMilestone(t *testing.T) {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert a payment
// Insert a payment (£25 deposit leaves F1 cap headroom for the 15% discount)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
@@ -386,10 +388,11 @@ func TestCampaignAutoApply_GlobalMilestoneAppliedInPerson(t *testing.T) {
t.Fatalf("failed to create campaign: %v", err)
}
// Insert an IN-PERSON payment
// Insert an IN-PERSON payment (£40 on the £50 booking — leaves the £10
// headroom the 20% global-milestone discount needs to pass the F1 cap).
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
VALUES ($1, 'full', 'in_person_card', 40.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
@@ -485,10 +488,10 @@ func TestCampaignAutoApply_ReferralDiscount(t *testing.T) {
t.Fatalf("failed to insert referral discount: %v", err)
}
// Insert payment to trigger auto-apply
// Insert payment to trigger auto-apply (£25 deposit leaves F1 cap headroom)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
VALUES ($1, 'deposit', 'online_square', 2500, 'completed', NOW(), NOW())
VALUES ($1, 'deposit', 'online_square', 25.00, 'completed', NOW(), NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
@@ -318,7 +318,7 @@ func TestGetCheckoutStatus_TerminalTipSplit(t *testing.T) {
// Refundable total must be £50 (the booking portion), not £55.
svc := NewPaymentService()
refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID)
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
require.NoError(t, err)
assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total")
}
@@ -479,7 +479,7 @@ func TestGetBookingRefundableAmountCents_ExcludesTips(t *testing.T) {
require.NoError(t, err)
svc := NewPaymentService()
refundable, err := svc.GetBookingRefundableAmountCents(ctx, bookingID)
refundable, err := svc.GetBookingRefundableAmountPence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, int64(5000), refundable, "tips must not count toward the refundable amount")
}
@@ -387,7 +387,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
require.NoError(t, err)
svc := NewPaymentService()
initial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
initial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Positive(t, initial)
@@ -400,7 +400,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
Amount: 20.00,
}, nil)
require.NoError(t, err)
afterPartial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
afterPartial, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, initial-2000, afterPartial)
@@ -414,7 +414,7 @@ func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
Amount: 5.00,
}, nil)
require.NoError(t, err)
afterTip, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
afterTip, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
}
+15 -15
View File
@@ -3203,8 +3203,8 @@ func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
func TestValidatePartialAmount(t *testing.T) {
tests := []struct {
name string
amountCents int64
remainingCents int64
amountPence int64
remainingPence int64
expectErr bool
}{
{"valid partial", 500, 1000, false},
@@ -3216,7 +3216,7 @@ func TestValidatePartialAmount(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountCents, tt.remainingCents)
err := ValidatePartialAmount(tt.amountPence, tt.remainingPence)
if tt.expectErr && err == nil {
t.Error("expected error, got nil")
}
@@ -3227,7 +3227,7 @@ func TestValidatePartialAmount(t *testing.T) {
}
}
func TestGetBookingRemainingBalanceCents(t *testing.T) {
func TestGetBookingRemainingBalancePence(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
@@ -3248,7 +3248,7 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
service := NewPaymentService()
initialRemaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
initialRemaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -3267,12 +3267,12 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
t.Fatalf("failed to create payment: %v", err)
}
afterPartial, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
afterPartial, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterPartial != initialRemaining-2000 {
t.Errorf("expected %d cents remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
t.Errorf("expected %d pence remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
}
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
@@ -3286,12 +3286,12 @@ func TestGetBookingRemainingBalanceCents(t *testing.T) {
t.Fatalf("failed to create payment: %v", err)
}
afterFull, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
afterFull, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterFull != 0 {
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
t.Errorf("expected 0 pence remaining after full payment, got %d", afterFull)
}
}
@@ -3327,7 +3327,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
}, nil)
require.NoError(t, err)
remaining, err := service.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
@@ -3342,7 +3342,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
`, payRowID, bookingID, float64(refundAmount)/100.0)
require.NoError(t, err)
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount")
@@ -3353,7 +3353,7 @@ func TestGetBookingRemainingBalanceCents_RefundsReopenCapacity(t *testing.T) {
`, payRowID, bookingID, bookingTotal)
require.NoError(t, err)
remaining, err = service.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total")
}
@@ -3909,14 +3909,14 @@ func TestGetBookingPaymentInfo_Found(t *testing.T) {
}
// =============================================================================
// Service layer: GetBookingRemainingBalanceCents
// Service layer: GetBookingRemainingBalancePence
// =============================================================================
func TestGetBookingRemainingBalanceCents_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetBookingRemainingBalanceCents(ctx, "000000000001")
_, err := svc.GetBookingRemainingBalancePence(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent booking ID")
}
@@ -3926,7 +3926,7 @@ func TestGetBookingRemainingBalanceCents_FullBalance(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
cents, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
cents, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@@ -16,6 +16,15 @@ const (
ProtectedDepositMaxPct = 0.50
RequiredDepositPct = 0.20
// depositPromotionMinPct is the share of the booking total a payment must
// cover before a pending_release booking is promoted back to 'confirmed'
// (A10) — the deposit-promotion threshold in the CreateBookingPayment
// deposit-promotion query. Named separately from RequiredDepositPct (the
// deposit REQUIRED at booking time, used by bookings.go): the promotion
// threshold is about already-paid money, not the amount to demand up front,
// even though both are 20% today.
depositPromotionMinPct = 0.2
LoyaltyStampCost = 10
LoyaltyDiscountPercent = 10.0
)
+70 -25
View File
@@ -66,6 +66,14 @@ const maxManualRefundAttempts = 3
// inserted so the owner learns the reconcile is hard-failing.
const maxConsecutiveReconcileFailures = 5
// maxTrackedReconcileFailures caps the in-memory consecutive-failure counter
// so the map never grows past a sane value AND the ==maxConsecutiveReconcileFailures
// notification check can never be skipped by an odd increment pattern (A5): a
// row whose counter is already at the cap stops growing, but the notification
// fired when it first crossed maxConsecutiveReconcileFailures and is deduped by
// the NOT EXISTS guard, so capping loses no visibility.
const maxTrackedReconcileFailures = 10
// manualReconcileFailures counts consecutive cap-time reconcile failures per
// refund row (keyed by refunds.id). resolveManualRefundAtCap and the
// charge-group cap path re-arm a row under the attempt cap on a reconcile
@@ -93,7 +101,13 @@ func trackReconcileFailureReArm(ctx context.Context, ids []string) {
manualReconcileFailureMu.Lock()
notify := false
for _, id := range ids {
manualReconcileFailures[id]++
// A5: cap the counter so the map never grows unbounded and the
// ==maxConsecutiveReconcileFailures check below is never skipped by an
// odd increment pattern — a counter that grew past 5 without a reset
// (e.g. to 6) would silently stop firing the notification forever.
if manualReconcileFailures[id] < maxTrackedReconcileFailures {
manualReconcileFailures[id]++
}
if manualReconcileFailures[id] == maxConsecutiveReconcileFailures {
notify = true
}
@@ -164,7 +178,7 @@ func notifyCriticalReconcileFailure(ctx context.Context, ids []string) {
// (id, nil) — exact COMPLETED refund found
// (nil, nil) — genuinely no exact match
// (nil, err) — reconcile failed (network/API error)
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCents int64) (*string, error) {
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountPence int64) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{})
if err != nil {
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
@@ -172,7 +186,7 @@ func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountCe
}
for i := range refunds {
r := &refunds[i]
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountCents {
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountPence {
return &r.ID, nil
}
}
@@ -206,7 +220,8 @@ type paymentRow struct {
// - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest
// - <24 hours or no-show: Keep all pre-payments
//
// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50).
// The "protected deposit" is defined as min(totalPrePaid, subtotal *
// ProtectedDepositMaxPct).
// This means up to 50% of the subtotal is always treated as a deposit for
// refund purposes, regardless of whether deposit_required was set on the booking.
//
@@ -945,7 +960,7 @@ func isRefundAmountInvalid(err error) bool {
// COMPLETED refund on the same charge is a manual per-record refund,
// attributing it would mark our rows completed when the aggregate money never
// moved.
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents int64, oldestCreatedAt time.Time) (*string, error) {
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalPence int64, oldestCreatedAt time.Time) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, oldestCreatedAt)
if err != nil {
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
@@ -953,7 +968,7 @@ func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents in
}
for i := range refunds {
r := &refunds[i]
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalCents {
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalPence {
return &r.ID, nil
}
}
@@ -964,6 +979,24 @@ func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents in
// notification centre — one row per affected booking. The sweep only processes
// 'pending' rows, so this fires once per row transition (no spam); the
// NOT EXISTS guard prevents duplicates on re-runs.
//
// The webhooks package carries the SINGULAR variant of this same insert —
// insertRefundFailedNotification (handlers/webhooks/square.go) — which demotes
// a single webhook-surfaced FAILED refund to the identical 'refund_failed'
// row. The two share the same NOT EXISTS dedup guard on
// (reason='refund_failed', booking_id), so a refund resolved by either path
// can never be double-notified; keep the reason string and dedup predicate in
// lockstep when either changes.
// InsertRefundFailedNotifications is the EXPORTED single source for surfacing
// failed refunds in the admin notification centre, consumed by both the sweep
// path (sweep.go) and the webhook path (handlers/webhooks/square.go). The
// webhook package calls this instead of maintaining its own copy, so the SQL
// and the (reason='refund_failed', booking_id) dedup predicate live in exactly
// one place. It delegates to the package-internal insertRefundFailedNotifications.
func InsertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
insertRefundFailedNotifications(ctx, refundIDs)
}
func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
if len(refundIDs) == 0 {
return
@@ -1117,12 +1150,12 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
oldest = pr.CreatedAt
}
}
var totalCents int64
var totalPence int64
for _, pr := range pending {
totalCents += int64(math.Round(pr.Amount * 100))
totalPence += int64(math.Round(pr.Amount * 100))
}
if clock.Now().Sub(oldest) > stalePendingRefundAge {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave rows
@@ -1137,6 +1170,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
`, *sqRefundID, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(idsOf(pending)...)
log.Printf("Aged card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
return len(pending), nil
default:
@@ -1146,6 +1181,8 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds failed (charge %s): %v", chargeID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(idsOf(pending)...)
insertRefundFailedNotifications(ctx, idsOf(pending))
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
@@ -1179,7 +1216,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// retention may have lapsed.
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: chargeID,
Amount: totalCents,
Amount: totalPence,
IdempotencyKey: chargeAggKey(chargeID),
Reason: reason,
})
@@ -1193,7 +1230,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// does NOT — marking the rows completed would claim the full amount was
// refunded when only part of it was.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalCents)
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalPence)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's own
@@ -1290,7 +1327,7 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
// responses — reconcile BEFORE marking failed (same bug class as the
// age guard). An exact COMPLETED refund resolves to completed.
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
@@ -1377,7 +1414,7 @@ func aggRefundKeySuffix(ids []string) string {
// second charge's refund (lost money).
func chargeAggKey(chargeID string) string {
key := chargeID + "-square-agg"
if len(key) <= 45 {
if len(key) <= maxIdempotencyKeyLength {
return key
}
return aggRefundKeySuffix([]string{chargeID}) + "-square-agg"
@@ -1640,8 +1677,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
processedAged := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
amountPence := int64(math.Round(pr.Amount * 100))
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
@@ -1655,6 +1692,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
processedAged++
default:
if _, upErr := db.Conn.Exec(ctx, `
@@ -1663,6 +1702,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
`, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s failed: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
@@ -1676,7 +1717,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
processed := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
amountPence := int64(math.Round(pr.Amount * 100))
// A payment with NO booking is a gift-card purchase (BuyGiftCard
// inserts without a booking) — the handler rejects these outright, so a
@@ -1688,7 +1729,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// the over-refund guard and the customer is handled via the gift-card
// section.
if pr.BookingID == "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for gift-card-purchase manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr)
@@ -1699,6 +1740,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark gift-card-purchase manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
processed++
default:
if _, upErr := db.Conn.Exec(ctx, `
@@ -1707,6 +1750,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
`, pr.ID); upErr != nil {
log.Printf("Failed to mark gift-card-purchase manual refund %s failed: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Gift-card-purchase manual refund %s (payment %s) blocked — payment has no booking; Square shows no COMPLETED refund — marked failed, customer must be refunded via the gift-card section", pr.ID, pr.PaymentID)
}
@@ -1723,7 +1768,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// that would let the over-refund guard exclude money that may have
// moved). Mirrors the stalePendingRefundAge age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr)
@@ -1754,7 +1799,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// helper persists a generated fallback to the row first, so every retry
// reuses the SAME key — a lost-response retry can never issue a second
// refund (Square dedups same-key retries).
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountCents, pr.IdempotencyKey)
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountPence, pr.IdempotencyKey)
if keyErr != nil {
// The key could not be persisted — Square must not be called with an
// empty/unknown key. Leave the row pending for the next sweep (never
@@ -1765,7 +1810,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
}
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.SquarePaymentID,
Amount: amountCents,
Amount: amountPence,
IdempotencyKey: idemKey,
Reason: pr.Reason,
})
@@ -1778,7 +1823,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// applies whether the client classified the code as a definitive
// decline or as already-processed.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountCents)
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountPence)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's
@@ -1829,7 +1874,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountCents)
resolveManualRefundAtCap(ctx, pr, amountPence)
}
default:
@@ -1842,7 +1887,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountCents)
resolveManualRefundAtCap(ctx, pr, amountPence)
}
}
}
@@ -1855,8 +1900,8 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// resolves the row to completed; otherwise mark failed and notify the admin.
// The row is never re-issued here (reconcile is a read), so the cap cannot
// cause a double refund.
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountPence int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. NEVER mark failed
+6 -6
View File
@@ -402,11 +402,11 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
return int64(math.Round(amount * 100)), nil
}
// GetBookingRefundableAmountCents returns the total refundable amount (in
// GetBookingRefundableAmountPence returns the total refundable amount (in
// pence) for a booking: the sum of completed non-tip payments minus already
// refunded (completed + pending). Tips are excluded — they are gratuity above
// the booking total and are not refundable via the admin refund endpoint.
func (s *PaymentService) GetBookingRefundableAmountCents(ctx context.Context, bookingID string) (int64, error) {
func (s *PaymentService) GetBookingRefundableAmountPence(ctx context.Context, bookingID string) (int64, error) {
var amount float64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(p.amount), 0) - COALESCE((
@@ -502,8 +502,8 @@ func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string)
return userID, nil
}
func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) {
var remainingCents int64
func (s *PaymentService) GetBookingRemainingBalancePence(ctx context.Context, bookingID string) (int64, error) {
var remainingPence int64
err := db.Conn.QueryRow(ctx, `
WITH booking_total AS (
SELECT total_amount AS total_pounds FROM bookings WHERE id = $1
@@ -530,11 +530,11 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
-- (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)
`, bookingID).Scan(&remainingPence)
if err != nil {
return 0, err
}
return remainingCents, nil
return remainingPence, nil
}
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
+107 -47
View File
@@ -67,9 +67,10 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) {
cutoff := clock.Now().Add(-stalePendingPaymentAge)
keyedCutoff := clock.Now().Add(-stalePendingKeyedAge)
// Pass 0: stranded Square-less MANUAL refund rows at the 3-attempt cap.
// The Square-less pre-pass inside sweepManualPendingSquareRefunds
// (refunds.go) reconciles only rows with refund_attempts < 3; legacy rows
// Pass 0: stranded Square-less MANUAL refund rows at the attempt cap
// (maxManualRefundAttempts). The Square-less pre-pass inside
// sweepManualPendingSquareRefunds (refunds.go) reconciles only rows with
// refund_attempts < maxManualRefundAttempts; legacy rows
// that already hit the cap are never reconciled by it and would stay
// 'pending' forever, permanently blocking the over-refund guard. Such rows
// can never be refunded via Square (no square_payment_id), so they are
@@ -191,12 +192,12 @@ type staleRow struct {
// 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)
TotalAmount float64 // till_sales.total_amount — the funding this sale added
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)
TotalAmount float64 // till_sales.total_amount — the funding this sale added
}
// sweepStaleRows resolves the stale pending rows of one table. Rows with a
@@ -643,13 +644,55 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
return true
}
// replayRescueClockSkew is the margin by which a replayed payment's CreatedAt
// may lag the pending row's CreatedAt and still be the ORIGINAL charge under a
// retained idempotency key. Square creates the payment at the same instant the
// app creates the pending row (the same transaction), so a replayed payment
// created AFTER the row by more than this margin cannot be the original — it is
// a NEW charge Square made with an expired key (finding A1).
const replayRescueClockSkew = time.Hour
// replayLegitimateRetryWindow is the maximum lag between the pending row's
// creation and a replayed payment's creation for the payment to be the REAL
// charge under a legitimately replayed key. A same-key retry — the documented
// retry path (handlers.go:1579-1591) — creates its charge somewhere between
// the row's creation and the sweep's 22h keyed cutoff (stalePendingKeyedAge),
// so any COMPLETED payment created within [row.CreatedAt, row.CreatedAt +
// replayLegitimateRetryWindow] can be that retry charge and must be rescued.
// A payment created LATER than 21h after the row (i.e. within ~1-3h of the
// sweep's own replay, which runs at row age 22h+) is the classic expired-key
// replay-induced charge — the sweep just created it by replaying the still
// valid saved-card source under a key Square no longer retains — and rescuing
// it would hide the duplicate charge behind the original row (finding A1).
// 21h is a clear margin: a legitimate retry cannot occur after the sweep has
// already picked the row up at the 22h cutoff.
const replayLegitimateRetryWindow = 21 * time.Hour
// replayMatchesRowAmount reports whether the replayed payment charged the same
// amount the pending row records — the amount the sweep's replay body repeats
// and the amount any same-key retry MUST reuse (the retry path rejects a
// different amount). A replayed payment carrying a DIFFERENT amount cannot be
// the charge this row is waiting on and must never be rescued onto it. A
// payment with no amount (zero — test fixtures; real Square payments always
// carry one) is not refused here: the lag window below is the primary guard.
func replayMatchesRowAmount(r staleRow, pr *square.PaymentResult) bool {
return pr == nil || pr.Amount == 0 || pr.Amount == r.AmountPence
}
// replayWithinLegitimateWindow reports whether a replayed COMPLETED payment is
// the REAL charge this pending row is waiting on — the ORIGINAL charge under a
// retained key (created ~at row creation) or a later SAME-KEY RETRY charge
// (created between the row's creation and the 22h sweep cutoff, F2). The
// amount must match the row (a retry can never change it) and the payment must
// have been created within replayLegitimateRetryWindow of the row. The source
// is matched by construction: the replay body is rebuilt from the row's stored
// square_request_snapshot with the LIVE square_source_id override, so a payment
// returned by the replay necessarily charged the row's source (Square's
// PaymentResult does not echo the source id back, so it cannot be compared
// directly). A payment created very near the sweep time (lag > 21h) is the
// expired-key replay-induced charge and is NOT legitimate.
func replayWithinLegitimateWindow(r staleRow, pr *square.PaymentResult) bool {
if !replayMatchesRowAmount(r, pr) {
return false
}
created, ok := parseReplayedCreatedAt(pr)
if !ok || r.CreatedAt.IsZero() {
return false
}
return !created.Before(r.CreatedAt) && !created.After(r.CreatedAt.Add(replayLegitimateRetryWindow))
}
// isSavedCardSource reports whether a Square source id is a card-on-file
// (saved-card) reference. Only a ccof: source stays valid for recharging long
@@ -682,10 +725,14 @@ func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
// the same instant the pending row was created; a NEW charge made by an
// expired-key replay (Square's ~24h key retention is UNVERIFIED —
// square_http_client.go:626) against the still-valid ccof: source is created
// ~22h later. Refusal is money-safe: a replayed payment that cannot be proven
// to be the original 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.
// ~22h later. A same-key RETRY (handlers.go:1579-1591) is a legitimate
// exception: the retry's charge is created between the row's creation and the
// 22h sweep cutoff, so a replayed payment inside replayLegitimateRetryWindow
// is the REAL charge and must be rescued (F2). Refusal is money-safe: a
// 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 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
@@ -704,7 +751,7 @@ func replayRevealsNewCharge(r staleRow, pr *square.PaymentResult) (newCharge boo
// rescue rather than hide a possible second charge.
return true, created, createdOK
}
return created.After(r.CreatedAt.Add(replayRescueClockSkew)), created, true
return !replayWithinLegitimateWindow(r, pr), created, true
}
// reconcileStalePaymentByKey asks Square for the authoritative status of the
@@ -782,8 +829,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
if !fallbackBody && !IsExplicitDevOrMockEnv() {
dec, err := decryptSnapshot(snapshot)
if err != nil {
log.Printf("CRITICAL: stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err)
return staleReconcileLeavePending, ""
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: failed to decrypt the stored request snapshot for row %s (%v) — leaving pending — MANUAL RECONCILIATION REQUIRED", table, r.ID, err)
}
snapshot = dec
}
@@ -836,9 +882,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
// single-use nonce cannot be recharged), keeping the proven-failed
// path and its clawback.
if isSavedCardSource(r.SquareSourceID) {
log.Printf("CRITICAL: stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID)
notifyStaleRowCritical(ctx, r)
return staleReconcileLeavePending, ""
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: Square rejected the identical-body replay (no payment under the stored key) but the row's source is a still-valid saved card (ccof:) — the replay may have landed a NEW charge under an expired idempotency key — leaving row %s PENDING without failing/clawing back — MANUAL RECONCILIATION REQUIRED: verify at Square whether a charge exists before re-issuing", table, r.ID)
}
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, ""
@@ -850,11 +894,9 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
// retained-key replay while the dev mock (source-aware only)
// rescues it. The stranded row would otherwise be invisible
// until the 24h blind-fail — surface it now (finding 2).
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
} else {
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 leavePendingCritical(ctx, r, "stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED via the minimal snapshot-less fallback body (key=%s) — real Square compares the WHOLE request (reference_id/customer_id/note/buyer_email_address absent from the rebuilt body) and rejects the identical-key replay, stranding the row pending; the dev mock would rescue it — dev-vs-prod observability divergence, NOT proof the charge never happened — MANUAL RECONCILIATION REQUIRED", table, r.IdempotencyKey)
}
return staleReconcileLeavePending, ""
return leavePendingCritical(ctx, r, "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)
}
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, ""
@@ -881,9 +923,7 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
if createdOK {
lag = created.Sub(r.CreatedAt).Round(time.Minute).String()
}
log.Printf("CRITICAL: stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag)
notifyStaleRowCritical(ctx, r)
return staleReconcileLeavePending, ""
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key (likely a second charge against a still-valid saved card), NOT the original charge — leaving the row PENDING without rescue — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag)
}
return staleReconcileCompleted, pr.ID
case "CANCELED", "FAILED":
@@ -915,7 +955,10 @@ func leaveGiftCardPurchasePending(ctx context.Context, r staleRow) {
// 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.
// notification per issue instead of one per sweep run, and requires the prior
// notification to be unacknowledged (acknowledged_at IS NULL) so that after an
// admin acknowledges it, a NEW event for the same booking/user re-notifies —
// matching the webhook copy's guard (handlers/webhooks/square.go) exactly.
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)
@@ -925,6 +968,7 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *s
WHERE an.reason = 'critical_payment_log'
AND an.booking_id IS NOT DISTINCT FROM $1
AND an.user_id IS NOT DISTINCT FROM $2
AND an.acknowledged_at IS NULL
)
`, bookingID, userID)
if err != nil {
@@ -955,6 +999,21 @@ func notifyStaleRowCritical(ctx context.Context, r staleRow) {
insertCriticalPaymentNotification(ctx, nil, userID)
}
// leavePendingCritical is the shared terminal outcome for a stale pending row
// that cannot be safely resolved by the sweep: it logs a CRITICAL line (the
// caller's existing message), raises the deduped critical-payment admin
// notification via notifyStaleRowCritical, and returns
// staleReconcileLeavePending so the row stays pending for a human. Every
// "do not touch, an operator must reconcile" branch — snapshot-decrypt
// failure, the ccof replay rejection, IDEMPOTENCY_KEY_REUSED and the
// replayed-new-charge case (A4) — now runs the identical log + notification
// outcome through this one helper.
func leavePendingCritical(ctx context.Context, r staleRow, format string, args ...any) (staleReconcileResult, string) {
notifyStaleRowCritical(ctx, r)
log.Printf("CRITICAL: "+format, args...)
return staleReconcileLeavePending, ""
}
// 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
@@ -1056,26 +1115,27 @@ func squareHasCode(err error, codes ...string) bool {
}
// sweepSquarelessManualRefundsAtAttemptCap reconciles Square-less MANUAL refund
// rows that have already hit the 3-attempt cap. The Square-less pre-pass inside
// sweepManualPendingSquareRefunds (refunds.go) filters refund_attempts < 3, so
// a legacy manual refund on a payment with no square_payment_id that reached
// the cap (attempts incremented by pre-guard Square attempts) is never
// reconciled by it and would stay 'pending' forever, permanently blocking the
// over-refund guard (F10). Such rows can never be refunded via Square, so they
// are marked 'failed' and surfaced in the admin notification centre for
// in-person arrangement — the same terminal treatment the pre-pass gives rows
// under the cap. Returns the number of rows marked failed.
// rows that have already hit the attempt cap (maxManualRefundAttempts). The
// Square-less pre-pass inside sweepManualPendingSquareRefunds (refunds.go)
// filters refund_attempts < maxManualRefundAttempts, so a legacy manual refund
// on a payment with no square_payment_id that reached the cap (attempts
// incremented by pre-guard Square attempts) is never reconciled by it and would
// stay 'pending' forever, permanently blocking the over-refund guard (F10).
// Such rows can never be refunded via Square, so they are marked 'failed' and
// surfaced in the admin notification centre for in-person arrangement — the
// same terminal treatment the pre-pass gives rows under the cap. Returns the
// number of rows marked failed.
func sweepSquarelessManualRefundsAtAttemptCap(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts >= 3
AND r.status = 'pending' AND r.refund_attempts >= %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'manual'
RETURNING r.id
`)
`, maxManualRefundAttempts))
if err != nil {
return 0, err
}
@@ -1091,7 +1151,7 @@ func sweepSquarelessManualRefundsAtAttemptCap(ctx context.Context) (int, error)
return 0, err
}
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less manual refund(s) at the 3-attempt cap failed (in-person arrangement needed)", len(failedIDs))
log.Printf("Marked %d Square-less manual refund(s) at the %d-attempt cap failed (in-person arrangement needed)", len(failedIDs), maxManualRefundAttempts)
}
insertRefundFailedNotifications(ctx, failedIDs)
return len(failedIDs), nil
+12 -6
View File
@@ -837,11 +837,17 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
t.Fatalf("failed to age the stale payment: %v", err)
}
// The replayed payment is the ORIGINAL — created at the same instant as the
// pending row (~23h ago), as a retained-key dedup returns. The env is
// flipped to production for the sweep so the A1 cross-check runs; the dev
// mock is constructed BEFORE the flip (NewDevClient refuses production
// without SQUARE_ALLOW_REAL_API).
// The replayed payment is the ORIGINAL — created at the SAME instant as the
// pending row, as a retained-key dedup returns. Seeding CreatedAt from the
// row's own timestamp makes the F2 lag ~0 deterministically (a fixed
// clock.Now()-relative offset would race the DB NOW() microsecond
// truncation). The env is flipped to production for the sweep so the A1
// cross-check runs; the dev mock is constructed BEFORE the flip
// (NewDevClient refuses production without SQUARE_ALLOW_REAL_API).
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)
}
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
@@ -849,7 +855,7 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
Status: "COMPLETED",
ID: "pay_original_under_key",
SquarePayID: "pay_original_under_key",
CreatedAt: clock.Now().Add(-23 * time.Hour).Format(time.RFC3339),
CreatedAt: rowCreatedAt.Format(time.RFC3339Nano),
}}
defer func() { SquareClient = origClient }()
+81 -30
View File
@@ -63,11 +63,22 @@ type TillSaleResponse struct {
// request-stable key instead.
// definitivePaymentDeclineCodes are Square payment error codes meaning the
// card charge can never succeed (declined / expired / not supported). They are
// matched against the formatted Square API error so a DEFINITIVE rejection can
// claw back a gift card funded earlier in the same till-sale request. Anything
// else (transport errors, 5xx, unknown) is treated as ambiguous: the sale is
// left pending for the stale-pending sweep, which may still resolve it.
// card charge can never succeed (declined / expired / not supported / SCA
// verification required). They are matched against the formatted Square API
// error message so a DEFINITIVE rejection can claw back a gift card funded
// earlier in the same till-sale request. Anything else (transport errors, 5xx,
// unknown) is treated as ambiguous: the sale is left pending for the
// stale-pending sweep, which may still resolve it.
//
// A1: this list is the MESSAGE-MATCH fallback ONLY. The authoritative
// structured-code classification lives in the square package's
// definitivePaymentCodes / IsDefinitivePaymentError
// (square_http_client.go:777-818) — the single source of truth that also
// carries the SCA buyer-verification codes — and isDefinitiveChargeFailure
// delegates to it FIRST. The list below must stay a subset-compatible mirror
// for errors that carry no structured code (the dev mock's plain errors, a
// non-JSON failure body), where the formatted "[CATEGORY/CODE]" message is the
// only signal available.
var definitivePaymentDeclineCodes = []string{
"CARD_DECLINED",
"CARD_EXPIRED",
@@ -81,27 +92,60 @@ var definitivePaymentDeclineCodes = []string{
"INSUFFICIENT_FUNDS",
"ADDRESS_VERIFICATION_FAILURE",
"TRANSACTION_LIMIT",
// Square's specific CARD_DECLINED_* decline reasons. This list must stay a
// SUPERSET-MATCHED MIRROR of square_http_client.go:definitivePaymentCodes
// (the single source of truth) — every variant the square package treats as
// definitive must also match here, so a dev-mock plain error carrying e.g.
// CARD_DECLINED_INSUFFICIENT_FUNDS in its formatted message classifies
// definitively. Add any new definitivePaymentCodes entry here too.
"CARD_DECLINED_CALL_ISSUER",
"CARD_DECLINED_AVS_FAILURE",
"CARD_DECLINED_CVV_FAILURE",
"CARD_DECLINED_INSUFFICIENT_FUNDS",
"CARD_DECLINED_INVALID_ACCOUNT",
"CARD_DECLINED_INVALID_AMOUNT",
"CARD_DECLINED_CARD_EXPIRED",
"CARD_DECLINED_PIN_RETRIES_EXCEEDED",
// SCA / buyer-verification codes — the buyer must re-verify or the card be
// re-tokenized before the charge can succeed; retrying is pointless. Kept
// in the message fallback so the dev mock's plain errors classify exactly
// like the real client's structured codes.
"CARD_DECLINED_VERIFICATION_REQUIRED",
"VERIFICATION_TOKEN_EXPIRED",
"VERIFICATION_TOKEN_INVALID",
"CVV_VERIFICATION_REQUIRED",
"ADDRESS_VERIFICATION_REQUIRED",
"MISSING_PIN",
"MISSING_VERIFICATION_TOKEN",
}
// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a
// definitive business rejection (declined/expired) rather than an ambiguous
// transport/server error. The real HTTP client surfaces declines as a
// structured squareAPIError carrying the Square error Code (and Category), so
// the classification matches those EXACTLY against definitivePaymentDeclineCodes
// — a Square message-wording change can never silently flip the
// definitive↔retryable decision that drives the gift-card funding clawback.
// Only errors that carry NO structured code (the dev mock's plain errors, or a
// non-JSON failure body) fall back to the legacy formatted-message match
// ("square: POST /v2/payments: [CATEGORY/CODE] ..."), which is the only signal
// available for them.
// definitive business rejection (declined/expired/SCA-required) rather than an
// ambiguous transport/server error.
//
// A1 — single source of truth: the classification delegates FIRST to the
// square package's exported IsDefinitivePaymentError (square_http_client.go
// definitivePaymentCodes), which is the union of the card decline codes and
// the SCA buyer-verification codes. Delegating means the two parallel lists
// can never drift again — an SCA rejection (e.g. CVV_VERIFICATION_REQUIRED)
// now classifies as definitive here exactly as it does everywhere else, so the
// till's gift-card clawback (till.go:1184) reverses the funding on a
// verification failure the same way it does on a plain decline. Only errors
// that carry NO structured code (the dev mock's plain errors, or a non-JSON
// failure body) fall back to the legacy formatted-message match
// ("square: POST /v2/payments: [CATEGORY/CODE] ...") against
// definitivePaymentDeclineCodes — the only signal available for them.
func isDefinitiveChargeFailure(err error) bool {
if err == nil {
return false
}
// The formatted message carries both [CATEGORY/CODE] and the legacy check
// matched either, so compare the Code AND the Category exactly.
if code := square.ErrorCode(err); code != "" {
return declineCodeListContains(code) || declineCodeListContains(square.ErrorCategory(err))
if square.IsDefinitivePaymentError(err) {
return true
}
// Any other structured Square error code is authoritative — never
// substring-match its message.
if square.ErrorCode(err) != "" {
return false
}
msg := strings.ToUpper(err.Error())
for _, code := range definitivePaymentDeclineCodes {
@@ -112,17 +156,6 @@ func isDefinitiveChargeFailure(err error) bool {
return false
}
// declineCodeListContains reports whether s is exactly one of the definitive
// payment decline codes.
func declineCodeListContains(s string) bool {
for _, code := range definitivePaymentDeclineCodes {
if s == code {
return true
}
}
return false
}
// deriveTillIdempotencyKey returns the deterministic no-client-key fallback
// BASE idempotency key for a till sale: "till-" + sha256 over the canonical
// request fields (action, created_by admin, amount in pence, and the gift card
@@ -1119,6 +1152,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// returns IDEMPOTENCY_KEY_REUSED, leaving the row pending forever.
// The snapshot holds PII (buyer email + ccof token), so it is
// encrypted at rest via encryptSnapshot (plaintext in dev/mock).
//
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
// snapshot-is-null guard like the booking/tip/terminal flows): the
// pending-reuse branch above already refreshed square_request_snapshot
// in the SAME transaction as the square_source_id refresh
// (refreshTillSnapshotSource, B6), and this post-commit write stores
// the fresh full body for THIS attempt. A guard would wrongly skip
// this write on the reuse path when the in-tx refresh failed
// best-effort — do NOT "fix" it into the guarded form.
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 stored, eErr := encryptSnapshot(snap); eErr != nil {
@@ -1156,6 +1198,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// 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.
//
// The write is INTENTIONALLY unconditional (WHERE id = $2, no
// snapshot-is-null guard like the booking/tip/terminal flows): the
// pending-reuse branch above already refreshed square_request_snapshot
// in the SAME transaction as the square_source_id refresh
// (refreshTillSnapshotSource, B6), and this post-commit write stores
// the fresh full body for THIS attempt. A guard would wrongly skip
// this write on the reuse path when the in-tx refresh failed
// best-effort — do NOT "fix" it into the guarded form.
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 stored, eErr := encryptSnapshot(snap); eErr != nil {
+2 -14
View File
@@ -32,26 +32,14 @@ func require2FADisabled() bool {
// It is fail-closed: enforcement is ON unless 2FA has been explicitly disabled
// (REQUIRE_2FA=false/0/off/no, case-insensitive — see require2FADisabled) or
// SQUARE_ENVIRONMENT explicitly selects the dev/mock stack
// (mock/dev/development/test). Empty or unknown SQUARE_ENVIRONMENT values are
// (mock/dev/development/test — see IsExplicitDevOrMockEnv in
// idempotency_helpers.go). 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 {
return !require2FADisabled() && !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.
+4 -4
View File
@@ -26,12 +26,12 @@ func ValidateAmount(amount int64) error {
}
// ValidatePartialAmount checks that the partial amount doesn't exceed the remaining balance
func ValidatePartialAmount(amountCents int64, remainingCents int64) error {
if amountCents > remainingCents {
func ValidatePartialAmount(amountPence int64, remainingPence int64) error {
if amountPence > remainingPence {
return fmt.Errorf("partial amount (£%.2f) exceeds remaining balance (£%.2f)",
float64(amountCents)/100, float64(remainingCents)/100)
float64(amountPence)/100, float64(remainingPence)/100)
}
if amountCents <= 0 {
if amountPence <= 0 {
return errors.New("amount must be greater than 0")
}
return nil
+8 -8
View File
@@ -1671,12 +1671,12 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
// Remaining balance should be total - paid (gross) = 100 - 30 = 70
svc := NewPaymentService()
remaining, rErr := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining, rErr := svc.GetBookingRemainingBalancePence(ctx, bookingID)
if rErr != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr)
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr)
}
if remaining != 7000 {
t.Errorf("expected remaining 7000 cents (£70), got %d", remaining)
t.Errorf("expected remaining 7000 pence (£70), got %d", remaining)
}
// Pay another £40 with VAT — remaining should be 100 - 70 = 30
@@ -1700,12 +1700,12 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
t.Fatalf("second payment: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
remaining2, rErr2 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining2, rErr2 := svc.GetBookingRemainingBalancePence(ctx, bookingID)
if rErr2 != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr2)
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr2)
}
if remaining2 != 3000 {
t.Errorf("expected remaining 3000 cents (£30), got %d", remaining2)
t.Errorf("expected remaining 3000 pence (£30), got %d", remaining2)
}
// Pay the final £30 — remaining should be 0
@@ -1729,9 +1729,9 @@ func TestVAT_RemainingBalanceWithVAT(t *testing.T) {
t.Fatalf("third payment: expected 200, got %d: %s", w3.Code, w3.Body.String())
}
remaining3, rErr3 := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
remaining3, rErr3 := svc.GetBookingRemainingBalancePence(ctx, bookingID)
if rErr3 != nil {
t.Fatalf("GetBookingRemainingBalanceCents failed: %v", rErr3)
t.Fatalf("GetBookingRemainingBalancePence failed: %v", rErr3)
}
if remaining3 != 0 {
t.Errorf("expected remaining 0, got %d", remaining3)