fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs

Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 78e6d00dc5
commit 6d82535780
60 changed files with 6608 additions and 801 deletions
+151 -13
View File
@@ -135,7 +135,9 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
respBody = respBody[:maxResponseBody]
}
if resp.StatusCode >= 300 {
var errResp struct{ Errors []SquareError `json:"errors"` }
var errResp struct {
Errors []SquareError `json:"errors"`
}
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
se := errResp.Errors[0]
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field)
@@ -212,6 +214,11 @@ type sqCreatePaymentRequest struct {
TipMoney *sqMoney `json:"tip_money,omitempty"`
VerificationToken string `json:"verification_token,omitempty"`
BuyerEmailAddress string `json:"buyer_email_address,omitempty"`
// CustomerDetails carries customer_initiated so Square classifies the
// charge as cardholder-initiated (SCA applies) rather than defaulting to a
// merchant-initiated classification. Online card entry is always
// cardholder-initiated in this app, so the flag is sent as true when set.
CustomerDetails *CreateCustomerDetails `json:"customer_details,omitempty"`
}
type sqCreatePaymentResponse struct {
@@ -498,6 +505,7 @@ func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymen
Note: req.Note,
VerificationToken: req.VerificationToken,
BuyerEmailAddress: req.BuyerEmail,
CustomerDetails: req.CustomerDetails,
}
if req.TipMoney != nil {
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
@@ -614,6 +622,12 @@ func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentR
// A replay body missing fields the original charge carried would return
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
// 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.
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
var req CreatePaymentReq
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
@@ -687,7 +701,9 @@ func (e *squareAPIError) Unwrap() error { return e.err }
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
// Square's error response body. It returns "" for non-Square errors so callers
// can classify charge failures structurally instead of substring-matching the
// message.
// message. This is the exported Code accessor for the error type (a direct
// `(*SquareError).Code()` method is impossible: SquareError already declares a
// field named Code, and Go forbids a method colliding with a struct field).
func ErrorCode(err error) string {
var sqErr *squareAPIError
if errors.As(err, &sqErr) {
@@ -719,7 +735,11 @@ func ErrorStatusCode(err error) int {
}
// ErrorCategory returns the Square error Category carried by err when err (or
// any error it wraps) is a *squareAPIError, and "" otherwise.
// any error it wraps) is a *squareAPIError, and "" otherwise. This is the
// exported Category accessor for the error type (a direct
// `(*SquareError).Category()` method is impossible: SquareError already
// declares a field named Category, and Go forbids a method colliding with a
// struct field).
func ErrorCategory(err error) string {
var sqErr *squareAPIError
if errors.As(err, &sqErr) {
@@ -754,17 +774,65 @@ func IsNotFound(err error) bool {
return strings.Contains(err.Error(), "HTTP 404")
}
// 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).
var definitivePaymentCodes = map[string]bool{
"CARD_DECLINED": true,
"CARD_EXPIRED": true,
"INVALID_EXPIRATION": true,
"INVALID_EXPIRATION_DATE": true,
"CARD_NOT_SUPPORTED": true,
"VERIFY_CVV_FAILURE": true,
"AVS_FAILURE": true,
"PAYMENT_CARD_DECLINED": true,
"GENERIC_DECLINE": true,
"INSUFFICIENT_FUNDS": true,
"ADDRESS_VERIFICATION_FAILURE": true,
"TRANSACTION_LIMIT": 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,
"VERIFICATION_TOKEN_EXPIRED": true,
"VERIFICATION_TOKEN_INVALID": true,
"CVV_VERIFICATION_REQUIRED": true,
"ADDRESS_VERIFICATION_REQUIRED": true,
"MISSING_PIN": true,
"MISSING_VERIFICATION_TOKEN": true,
}
// IsDefinitivePaymentError reports whether err is a definitive CreatePayment
// rejection (declined card, expired source, or an SCA/verification failure the
// buyer must resolve) rather than an ambiguous transport/server error. Handlers
// use this to avoid retrying a request that can never succeed as-is.
func IsDefinitivePaymentError(err error) bool {
return definitivePaymentCodes[ErrorCode(err)]
}
// Definitive Square refund rejection codes — the refund was declined and can
// never succeed, so retrying is pointless and the refund record should be
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
// callers leave the refund 'pending' for a scheduler retry. Codes match
// Square's documented Refunds error list (REFUND_DECLINED, REFUND_AMOUNT_INVALID,
// PAYMENT_NOT_REFUNDABLE); note PAYMENT_ALREADY_REFUNDED and
// REFUND_ALREADY_PENDING are intentionally absent — money is in flight or has
// moved, so they map to ErrRefundAlreadyProcessed instead of ErrRefundDeclined.
// PAYMENT_NOT_REFUNDABLE). REFUND_AMOUNT_INVALID is special: Square returns it
// BOTH for a genuinely invalid refund amount AND for an already-refunded
// payment, so refundPaymentHTTP reconciles it via PaymentWasRefunded before
// classifying (an existing refund → ErrRefundAlreadyProcessed, otherwise
// ErrRefundDeclined). REFUND_ALREADY_PENDING maps to ErrRefundAlreadyProcessed
// (money in flight); PAYMENT_ALREADY_REFUNDED is no longer emitted by Square
// but is kept as a defensive fallback for the same outcome.
var definitiveRefundCodes = map[string]bool{
"REFUND_DECLINED": true,
"REFUND_AMOUNT_INVALID": true,
"REFUND_DECLINED": true,
"REFUND_AMOUNT_INVALID": true,
"PAYMENT_NOT_REFUNDABLE": true,
}
@@ -782,17 +850,87 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *
var resp sqRefundPaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/refunds", body, &resp); err != nil {
var sqErr *squareAPIError
if errors.As(err, &sqErr) && definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if errors.As(err, &sqErr) && (sqErr.Code == "PAYMENT_ALREADY_REFUNDED" || sqErr.Code == "REFUND_ALREADY_PENDING") {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
if errors.As(err, &sqErr) {
switch sqErr.Code {
case "PAYMENT_ALREADY_REFUNDED", "REFUND_ALREADY_PENDING":
// Money is in flight or has already moved at Square — never
// mark 'failed' (that would let the guard over-refund).
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
case "REFUND_AMOUNT_INVALID":
// Square returns REFUND_AMOUNT_INVALID both for a genuinely
// invalid refund amount AND for an already-refunded payment
// (Square no longer emits PAYMENT_ALREADY_REFUNDED). Reconcile
// against the refund list to tell the two apart: money already
// moved → ErrRefundAlreadyProcessed (resolve 'completed');
// nothing moved → ErrRefundDeclined (mark 'failed', never
// retry). The reconciliation is amount-aware: only an EXACT-
// amount COMPLETED refund proves THIS requested amount already
// moved. A smaller partial refund does NOT cover the requested
// amount — resolving the row 'completed' against a partial
// refund would claim the full amount was returned when only
// part of it was, permanently blocking the remaining refund
// (the over-refund guard excludes completed rows). If the
// reconciliation itself fails, return the error unwrapped so
// the caller keeps the refund pending rather than making a
// money decision on partial data.
exactRefund, rErr := paymentRefundedExactlyWithClient(ctx, req.PaymentID, req.Amount, hc)
if rErr != nil {
return nil, rErr
}
if exactRefund {
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, err)
}
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
if definitiveRefundCodes[sqErr.Code] {
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, err)
}
}
return nil, err
}
return refundFromSquare(&resp.Refund), nil
}
// 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).
func PaymentWasRefunded(ctx context.Context, paymentID string) (bool, error) {
return paymentWasRefundedWithClient(ctx, paymentID, newHTTPClient())
}
func paymentWasRefundedWithClient(ctx context.Context, paymentID string, hc *httpClient) (bool, error) {
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
if err != nil {
return false, err
}
for _, r := range refunds {
switch r.Status {
case "COMPLETED", "APPROVED", "PENDING":
return true, nil
}
}
return false, nil
}
// paymentRefundedExactlyWithClient reports whether Square holds a COMPLETED
// refund for the EXACT amount requested. Unlike paymentWasRefundedWithClient
// (any refund counts), an exact-match is required so a REFUND_AMOUNT_INVALID
// rejection can only resolve to "already refunded" when THIS requested amount
// provably moved — a partial refund does not cover it.
func paymentRefundedExactlyWithClient(ctx context.Context, paymentID string, amount int64, hc *httpClient) (bool, error) {
refunds, err := listRefundsHTTPWithClient(ctx, paymentID, time.Time{}, hc)
if err != nil {
return false, err
}
for _, r := range refunds {
if r.Status == "COMPLETED" && r.Amount == amount {
return true, nil
}
}
return false, nil
}
func listRefundsHTTP(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
return listRefundsHTTPWithClient(ctx, paymentID, beginTime, newHTTPClient())
}