fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the review found in the initial integration, plus the rough edges it introduced. Money-safety: - Replay-by-key now replays the FULL original request verbatim from a stored square_request_snapshot, so a retained idempotency key returns the original payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge). - Dev mock mirrors real Square for unknown-key replays: ccof: saved-card sources are charged and rescued; spent cnon: nonces surface ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.) - Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales claw back gift-card funding; event-type strings match Square's real catalog. - Expired-gift-card cancellation refunds set creditFailed (never a phantom 'completed' refund); cancellation refunds lock all payment rows ascending. - Sweep never rescue-completes a gift-card purchase without delivering the card. - Tip no-client-key fallback is a deterministic count-based key under the booking advisory lock (retry-safe, distinct tips don't collapse). - M-cap subtracts completed refunds, clamped to [0, total]. 2FA (PSD2 SCA stand-in) for online saved-card payments: - Full feature: status/setup/verify/disable endpoints, gating helper wired into all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account admin-tab settings UI, frontend gating across all payment surfaces. - Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env. - Verify is brute-force hardened (5-attempt lockout, timing-safe compare); plaintext codes only logged when enforcement is off (dev). - GDPR: anonymize_user also scrubs 2FA columns and staff notes. Infra/docs: - nginx: /api/ response cache removed (cross-user disclosure); port 80 redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS; separate webhook rate-limit zone. - Schema: users 2FA columns; payments/till_sales square_source_id + square_request_snapshot. - Legal docs: gift-card cooling-off, international-transfers section, tips policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected. - Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26 packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -140,14 +143,40 @@ type staleRow struct {
|
||||
// the lost-response case: the sweep replays the key at Square to learn the
|
||||
// true charge outcome before declaring failure.
|
||||
IdempotencyKey string
|
||||
// SquareSourceID is the exact source_id sent in the original CreatePayment
|
||||
// call, stored on the row so the replay-by-key can rebuild an IDENTICAL
|
||||
// request body (same key + source + amount). Replaying a different body
|
||||
// would return IDEMPOTENCY_KEY_REUSED, which proves nothing about whether
|
||||
// the charge landed.
|
||||
SquareSourceID string
|
||||
// SquareRequestSnapshot is the verbatim original CreatePayment request JSON
|
||||
// stored on the row at charge time (square_request_snapshot) — the FULL
|
||||
// body the replay-by-key must repeat (source, key, amount, customer_id,
|
||||
// reference_id, note, buyer_email_address, verification_token, ...).
|
||||
// Square's idempotency dedup compares the whole request, so a replay built
|
||||
// from partial row data returns IDEMPOTENCY_KEY_REUSED for a RETAINED key
|
||||
// and the row stays pending forever (safe but never auto-rescued). Nil for
|
||||
// legacy rows — the sweep then rebuilds the minimal body from the stored
|
||||
// key + source + amount.
|
||||
SquareRequestSnapshot []byte
|
||||
// AmountPence is the row's charge amount in pence — the amount the original
|
||||
// CreatePayment used. The replay-by-key must repeat it so Square's
|
||||
// idempotency dedup returns the original payment.
|
||||
AmountPence int64
|
||||
// CreatedAt is the pending row's creation time; rows already past Square's
|
||||
// idempotency-key retention window cannot be replayed trustworthily.
|
||||
CreatedAt time.Time
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
CreatedAt time.Time
|
||||
// BookingID is the payments row's booking_id ("" when the row has none).
|
||||
// A payments row with NO booking is a gift-card purchase (BuyGiftCard
|
||||
// inserts without a booking): rescuing it to 'completed' on a Square
|
||||
// COMPLETED reconcile would permanently block the same-key retry that
|
||||
// delivers the card, so such rows are kept pending instead (C6).
|
||||
BookingID *string
|
||||
// CreatedBy is the payments row's created_by user id (gift-card purchases
|
||||
// always carry the purchaser), used to attribute the critical-payment admin
|
||||
// notification.
|
||||
CreatedBy *string
|
||||
ItemID string // till_sales.item_id — the gift card ("" when NULL / non-gift-card)
|
||||
RedeemToUserID *string // gift_cards.redeemed_by — user credited by a create-with-redeem
|
||||
IsCreate bool // true when this sale created the gift card (timestamps equal)
|
||||
HasGiftCard bool // false when the LEFT JOIN found no gift_cards row (gc.id IS NULL)
|
||||
@@ -177,6 +206,14 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv
|
||||
if r.SquarePaymentID != "" {
|
||||
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
|
||||
case staleReconcileCompleted:
|
||||
if table == "payments" && r.BookingID == nil {
|
||||
// Gift-card purchase row: the charge landed at Square but
|
||||
// the gift card was never delivered (C6). Completing the row
|
||||
// permanently blocks the same-key retry that delivers the
|
||||
// card — leave it pending and alert.
|
||||
leaveGiftCardPurchasePending(ctx, r)
|
||||
continue
|
||||
}
|
||||
if rescueStaleRowCompleted(ctx, table, r.ID) {
|
||||
resolved++
|
||||
completed++
|
||||
@@ -257,8 +294,15 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
|
||||
log.Printf("Stale pending %s row %s has a stored idempotency key but is already past Square's key retention window — marked failed without a replay reconcile (may have been charged with a lost response)", table, r.ID)
|
||||
continue
|
||||
}
|
||||
switch res, sqPayID := reconcileStalePaymentByKey(ctx, table, r.IdempotencyKey, r.AmountPence); res {
|
||||
switch res, sqPayID := reconcileStalePaymentByKey(ctx, table, r); res {
|
||||
case staleReconcileCompleted:
|
||||
if table == "payments" && r.BookingID == nil {
|
||||
// Gift-card purchase row (C6): the charge landed at Square but
|
||||
// the card was never delivered. Completing the row blocks the
|
||||
// same-key retry that delivers the card — leave it pending.
|
||||
leaveGiftCardPurchasePending(ctx, r)
|
||||
continue
|
||||
}
|
||||
if rescueKeyedStaleRowCompleted(ctx, table, r.ID, sqPayID) {
|
||||
resolved++
|
||||
completed++
|
||||
@@ -306,6 +350,7 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
if table == "till_sales" {
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT ts.id, COALESCE(ts.square_payment_id, ''), COALESCE(ts.idempotency_key, ''),
|
||||
COALESCE(ts.square_source_id, ''), COALESCE(ts.square_request_snapshot, ''),
|
||||
ts.created_at, ts.item_id, gc.redeemed_by,
|
||||
(ts.created_at = gc.created_at) AS is_create,
|
||||
(gc.id IS NOT NULL) AS has_gift_card, ts.total_amount
|
||||
@@ -316,7 +361,8 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
} else {
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT id, COALESCE(square_payment_id, ''), COALESCE(idempotency_key, ''),
|
||||
created_at, amount
|
||||
COALESCE(square_source_id, ''), COALESCE(square_request_snapshot, ''),
|
||||
created_at, amount, booking_id, created_by
|
||||
FROM `+table+`
|
||||
WHERE status = 'pending' AND created_at < $1`+keyedPredicate+`
|
||||
`, cutoff)
|
||||
@@ -343,13 +389,14 @@ func fetchStaleRows(ctx context.Context, table string, cutoff time.Time, keyedOn
|
||||
func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) {
|
||||
var r staleRow
|
||||
if table == "till_sales" {
|
||||
var itemID, redeemedBy sql.NullString
|
||||
var itemID, redeemedBy, snapshot sql.NullString
|
||||
var isCreate *bool
|
||||
var hasGiftCard bool
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt,
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.SquareSourceID, &snapshot, &r.CreatedAt,
|
||||
&itemID, &redeemedBy, &isCreate, &hasGiftCard, &r.TotalAmount); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.SquareRequestSnapshot = []byte(snapshot.String)
|
||||
r.ItemID = itemID.String
|
||||
if redeemedBy.Valid && redeemedBy.String != "" {
|
||||
r.RedeemToUserID = &redeemedBy.String
|
||||
@@ -360,10 +407,20 @@ func scanStaleRow(table string, rows pgx.Rows) (staleRow, error) {
|
||||
return r, nil
|
||||
}
|
||||
var amount float64
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.CreatedAt, &amount); err != nil {
|
||||
var bookingID, createdBy, snapshot sql.NullString
|
||||
if err := rows.Scan(&r.ID, &r.SquarePaymentID, &r.IdempotencyKey, &r.SquareSourceID, &snapshot, &r.CreatedAt, &amount, &bookingID, &createdBy); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.SquareRequestSnapshot = []byte(snapshot.String)
|
||||
r.AmountPence = int64(math.Round(amount * 100))
|
||||
if bookingID.Valid && bookingID.String != "" {
|
||||
b := bookingID.String
|
||||
r.BookingID = &b
|
||||
}
|
||||
if createdBy.Valid && createdBy.String != "" {
|
||||
c := createdBy.String
|
||||
r.CreatedBy = &c
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -438,22 +495,51 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
|
||||
|
||||
// reconcileStalePaymentByKey asks Square for the authoritative status of the
|
||||
// charge made under a stale pending row's idempotency key and returns the
|
||||
// tri-state result. The replay returns the ORIGINAL payment for a retained key
|
||||
// (Square's documented idempotency behavior — never a second charge); a
|
||||
// COMPLETED payment rescues the row to 'completed' with its real payment id. A
|
||||
// definitive rejection (ErrReplayKeyNotRetained — Square has no payment under
|
||||
// the key, so the charge never happened) or a FAILED/CANCELED payment proves
|
||||
// the charge never completed and fails the row. Any OTHER error (transport /
|
||||
// 5xx / ambiguous) leaves the row pending — the charge may still have
|
||||
// completed at Square. The second return value is the Square payment id of the
|
||||
// completed payment ("" otherwise), written back on a rescue.
|
||||
func reconcileStalePaymentByKey(ctx context.Context, table, idempotencyKey string, amountPence int64) (staleReconcileResult, string) {
|
||||
pr, err := SquareClient.ReplayPaymentByKey(ctx, idempotencyKey, amountPence)
|
||||
// tri-state result. The replay sends an IDENTICAL body to the original charge:
|
||||
// the stored square_request_snapshot (the FULL request — source_id, key,
|
||||
// amount and every field the original carried). Replaying a partial body would
|
||||
// return IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending
|
||||
// forever. Square's idempotency guarantee returns the ORIGINAL payment for a
|
||||
// retained key (never a second charge); a COMPLETED payment rescues the row to
|
||||
// 'completed' with its real payment id. A definitive 4xx rejection
|
||||
// (ErrReplayKeyNotRetained — Square attempted a real charge with the
|
||||
// expired/used source and refused it, so the charge never happened) or a
|
||||
// FAILED/CANCELED payment proves the charge never completed and fails the row.
|
||||
// IDEMPOTENCY_KEY_REUSED is NEVER proof of no charge: with an identical body it
|
||||
// can only mean the stored source differs from the original (a data bug), so
|
||||
// the row is left pending with a CRITICAL log. Any OTHER error (transport /
|
||||
// 5xx / ambiguous) leaves the row pending — the charge may still have completed
|
||||
// at Square. The second return value is the Square payment id of the completed
|
||||
// payment ("" otherwise), written back on a rescue.
|
||||
func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (staleReconcileResult, string) {
|
||||
snapshot := r.SquareRequestSnapshot
|
||||
if len(bytes.TrimSpace(snapshot)) == 0 {
|
||||
// Legacy row without a stored request snapshot — rebuild the minimal
|
||||
// identical body (key + source + amount) exactly as the pre-snapshot
|
||||
// replay did. Such rows can still be reconciled as long as the stored
|
||||
// source/amount match the original charge.
|
||||
fallback, mErr := json.Marshal(square.CreatePaymentReq{
|
||||
Amount: r.AmountPence,
|
||||
Currency: "GBP",
|
||||
SourceID: r.SquareSourceID,
|
||||
IdempotencyKey: r.IdempotencyKey,
|
||||
})
|
||||
if mErr != nil {
|
||||
log.Printf("Stale pending %s reconcile by key: failed to rebuild replay body for row %s (%v) — leaving pending", table, r.ID, mErr)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
snapshot = fallback
|
||||
}
|
||||
pr, err := SquareClient.ReplayPaymentByKey(ctx, snapshot)
|
||||
if err != nil {
|
||||
if errors.Is(err, square.ErrReplayKeyNotRetained) {
|
||||
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (replay probe rejected) — marking failed; the charge provably never happened", table)
|
||||
log.Printf("Stale pending %s reconcile by key: Square has no payment under the stored idempotency key (identical-body replay rejected) — marking failed; the charge provably never happened", table)
|
||||
return staleReconcileDefinitivelyFailed, ""
|
||||
}
|
||||
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
||||
log.Printf("CRITICAL: stale pending %s reconcile by key hit IDEMPOTENCY_KEY_REUSED — the stored square_source_id differs from the original charge's source (data bug); this is NOT proof the charge never happened — leaving pending — MANUAL RECONCILIATION REQUIRED", table)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
log.Printf("Stale pending %s reconcile by idempotency key hit an ambiguous error (%v) — leaving pending for a later sweep run", table, err)
|
||||
return staleReconcileLeavePending, ""
|
||||
}
|
||||
@@ -479,6 +565,44 @@ func reconcileStalePaymentByKey(ctx context.Context, table, idempotencyKey strin
|
||||
}
|
||||
}
|
||||
|
||||
// leaveGiftCardPurchasePending keeps a gift-card-purchase payment row (payments
|
||||
// table, booking_id NULL) pending after Square confirms the charge COMPLETED,
|
||||
// instead of rescuing it to 'completed'. Completing the row would permanently
|
||||
// block the same-key retry (BuyGiftCard) that reuses the pending record to
|
||||
// deliver the card — the customer would stay charged with no gift card (C6).
|
||||
// The row is left pending with a CRITICAL log + admin notification so the retry
|
||||
// can still deliver the card and an admin is alerted to reconcile manually.
|
||||
func leaveGiftCardPurchasePending(ctx context.Context, r staleRow) {
|
||||
log.Printf("CRITICAL: gift card purchase payment %s is COMPLETED at Square but the gift card was never issued (issue transaction failed, retry abandoned) — leaving the payment PENDING so a same-key retry can still deliver the card — MANUAL RECONCILIATION REQUIRED", r.ID)
|
||||
insertCriticalPaymentNotification(ctx, nil, r.CreatedBy)
|
||||
}
|
||||
|
||||
// insertCriticalPaymentNotification surfaces an unresolved money event in the
|
||||
// admin notification centre (reason 'critical_payment_log' — the DB-backed
|
||||
// stand-in for the un-watched CRITICAL payment logs). bookingID is set when the
|
||||
// issue ties to a booking (untracked terminal charges); userID is set when it
|
||||
// ties to a user (gift-card purchases). The NOT EXISTS guard keeps ONE
|
||||
// notification per issue instead of one per sweep run.
|
||||
func insertCriticalPaymentNotification(ctx context.Context, bookingID, userID *string) {
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id, created_at)
|
||||
SELECT 'critical_payment_log', $1, $2, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM admin_notifications an
|
||||
WHERE an.reason = 'critical_payment_log'
|
||||
AND an.booking_id IS NOT DISTINCT FROM $1
|
||||
AND an.user_id IS NOT DISTINCT FROM $2
|
||||
)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert admin_notifications for critical payment issue (booking=%v user=%v): %v", bookingID, userID, err)
|
||||
return
|
||||
}
|
||||
if int(tag.RowsAffected()) > 0 {
|
||||
log.Printf("Inserted critical-payment admin notification (booking=%v user=%v)", bookingID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// staleReconcileResult is the tri-state outcome of reconciling one stale
|
||||
// pending row against Square. Only a definitively-resolved outcome touches the
|
||||
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
|
||||
@@ -624,7 +748,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
// terminal_checkouts table. A stale PENDING/IN_PROGRESS row means the
|
||||
// checkout is still live at Square (or was left after a crash / lost poll).
|
||||
rows, err := db.Conn.Query(ctx, `
|
||||
SELECT 'terminal_checkout', checkout_id, checkout_id
|
||||
SELECT 'terminal_checkout', checkout_id, checkout_id, booking_id
|
||||
FROM terminal_checkouts
|
||||
WHERE status IN ('PENDING', 'IN_PROGRESS')
|
||||
AND created_at < $1
|
||||
@@ -634,7 +758,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
for rows.Next() {
|
||||
var r staleTerminalCheckoutRow
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID, &r.BookingID); err != nil {
|
||||
log.Printf("Failed to scan stale terminal checkout row: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -644,7 +768,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
|
||||
// Till-sale card-machine checkouts are tracked on the till_sales row.
|
||||
rows, err = db.Conn.Query(ctx, `
|
||||
SELECT 'till_sale', id, square_checkout_id
|
||||
SELECT 'till_sale', id, square_checkout_id, ''
|
||||
FROM till_sales
|
||||
WHERE status = 'pending' AND square_checkout_id IS NOT NULL
|
||||
AND created_at < $1
|
||||
@@ -654,7 +778,7 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
for rows.Next() {
|
||||
var r staleTerminalCheckoutRow
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
|
||||
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID, &r.BookingID); err != nil {
|
||||
log.Printf("Failed to scan stale terminal checkout row: %v", err)
|
||||
continue
|
||||
}
|
||||
@@ -697,21 +821,16 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
switch {
|
||||
case rErr == nil && recheck.Status == "COMPLETED":
|
||||
// The customer completed the payment during the cancel window.
|
||||
// The poll handler records it — mark the row COMPLETED (or
|
||||
// leave the till sale pending) instead of failed.
|
||||
// Record it if it was never polled/recorded — a COMPLETED
|
||||
// checkout must not stay an untracked charge (H4).
|
||||
if r.Kind == "terminal_checkout" {
|
||||
if tag, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, r.RowID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed after cancel re-check: %v", r.RowID, upErr)
|
||||
} else if int(tag.RowsAffected()) > 0 {
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, recheck) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
|
||||
}
|
||||
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — recorded as completed, payment handled by the poll handler", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
|
||||
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — payment recorded by the sweep", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
|
||||
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
|
||||
// The cancel landed (CANCELED / cancel-requested / expired /
|
||||
// still-reporting-pending-but-now-cancelled) — it can never
|
||||
@@ -734,14 +853,11 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
}
|
||||
case gErr == nil && pr.Status == "COMPLETED":
|
||||
if r.Kind == "terminal_checkout" {
|
||||
// The payment is recorded by the poll handler; release the
|
||||
// in-flight guard so a fresh charge can be created.
|
||||
if tag, upErr := db.Conn.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, r.RowID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", r.RowID, upErr)
|
||||
} else if int(tag.RowsAffected()) > 0 {
|
||||
// The checkout completed at Square but was never polled/recorded
|
||||
// (the frontend never called GetCheckoutStatus). Record the
|
||||
// payment rows now — otherwise the charge stays invisible to
|
||||
// refunds and TotalPaid (H4).
|
||||
if recordUntrackedTerminalPayment(ctx, r.CheckoutID, r.BookingID, pr) {
|
||||
resolved++
|
||||
}
|
||||
} else {
|
||||
@@ -784,11 +900,14 @@ func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
|
||||
// staleTerminalCheckoutRow is one live-checkout row the sweep reads from
|
||||
// either table so it can resolve the checkout at Square before touching the
|
||||
// row. Kind is "terminal_checkout" (booking, terminal_checkouts table) or
|
||||
// "till_sale" (till_sales.square_checkout_id).
|
||||
// "till_sale" (till_sales.square_checkout_id). BookingID is the booking the
|
||||
// terminal_checkouts row belongs to ("" for till sales) — needed to record an
|
||||
// untracked COMPLETED terminal charge (H4).
|
||||
type staleTerminalCheckoutRow struct {
|
||||
Kind string
|
||||
RowID string
|
||||
CheckoutID string
|
||||
BookingID string
|
||||
}
|
||||
|
||||
// markTerminalCheckoutRowFailed moves one tracked row to the terminal 'failed'
|
||||
@@ -813,6 +932,162 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR
|
||||
return int(tag.RowsAffected()) > 0
|
||||
}
|
||||
|
||||
// recordUntrackedTerminalPayment records the payments row(s) for a terminal
|
||||
// checkout that COMPLETED at Square but was never polled/recorded, then marks
|
||||
// the terminal_checkouts row COMPLETED. The stale sweep otherwise leaves a real
|
||||
// charge with NO payments row — invisible to refunds and TotalPaid (H4). It
|
||||
// reuses the exact insert convention of GetCheckoutStatus: the same advisory
|
||||
// lock key (serializes against a concurrent poll), the same dedup by
|
||||
// booking_id + square_payment_id, the same PaymentRecord shape and derived
|
||||
// idempotency key, and the same deposit/balance/tip split for a charge above
|
||||
// the remaining booking value. Returns true when the checkout row was resolved
|
||||
// (payment recorded or already recorded); false when recording failed (the
|
||||
// row is left pending so the next sweep re-runs the whole reconcile).
|
||||
func recordUntrackedTerminalPayment(ctx context.Context, checkoutID, bookingID string, pr *square.PaymentResult) bool {
|
||||
if pr == nil || pr.SquarePayID == "" {
|
||||
log.Printf("CRITICAL: terminal checkout %s is COMPLETED at Square but carries no Square payment ID — cannot record the payment — MANUAL RECONCILIATION REQUIRED", checkoutID)
|
||||
return false
|
||||
}
|
||||
service := NewPaymentService()
|
||||
|
||||
// Serialize with the poll handler (GetCheckoutStatus): both record the same
|
||||
// Square payment, so the advisory lock + dedup SELECT prevent a double
|
||||
// insert (the idempotency_key UNIQUE constraint is the backstop).
|
||||
pinConn, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for terminal-completion lock: %v", err)
|
||||
return false
|
||||
}
|
||||
defer pinConn.Release()
|
||||
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:terminal:"+pr.SquarePayID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire terminal-completion serialization lock for %s: %v", pr.SquarePayID, err)
|
||||
return false
|
||||
}
|
||||
if !lockOK {
|
||||
log.Printf("Terminal-completion serialization lock for %s not acquired within bound — a poll is already recording this checkout", pr.SquarePayID)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('crussell:terminal:' || $1))`, pr.SquarePayID); err != nil {
|
||||
log.Printf("Failed to release terminal-completion serialization lock for %s: %v", pr.SquarePayID, err)
|
||||
}
|
||||
}()
|
||||
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin terminal-completion transaction: %v", err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
log.Printf("Failed to rollback terminal-completion transaction: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Dedup by Square payment ID: a concurrent poll (or a prior sweep run)
|
||||
// already recorded this charge — just release the in-flight guard.
|
||||
var existingID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM payments
|
||||
WHERE booking_id = $1 AND square_payment_id = $2
|
||||
`, bookingID, pr.SquarePayID).Scan(&existingID); err == nil {
|
||||
if _, upErr := tx.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, checkoutID); upErr != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr)
|
||||
return false
|
||||
}
|
||||
if cErr := tx.Commit(ctx); cErr != nil {
|
||||
log.Printf("Failed to commit terminal-completion transaction: %v", cErr)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to check for existing terminal payment: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// The payment type the admin charged is recorded on the checkout row by
|
||||
// CreateTerminalPayment; fall back to 'full' for legacy rows.
|
||||
var checkoutPaymentType string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1
|
||||
`, checkoutID).Scan(&checkoutPaymentType); err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err)
|
||||
}
|
||||
checkoutPaymentType = "full"
|
||||
}
|
||||
|
||||
idempotencyKey := bookingID + "-terminal-" + strconv.FormatInt(pr.Amount, 10) + "-" + pr.SquarePayID
|
||||
record := PaymentRecord{
|
||||
BookingID: bookingID,
|
||||
PaymentType: checkoutPaymentType,
|
||||
PaymentMethod: "in_person_card",
|
||||
Status: "completed",
|
||||
Amount: float64(pr.Amount) / 100.0,
|
||||
SquarePaymentID: &pr.SquarePayID,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Fees: float64(pr.Fees) / 100.0,
|
||||
CreatedAt: clock.Now(),
|
||||
UpdatedAt: clock.Now(),
|
||||
}
|
||||
|
||||
// M4 split: a terminal charge above the remaining booking value is a tip —
|
||||
// record it as its own record so only the booking portion is refundable.
|
||||
var records []PaymentRecord
|
||||
bookingInfo, bErr := service.GetBookingPaymentInfo(ctx, bookingID)
|
||||
if bErr == nil && bookingInfo != nil {
|
||||
charged := float64(pr.Amount) / 100.0
|
||||
remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid)
|
||||
bookingPortion := math.Min(charged, remainingBookingValue)
|
||||
bookingPortion = math.Round(bookingPortion*100) / 100
|
||||
tipAmount := math.Round((charged-bookingPortion)*100) / 100
|
||||
if tipAmount > 0.004 {
|
||||
records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
records = []PaymentRecord{record}
|
||||
}
|
||||
|
||||
primary := records[0]
|
||||
paymentID, err := service.CreatePaymentRecordTx(ctx, tx, primary, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create payment record for untracked terminal charge %s: %v", pr.SquarePayID, err)
|
||||
return false
|
||||
}
|
||||
ApplyVATToBookingPayment(ctx, tx, paymentID)
|
||||
for _, rec := range records[1:] {
|
||||
pid, cErr := service.CreatePaymentRecordTx(ctx, tx, rec, nil)
|
||||
if cErr != nil {
|
||||
log.Printf("Failed to create terminal tip split record: %v", cErr)
|
||||
return false
|
||||
}
|
||||
ApplyVATToBookingPayment(ctx, tx, pid)
|
||||
}
|
||||
|
||||
// Release the in-flight guard: this checkout is now recorded.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
|
||||
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
||||
`, checkoutID); err != nil {
|
||||
log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err)
|
||||
return false
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit terminal-completion transaction: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// The booking may now be fully paid — complete it like the poll handler does.
|
||||
completeFullyPaidBooking(ctx, bookingID)
|
||||
log.Printf("CRITICAL: recorded untracked terminal charge %s (booking %s) from stale checkout %s — payment row %s created (never polled by the frontend)", pr.SquarePayID, bookingID, checkoutID, paymentID)
|
||||
return true
|
||||
}
|
||||
|
||||
// isTerminalCheckoutError reports whether a GetCheckout error proves the
|
||||
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
|
||||
// for a still-live checkout and surfaces a definitively CANCELED status as a
|
||||
|
||||
Reference in New Issue
Block a user