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:
@@ -41,6 +41,17 @@ const (
|
||||
// Handlers log these errors verbatim, so echoing more than a snippet risks
|
||||
// leaking PII that Square may have mirrored from the request.
|
||||
maxErrorBody = 500
|
||||
|
||||
// MaxIdempotencyKeyLength is Square's 45-character idempotency-key limit for
|
||||
// /v2/payments, /v2/cards and /v2/refunds (64 only for
|
||||
// /v2/terminals/checkouts). The square package is the client to Square, so
|
||||
// THIS is the SINGLE SOURCE of the cap: square_dev.go's mock rejection and
|
||||
// the card/customer key builders route through it, and the payments package
|
||||
// aliases it (handlers/payments/idempotency_helpers.go's
|
||||
// maxIdempotencyKeyLength = square.MaxIdempotencyKeyLength) rather than
|
||||
// declaring a second, drifting 45. Update this one constant if Square ever
|
||||
// changes the limit.
|
||||
MaxIdempotencyKeyLength = 45
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -624,10 +635,14 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
|
||||
// so the snapshot is never reconstructed from partial row data.
|
||||
//
|
||||
// TODO (UNVERIFIED ASSUMPTION): this codebase assumes Square retains
|
||||
// idempotency keys for ~24 hours (the stale-pending sweeps use a 23h/25h age
|
||||
// guard on that window). Square's public docs no longer state the exact
|
||||
// retention window — confirm the current value with Square support and update
|
||||
// the sweep age guards and this comment when confirmed.
|
||||
// idempotency keys for ~24 hours. The stale-pending sweeps guard on that
|
||||
// window with three named constants (defined in handlers/payments): the keyed
|
||||
// reconcile cutoff stalePendingKeyedAge (22h, sweep.go), the pass-2
|
||||
// blind-fail cutoff stalePendingPaymentAge (24h, sweep.go), and the refund
|
||||
// age guard stalePendingRefundAge (23h, refunds.go). Square's public docs no
|
||||
// longer state the exact retention window — confirm the current value with
|
||||
// Square support and update the sweep age guards and this comment when
|
||||
// confirmed.
|
||||
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
|
||||
var req CreatePaymentReq
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
@@ -775,17 +790,23 @@ func IsNotFound(err error) bool {
|
||||
}
|
||||
|
||||
// definitivePaymentCodes are Square CreatePayment error codes that mean the
|
||||
// charge can NEVER succeed as-is. This includes the card decline/expiry codes
|
||||
// and — critically for SCA — the buyer-verification codes
|
||||
// (CARD_DECLINED_VERIFICATION_REQUIRED, VERIFICATION_TOKEN_EXPIRED,
|
||||
// VERIFICATION_TOKEN_INVALID, CVV_VERIFICATION_REQUIRED,
|
||||
// ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN, MISSING_VERIFICATION_TOKEN):
|
||||
// those mean the user must re-verify (3DS/SCA) or re-tokenize the card, NOT
|
||||
// that the same request should be retried. A same-request retry with the same
|
||||
// source/token can never succeed, so the failure is DEFINITIVE. This map is
|
||||
// the package-level source of truth; handlers mirror it via
|
||||
// IsDefinitivePaymentError / square.ErrorCode (the dev mock emits the same
|
||||
// codes so dev parity holds).
|
||||
// charge can NEVER succeed as-is. This is the SINGLE authoritative list of
|
||||
// definitive payment rejections — the exported
|
||||
// IsDefinitivePaymentError/square.ErrorCode accessors classify through it, the
|
||||
// dev mock (square_dev.go) emits the same codes so dev parity holds, and the
|
||||
// payments package (handlers/payments/till.go) is being migrated to delegate
|
||||
// to square.IsDefinitivePaymentError instead of its own legacy list. Do not
|
||||
// maintain a second decline-code list anywhere else: add codes HERE.
|
||||
//
|
||||
// The list is the COMPLETE union of Square's decline/expiry codes (including
|
||||
// the specific CARD_DECLINED_* decline reasons) and — critically for SCA —
|
||||
// the buyer-verification codes (CARD_DECLINED_VERIFICATION_REQUIRED,
|
||||
// VERIFICATION_TOKEN_EXPIRED, VERIFICATION_TOKEN_INVALID,
|
||||
// CVV_VERIFICATION_REQUIRED, ADDRESS_VERIFICATION_REQUIRED, MISSING_PIN,
|
||||
// MISSING_VERIFICATION_TOKEN): those mean the user must re-verify (3DS/SCA)
|
||||
// or re-tokenize the card, NOT that the same request should be retried. A
|
||||
// same-request retry with the same source/token can never succeed, so the
|
||||
// failure is DEFINITIVE.
|
||||
var definitivePaymentCodes = map[string]bool{
|
||||
"CARD_DECLINED": true,
|
||||
"CARD_EXPIRED": true,
|
||||
@@ -799,6 +820,17 @@ var definitivePaymentCodes = map[string]bool{
|
||||
"INSUFFICIENT_FUNDS": true,
|
||||
"ADDRESS_VERIFICATION_FAILURE": true,
|
||||
"TRANSACTION_LIMIT": true,
|
||||
// Square's specific CARD_DECLINED_* decline reasons — each is a definitive
|
||||
// rejection of the charge as-is (the issuer declined for a specific
|
||||
// reason), so retrying the same request is pointless.
|
||||
"CARD_DECLINED_CALL_ISSUER": true,
|
||||
"CARD_DECLINED_AVS_FAILURE": true,
|
||||
"CARD_DECLINED_CVV_FAILURE": true,
|
||||
"CARD_DECLINED_INSUFFICIENT_FUNDS": true,
|
||||
"CARD_DECLINED_INVALID_ACCOUNT": true,
|
||||
"CARD_DECLINED_INVALID_AMOUNT": true,
|
||||
"CARD_DECLINED_CARD_EXPIRED": true,
|
||||
"CARD_DECLINED_PIN_RETRIES_EXCEEDED": true,
|
||||
// SCA / buyer-verification codes — the buyer must re-verify or the card be
|
||||
// re-tokenized before the charge can succeed; retrying is pointless.
|
||||
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
||||
@@ -979,12 +1011,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, cust
|
||||
// Deterministic idempotency key derived from user + card (not time-based)
|
||||
// so that retries with the same details don't create duplicate cards.
|
||||
// SHA-256 hash prevents recovering the card token from the key itself.
|
||||
// Truncated to ≤45 chars — Square's idempotency-key limit is 45 chars for
|
||||
// /v2/cards, /v2/payments, and /v2/refunds (64 only for
|
||||
// /v2/terminals/checkouts).
|
||||
// The 38-hex tail (2-char margin: 5 prefix chars + 38 hex = 43, under the
|
||||
// 45-char limit via MaxIdempotencyKeyLength) is byte-identical to the
|
||||
// historical fixed slice — Square's limit for /v2/cards, /v2/payments, and
|
||||
// /v2/refunds is 45 chars (64 only for /v2/terminals/checkouts).
|
||||
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
|
||||
body := sqCreateCardRequest{
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-2-len("card-")],
|
||||
SourceID: cardToken,
|
||||
Card: sqCardPayload{
|
||||
// reference_id is Square's free-form client reference, used to link
|
||||
@@ -1090,12 +1123,14 @@ func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResul
|
||||
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
||||
// Deterministic idempotency key derived from the email (not time-based)
|
||||
// so retries with the same email don't create duplicate customers. SHA-256
|
||||
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
||||
// Square's idempotency-key limit is 45 chars for /v2/cards, /v2/payments,
|
||||
// and /v2/refunds (64 only for /v2/terminals/checkouts).
|
||||
// prevents recovering the email from the key. The 35-hex tail (1-char
|
||||
// margin: 9 prefix chars + 35 hex = 44, under the 45-char limit via
|
||||
// MaxIdempotencyKeyLength) is byte-identical to the historical fixed slice —
|
||||
// Square's limit for /v2/cards, /v2/payments, and /v2/refunds is 45 chars
|
||||
// (64 only for /v2/terminals/checkouts).
|
||||
ikHash := sha256.Sum256([]byte(email))
|
||||
body := sqCreateCustomerRequest{
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
||||
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:MaxIdempotencyKeyLength-1-len("customer-")],
|
||||
EmailAddress: email,
|
||||
GivenName: name,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user