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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user