PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges; a merchant-side 2FA check cannot legally substitute for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent). - payments/twofa.go: the homegrown 2FA fallback for token-less saved-card charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only: a non-empty Square verification_token (charge surfaces, token forwarded to Square) skips the gate; anything else is refused 402 verification_required. enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs). - New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces where the token IS forwarded to Square (charge — Square validates it) from card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token must NOT skip the save gate, auth-F1). - SCA tokenize-result wire contract (C1): a saved card charged with a fresh one-time tokenize-result sends the token as the charge SOURCE (new_card_token -> source_id) alongside saved_card_id, never a separate verification_token. resolveChargeSource resolves the saved-card branch FIRST (customer from the card row, token as source) so combined token+card requests are SCA-clean. - C6 consent fields (consent_version / consent_accepted) added to the booking/ tip/till/gift-card charge requests, enforced server-side before any fallback charge could reach Square and recorded on the 2fa_fallback_charge audit row; logVerificationTokenProvenance traces minted tokens to their charge. - user 2FA issuance gate refactored into pure build-agnostic functions (twoFAPepperConfigured / twoFADeliveryChannelConfigured / twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and .env.example entry removed; startup posture notes updated. - Test coverage: fail-closed 2FA production gates (pepper/delivery), token validation on save vs charge surfaces, completion idempotency, idempotency key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
152 lines
7.8 KiB
Go
152 lines
7.8 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
)
|
|
|
|
// roundingEpsilon is the "effectively zero" guard for pound-denominated
|
|
// payment splits (0.004 = 0.4 pence). Amounts at or below this threshold —
|
|
// pure float64 rounding residue from dividing pence by 100 — are treated as
|
|
// zero so a sub-penny slice never becomes a phantom payment row. Single
|
|
// shared constant so the split builders and the cash/gift-card terminal
|
|
// branches can never drift on the threshold.
|
|
const roundingEpsilon = 0.004
|
|
|
|
// SquareRefundStatusToLocal maps Square's refund status to the local refunds
|
|
// status enum, returning a (localStatus, terminal) pair. Square's PaymentRefund
|
|
// states are PENDING, APPROVED, COMPLETED, CANCELED, FAILED and REJECTED
|
|
// (developer.squareup.com/reference/square/objects/PaymentRefund). COMPLETED
|
|
// and APPROVED are terminal-completed — APPROVED explicitly, because the
|
|
// synchronous refund handlers resolve a returned APPROVED to 'completed' and
|
|
// that behaviour must not be lost. FAILED/REJECTED are terminal-failed (Square
|
|
// declined the refund and it must be surfaced as a definitive local failure).
|
|
// Everything else (PENDING — money in flight, the sweep reconciles it later —
|
|
// CANCELED, or any unknown status) is NON-terminal: the caller leaves the row
|
|
// untouched rather than guessing. This is the single shared implementation for
|
|
// both the payments refund handlers and the webhooks package, so the two can
|
|
// never drift on the same Square status again.
|
|
func SquareRefundStatusToLocal(status string) (string, bool) {
|
|
switch status {
|
|
case "COMPLETED", "APPROVED":
|
|
return "completed", true
|
|
case "FAILED", "REJECTED":
|
|
return "failed", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// verificationRequiredCodes are Square CreatePayment error codes that mean the
|
|
// buyer must complete Strong Customer Authentication (3DS/SCA) before the
|
|
// charge can succeed: Square is demanding a fresh verification_token from the
|
|
// cardholder's buyer-verification flow. These are NOT plain declines — the
|
|
// frontend must surface the SCA challenge (the banking app / banking-app
|
|
// approval) and retry the charge with the resulting verification token. This
|
|
// is the SINGLE authoritative list of SCA-challenge codes; keep it in lock-step
|
|
// with the dev mock's simulated SCA toggle (square_dev.go).
|
|
var verificationRequiredCodes = map[string]bool{
|
|
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
|
|
"VERIFICATION_TOKEN_EXPIRED": true,
|
|
"VERIFICATION_TOKEN_INVALID": true,
|
|
"MISSING_VERIFICATION_TOKEN": true,
|
|
}
|
|
|
|
// isVerificationRequiredError reports whether a SquareClient.CreatePayment
|
|
// error is an SCA/verification-required rejection (the charge must be retried
|
|
// through the buyer-verification flow with a fresh verification_token) rather
|
|
// than a plain decline. Matches square.ErrorCode against the four SCA codes;
|
|
// CVV_VERIFICATION_REQUIRED / ADDRESS_VERIFICATION_REQUIRED are deliberately
|
|
// excluded — those mean re-entering card data, not a 3DS challenge.
|
|
func isVerificationRequiredError(err error) bool {
|
|
return verificationRequiredCodes[square.ErrorCode(err)]
|
|
}
|
|
|
|
// writeVerificationRequiredResponse responds 402 with the structured
|
|
// verification_required body the frontend keys on to trigger the SCA challenge
|
|
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
|
|
// structured-error pattern — mw.RespondJSON, code + human message). The message
|
|
// tells the buyer to approve the payment in their banking app. Used both by the
|
|
// charge-failure paths (Square returned an SCA-required code) and by the SCA-only
|
|
// saved-card gate, which refuses any token-less charge (the homegrown 2FA
|
|
// fallback was removed — a token-less charge is always refused here, never
|
|
// authorised by a 2FA code).
|
|
func writeVerificationRequiredResponse(w http.ResponseWriter) {
|
|
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
|
|
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
|
|
"code": "verification_required",
|
|
})
|
|
}
|
|
|
|
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
|
|
// HTTP status a payment handler should return:
|
|
//
|
|
// - 503 (Service Unavailable) for AMBIGUOUS failures: transport/network
|
|
// errors, Square 5xx responses, context cancellation/deadline, the
|
|
// retryable 4xx statuses 429 (rate limited), 408 (request timeout), and
|
|
// 425 (too early), and the structured error code IDEMPOTENCY_KEY_REUSED —
|
|
// the money state at Square is unknown, so the frontend should treat it as
|
|
// a retry (the pending record is resumed on a same-key retry). Square's own
|
|
// docs treat 429 as "retry later"; mapping it (or a timeout/early request)
|
|
// to 402 would mislabel a retryable condition as a permanent decline.
|
|
// - 402 (Payment Required) for DEFINITIVE declines: a structured Square
|
|
// error (squareAPIError) carrying any OTHER 4xx status (400/402/422 etc.)
|
|
// means Square positively rejected the charge (card declined/expired,
|
|
// AVS/CVV failure) — retrying with the same inputs cannot succeed.
|
|
//
|
|
// IDEMPOTENCY_KEY_REUSED (Loop B CRITICAL-ish, finding 1) is AMBIGUOUS, never
|
|
// a definitive decline: Square retains the key against the ORIGINAL request
|
|
// body, so the error means a PREVIOUS attempt under this key used a different
|
|
// body — the original charge may have LANDED at Square. Classifying it 402
|
|
// would make the frontend regenerate the idempotency key (the 402 branch
|
|
// clears the cached key) and issue a NEW charge under a fresh key — a double
|
|
// charge when the original landed. Classifying it 503 keeps the key: a
|
|
// same-key retry with the ORIGINAL body makes Square dedup to the original
|
|
// payment (no new charge), and a retry with a different body keeps getting
|
|
// IDEMPOTENCY_KEY_REUSED while the pending row stays rescuable by the sweep —
|
|
// which already treats IDEMPOTENCY_KEY_REUSED as ambiguous (sweep.go:1088,
|
|
// replayErrorProvesNoCharge in square_http_client.go:681). The check keys on
|
|
// the structured ErrorCode, not the HTTP status, because Square may surface it
|
|
// as 400 or 409 depending on the request shape.
|
|
//
|
|
// A nil error is never expected (callers only invoke this on the error path);
|
|
// it maps to 402 defensively. The dev mock returns plain errors for simulated
|
|
// failures, which classify as 503 (ambiguous) — correct for a mock standing in
|
|
// for an unreachable Square.
|
|
func chargeFailureStatus(err error) int {
|
|
if err == nil {
|
|
return http.StatusPaymentRequired
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return http.StatusServiceUnavailable
|
|
}
|
|
if square.ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
|
|
return http.StatusServiceUnavailable
|
|
}
|
|
status := square.ErrorStatusCode(err)
|
|
if status == 0 || status >= 500 {
|
|
return http.StatusServiceUnavailable
|
|
}
|
|
// Retryable/ambiguous 4xx carve-outs: 429 (RATE_LIMITED), 408 (request
|
|
// timeout), and 425 (too early) are not definitive declines — Square's
|
|
// docs tell clients to retry later. Classify them as 503 so the pending
|
|
// record stays resumable on a same-key retry instead of being labelled a
|
|
// permanent decline. True declines (400/402/422 etc.) fall through to 402.
|
|
if status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status == http.StatusTooEarly {
|
|
return http.StatusServiceUnavailable
|
|
}
|
|
if status >= 400 && status < 500 {
|
|
return http.StatusPaymentRequired
|
|
}
|
|
// Anything else (1xx/2xx/3xx — impossible in practice, but defensive) is
|
|
// AMBIGUOUS: the money state at Square is unknown, so the failure must be
|
|
// retryable. The default is deliberately 503, never 402 — a definitive
|
|
// decline classification on an ambiguous outcome would suppress the
|
|
// same-key retry that resumes the pending record.
|
|
return http.StatusServiceUnavailable
|
|
}
|