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:
@@ -55,6 +55,12 @@ func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
|
||||
// PaymentWasRefunded has ZERO production callers (grep across the repo
|
||||
// confirms the only users are this package's tests) and is kept on the
|
||||
// SquareClient interface solely so the dev mock's refund-reconciliation
|
||||
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set.
|
||||
// Production reconciliation uses the package-level
|
||||
// paymentRefundedExactlyWithClient inside refundPaymentHTTP instead.
|
||||
func (p *ProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
|
||||
}
|
||||
|
||||
@@ -188,6 +188,9 @@ func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) e
|
||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||
return refundPaymentHTTP(ctx, req)
|
||||
}
|
||||
// PaymentWasRefunded has ZERO production callers — kept only to satisfy the
|
||||
// SquareClient interface for the dev mock's refund-reconciliation parity
|
||||
// tests. Production reconciliation uses paymentRefundedExactlyWithClient.
|
||||
func (d *devProdClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
return PaymentWasRefunded(ctx, paymentID)
|
||||
}
|
||||
@@ -311,11 +314,13 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
}
|
||||
// Square's idempotency-key limit for POST /v2/payments is 45 characters
|
||||
// (64 only for /v2/terminals/checkouts). Real Square rejects an oversized
|
||||
// key with a 400 VALUE_TOO_LONG; the mock mirrors the rejection with the
|
||||
// same structured error so dev parity catches over-length keys (the real
|
||||
// client always derives ≤45-char keys, so this only fires on a caller bug).
|
||||
if len(req.IdempotencyKey) > 45 {
|
||||
// (64 only for /v2/terminals/checkouts) — MaxIdempotencyKeyLength
|
||||
// (square_http_client.go), the single source the payments package also
|
||||
// aliases. Real Square rejects an oversized key with a 400
|
||||
// VALUE_TOO_LONG; the mock mirrors the rejection with the same structured
|
||||
// error so dev parity catches over-length keys (the real client always
|
||||
// derives ≤45-char keys, so this only fires on a caller bug).
|
||||
if len(req.IdempotencyKey) > MaxIdempotencyKeyLength {
|
||||
return nil, &squareAPIError{
|
||||
Code: "VALUE_TOO_LONG",
|
||||
Detail: "idempotency_key must be 45 characters or fewer",
|
||||
@@ -872,7 +877,10 @@ func (m *MockClient) RefundKeyCount() int {
|
||||
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
|
||||
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
|
||||
// status set the real client's paymentWasRefundedWithClient uses so handler
|
||||
// reconciliation behaves identically in dev/mock and production.
|
||||
// reconciliation behaves identically in dev/mock and production. TEST-ONLY on
|
||||
// the SquareClient interface (no production callers — reconciliation uses the
|
||||
// package-level paymentRefundedExactlyWithClient); kept so this mock satisfies
|
||||
// the interface and its refund-status parity tests can exercise the set.
|
||||
func (m *MockClient) PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ var ErrReplayKeyNotRetained = errors.New("square: no payment under idempotency k
|
||||
// CreatePaymentReq maps to Square's CreatePayment endpoint (POST /v2/payments).
|
||||
// Square API reference: https://developer.squareup.com/reference/square/payments-api/create-payment
|
||||
type CreatePaymentReq struct {
|
||||
Amount int64 // in pence (GBP cents)
|
||||
Amount int64 // in pence
|
||||
Currency string // "GBP"
|
||||
SourceID string // card token ("cnon:xxx" nonce) or card-on-file ID
|
||||
IdempotencyKey string
|
||||
@@ -217,13 +217,16 @@ type SquareClient interface {
|
||||
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
||||
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
||||
// PaymentWasRefunded reports whether Square holds any refund for the
|
||||
// payment (status COMPLETED, APPROVED, or PENDING). It is the
|
||||
// reconciliation source for deciding whether a REFUND_AMOUNT_INVALID
|
||||
// rejection means "already refunded" (money has already moved) vs "amount
|
||||
// invalid" (nothing happened). On the interface (not just the package
|
||||
// function) so handlers can reconcile through the injected client — a
|
||||
// package-level call constructs a real HTTP client even in dev/mock
|
||||
// builds, making the dev path dead code and untestable.
|
||||
// payment (status COMPLETED, APPROVED, or PENDING). TEST-ONLY: it has ZERO
|
||||
// production callers (grep across the repo confirms the only users are
|
||||
// this package's tests); the production reconciliation that decides
|
||||
// whether a REFUND_AMOUNT_INVALID rejection means "already refunded"
|
||||
// (money has already moved) vs "amount invalid" (nothing happened) uses
|
||||
// the package-level paymentRefundedExactlyWithClient inside
|
||||
// refundPaymentHTTP (square_http_client.go), not this interface method. It
|
||||
// is kept on the interface solely so the dev mock's refund-reconciliation
|
||||
// parity tests can exercise the COMPLETED/APPROVED/PENDING status set. Do
|
||||
// not add production callers without re-examining the interface surface.
|
||||
PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error)
|
||||
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
|
||||
// GetCardsOnFile returns the enabled cards on file for a user. TEST-ONLY:
|
||||
|
||||
Reference in New Issue
Block a user