feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.
BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
(SCA primary); no token => 2FA fallback requires delivery channel + consume +
insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
{sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
customer_initiated=false (merchant-initiated, no SCA, no liability shift);
customer-initiated online flows keep true
FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
(booking/tip/till/gift-card/admin); 'waiting for approval in your banking
app' state on admin surfaces; 2FA backup-only UX in the shared composable
MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
grandfathered; frontend helper tests
DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).
26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
This commit is contained in:
@@ -6,8 +6,48 @@ import (
|
||||
"net/http"
|
||||
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
)
|
||||
|
||||
// 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 2FA
|
||||
// gate when the SCA-only posture has no fallback for a token-less charge.
|
||||
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:
|
||||
//
|
||||
|
||||
@@ -504,7 +504,67 @@ func TestCreatePaymentMethod_AmbiguousCardSaveFailure_500(t *testing.T) {
|
||||
// H4 — 2FA gate on CreatePaymentMethod and BuyGiftCard(SaveCard)
|
||||
// =============================================================================
|
||||
|
||||
// TestTwoFactorEnforced_CreatePaymentMethod_Blocked_403 verifies the H4 gate on
|
||||
// TestIsVerificationRequiredError pins the SCA-challenge classification: the
|
||||
// four buyer-verification codes must classify as verification-required (so the
|
||||
// handlers surface the structured 402 body the frontend keys on to trigger the
|
||||
// 3DS challenge), while a plain decline and CVV re-entry requests must not.
|
||||
func TestIsVerificationRequiredError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"CARD_DECLINED_VERIFICATION_REQUIRED → true", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"VERIFICATION_TOKEN_EXPIRED → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_EXPIRED", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"VERIFICATION_TOKEN_INVALID → true", structuredSquareErrorFull(t, http.StatusBadRequest, "VERIFICATION_TOKEN_INVALID", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"MISSING_VERIFICATION_TOKEN → true", structuredSquareErrorFull(t, http.StatusBadRequest, "MISSING_VERIFICATION_TOKEN", "PAYMENT_METHOD_ERROR"), true},
|
||||
{"CARD_DECLINED plain decline → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"CVV_VERIFICATION_REQUIRED (re-entry, not a 3DS challenge) → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "CVV_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"INSUFFICIENT_FUNDS → false", structuredSquareErrorFull(t, http.StatusPaymentRequired, "INSUFFICIENT_FUNDS", "PAYMENT_METHOD_ERROR"), false},
|
||||
{"plain transport error → false", errors.New("network error: connection reset by peer"), false},
|
||||
{"nil → false", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isVerificationRequiredError(tt.err); got != tt.want {
|
||||
t.Errorf("isVerificationRequiredError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_SCARequired_ReturnsStructured402 verifies the SCA-required
|
||||
// surfacing end to end on a till charge: a Square
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED failure (the buyer must complete 3DS)
|
||||
// returns 402 with the structured verification_required body — the frontend
|
||||
// triggers the challenge — instead of the plain-text "Payment failed".
|
||||
func TestCreateTillSale_SCARequired_ReturnsStructured402(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
require.NoError(t, err)
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
origClient := SquareClient
|
||||
SquareClient = &tillChargeFailureClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorFull(t, http.StatusPaymentRequired, "CARD_DECLINED_VERIFICATION_REQUIRED", "PAYMENT_METHOD_ERROR")}
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
req := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "online_square",
|
||||
CardToken: "cnon:till-sca-required",
|
||||
IdempotencyKey: "till-sca-required-key",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(CreateTillSale, "POST", "/api/admin/till/sale", req, adminToken, ctx)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, "verification_required", body["code"], "an SCA-required charge must surface the structured verification_required code")
|
||||
require.Contains(t, body["error"], "card issuer requires verification")
|
||||
}
|
||||
// the dedicated add-card endpoint: with REQUIRE_2FA enforced and the user NOT
|
||||
// having completed 2FA setup, persisting a card is blocked with 403 and no card
|
||||
// row is created — the save-card endpoint is not an un-gated side door.
|
||||
|
||||
@@ -1477,9 +1477,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
// (reusePendingID != "") verifies WITHOUT consuming: the code was re-issued
|
||||
// for exactly this retry and the completed-charge transaction below
|
||||
// (ConsumePendingCode) burns it on terminal success, so a retry that fails
|
||||
// again keeps its code for one more attempt.
|
||||
// again keeps its code for one more attempt. A charge carrying a Square
|
||||
// verification_token (SCA performed) skips the gate; a token-less charge
|
||||
// falls back to 2FA and twoFAFallbackUsed is set for the charge-success
|
||||
// audit.
|
||||
giftCardVerificationToken := ""
|
||||
if req.VerificationToken != nil {
|
||||
giftCardVerificationToken = *req.VerificationToken
|
||||
}
|
||||
twoFAFallbackUsed := false
|
||||
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
|
||||
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, reusePendingID == "") {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode, giftCardVerificationToken, reusePendingID == "")
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1645,11 +1655,6 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||||
}
|
||||
|
||||
var verificationToken string
|
||||
if req.VerificationToken != nil {
|
||||
verificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
@@ -1661,7 +1666,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card Purchase",
|
||||
BuyerEmail: buyerEmail,
|
||||
VerificationToken: verificationToken,
|
||||
VerificationToken: giftCardVerificationToken,
|
||||
}
|
||||
// C3: a card-on-file (ccof) charge — paying with an existing saved card or
|
||||
// saving a new card during this purchase — is customer-initiated: Square
|
||||
@@ -1704,6 +1709,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
reissueTwoFACodeAfterFailedCharge(ctx, userID)
|
||||
}
|
||||
// Payment record intentionally left as 'pending' for manual retry.
|
||||
// SCA-required failures must surface the structured verification_required
|
||||
// body so the frontend triggers the 3DS challenge, not a plain decline.
|
||||
if isVerificationRequiredError(err) {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||
return
|
||||
}
|
||||
@@ -1867,6 +1878,14 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The 2FA BACKUP authorized this token-less saved-card gift-card purchase
|
||||
// (SCA was unavailable) — record the strict fallback audit row AFTER the
|
||||
// money transaction commits (a failed audit write must never roll back a
|
||||
// completed charge). The actor is the customer's own userID.
|
||||
if twoFAFallbackUsed {
|
||||
insertTwoFAFallbackAudit(ctx, userID, userID, paymentResult.CardLast4, buyPaymentID, "gift-card purchase authorized via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "success",
|
||||
|
||||
@@ -67,6 +67,30 @@ func insertAdminAuditCharge(ctx context.Context, adminID, targetUserID, action s
|
||||
}
|
||||
}
|
||||
|
||||
// insertTwoFAFallbackAudit records that a saved-card charge was authorized by
|
||||
// the homegrown 2FA BACKUP because SCA was unavailable (the charge carried no
|
||||
// Square verification_token). It is the strict-audit half of the SCA-primary /
|
||||
// 2FA-backup decision model: every 2FA-fallback authorization of a saved-card
|
||||
// charge must land an admin_audit_log row (action_type '2fa_fallback_charge',
|
||||
// details {sca_performed:false, fallback_reason:"verification_unavailable"}) so
|
||||
// the operator can distinguish SCA-authorized charges from fallback-authorized
|
||||
// ones. Mirrors insertAdminAuditCharge's best-effort, own-transaction,
|
||||
// non-fatal failure handling (a failed audit write can never abort a completed
|
||||
// charge). For customer-initiated online charges the actor (adminID) is the
|
||||
// customer's own userID; for admin surfaces it is the admin from request
|
||||
// context — the caller passes accordingly. cardLast4 and referenceID are filled
|
||||
// by the caller at charge success (paymentResult.CardLast4, booking/till/payment
|
||||
// id), where they are actually known.
|
||||
func insertTwoFAFallbackAudit(ctx context.Context, adminID, userID, cardLast4, referenceID, notes string) {
|
||||
insertAdminAuditCharge(ctx, adminID, userID, "2fa_fallback_charge", map[string]any{
|
||||
"sca_performed": false,
|
||||
"fallback_reason": "verification_unavailable",
|
||||
"card_last4": cardLast4,
|
||||
"reference_id": referenceID,
|
||||
"notes": notes,
|
||||
})
|
||||
}
|
||||
|
||||
type CreateTerminalPaymentRequest struct {
|
||||
Amount int64 `json:"amount" validate:"required,gt=0"`
|
||||
PaymentType string `json:"payment_type" validate:"required"`
|
||||
@@ -82,6 +106,12 @@ type CreateTerminalPaymentRequest struct {
|
||||
// enforced environment charges a saved card only when this matches the
|
||||
// customer's pending code; the operator relays it from the [2FA] log/email.
|
||||
VerificationCode string `json:"verification_code,omitempty"`
|
||||
// verification_token: Square 3DS/SCA verification token returned by the
|
||||
// frontend's buyer-verification flow (tokenizeWithVerification). When a
|
||||
// saved-card charge carries one, SCA has been performed by the issuer and
|
||||
// the homegrown 2FA gate is SKIPPED (SCA is primary). Forwarded verbatim
|
||||
// to Square on the CreatePaymentReq.
|
||||
VerificationToken *string `json:"verification_token,omitempty"`
|
||||
// idempotency_key: optional client-generated per-attempt UUID for saved-card
|
||||
// charges. The frontend generates one per distinct charge and reuses it
|
||||
// across retries of the SAME charge, so two DISTINCT identical charges on
|
||||
@@ -407,6 +437,12 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
|
||||
log.Printf("Failed to process request: %v", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// A3 (mirror of CreateBookingPayment at handlers.go:1596): tips have a
|
||||
// dedicated endpoint (POST /api/bookings/{id}/tip, CreateTipPayment) which
|
||||
// enforces the M4 "tips only after the service starts" gate, and the
|
||||
@@ -801,6 +837,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// SCA verification token (if any) — extracted once, used both by the
|
||||
// 2FA gate below (a present token skips the gate: SCA-primary) and
|
||||
// forwarded to Square on the CreatePaymentReq.
|
||||
terminalVerificationToken := ""
|
||||
if req.VerificationToken != nil {
|
||||
terminalVerificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
// Resolve the saved-card Square source for the booking's user (the
|
||||
// card's owner, not the admin) — shared new-card-vs-saved-card
|
||||
// resolution, see resolveChargeSource for the R6 rationale.
|
||||
@@ -957,8 +1001,30 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// its code for one more attempt (the completed-charge transaction
|
||||
// consumes it on terminal success).
|
||||
reusePendingRecord := paymentID != ""
|
||||
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, !reusePendingRecord) {
|
||||
return
|
||||
// SCA-primary / 2FA-backup gate (C5): charging the customer's SAVED card
|
||||
// requires authorization when the feature is enforced. Gate on the
|
||||
// card's owner — the booking's user, not the admin. A charge carrying a
|
||||
// Square verification_token (SCA performed) passes without 2FA; a
|
||||
// token-less charge falls back to the customer's 2FA code (single-use
|
||||
// via consume) and the caller records a strict fallback audit row on
|
||||
// the charge's success. New-card/terminal paths are not gated. Runs
|
||||
// AFTER the idempotency dedup/reuse switch above: a same-key retry of
|
||||
// an already-completed payment short-circuits there and returns the
|
||||
// existing result WITHOUT demanding a fresh code — no new money moves,
|
||||
// so no new authorization is needed. consume=!reusePendingRecord
|
||||
// (finding 4): a FRESH charge verifies WITH consumption — the code is
|
||||
// single-use at the gate, closing the TOCTOU where a verified-but-
|
||||
// unconsumed code could authorize a second charge — and a pending-reuse
|
||||
// retry verifies WITHOUT consuming, so a retry that fails again keeps
|
||||
// its code for one more attempt (the completed-charge transaction
|
||||
// consumes it on terminal success).
|
||||
twoFAFallbackUsed := false
|
||||
if bookingUserID.Valid {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode, terminalVerificationToken, !reusePendingRecord)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// B13: the pre-charge discount SET for the post-charge apply-time
|
||||
@@ -1036,18 +1102,23 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
CustomerID: savedCardCustomerID,
|
||||
IdempotencyKey: scKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
BuyerEmail: buyerEmail,
|
||||
// C3: a saved-card (ccof) charge is customer-initiated — Square
|
||||
// requires customer_details on stored-credential payments, and
|
||||
// omitting it can fail or silently misclassify the charge.
|
||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
||||
Amount: amount,
|
||||
Currency: "GBP",
|
||||
SourceID: sourceID,
|
||||
CustomerID: savedCardCustomerID,
|
||||
IdempotencyKey: scKey,
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
BuyerEmail: buyerEmail,
|
||||
VerificationToken: terminalVerificationToken,
|
||||
// MIT (merchant-initiated): the admin charging the customer's SAVED
|
||||
// card (admin "Charge Saved Card") is a merchant-initiated stored-
|
||||
// credential charge — NOT the cardholder. customer_initiated=false
|
||||
// classifies it MIT for Square: no SCA is demanded and no liability
|
||||
// shift applies, which is the correct treatment for an operator-
|
||||
// initiated charge (any issuer challenge is handled via
|
||||
// verification_token when the frontend performs one).
|
||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the charge
|
||||
// with an IDENTICAL body under the same key — Square compares the whole
|
||||
@@ -1077,6 +1148,14 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
if bookingUserID.Valid {
|
||||
reissueTwoFACodeAfterFailedCharge(r.Context(), bookingUserID.String)
|
||||
}
|
||||
// SCA-required failures (Square demands buyer verification) must
|
||||
// surface the structured verification_required body so the frontend
|
||||
// triggers the 3DS challenge instead of treating the payment as a
|
||||
// plain decline.
|
||||
if isVerificationRequiredError(err) {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||
return
|
||||
}
|
||||
@@ -1205,6 +1284,11 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
|
||||
"card_last4": paymentResult.CardLast4,
|
||||
"square_payment_id": paymentResult.SquarePayID,
|
||||
})
|
||||
// The 2FA BACKUP authorized this token-less saved-card charge
|
||||
// (SCA was unavailable) — record the strict fallback audit row.
|
||||
if twoFAFallbackUsed {
|
||||
insertTwoFAFallbackAudit(r.Context(), adminID, bookingUserID.String, paymentResult.CardLast4, bookingID, "admin saved-card charge authorized via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
}
|
||||
|
||||
// F6: a fully-paid saved-card charge completes the booking exactly like
|
||||
@@ -2074,8 +2158,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// re-rejected as "expired". Pending-reuse and fresh paths still gate — a
|
||||
// new charge may move at Square. The gate also runs before
|
||||
// resolveChargeSource below, so an un-2FA'd request never persists a card.
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
// SCA-primary: a request carrying a Square verification_token (SCA
|
||||
// performed) skips the gate; a token-less card-save/charge falls back to
|
||||
// the customer's 2FA code, and twoFAFallbackUsed records that the 2FA
|
||||
// BACKUP authorized the operation (the caller audits the charge on success).
|
||||
twoFAFallbackUsed := false
|
||||
bookingVerificationToken := ""
|
||||
if req.VerificationToken != nil {
|
||||
bookingVerificationToken = *req.VerificationToken
|
||||
}
|
||||
if req.SaveCard {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, true)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// After the idempotency check (which handles same-key retries), verify
|
||||
@@ -2258,9 +2355,14 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// re-issues a fresh code (reissueTwoFACodeAfterFailedCharge). A pending-reuse
|
||||
// retry verifies WITHOUT consuming: the code was re-issued for exactly this
|
||||
// retry and the completed-charge transaction consumes it on terminal success,
|
||||
// so a retry that fails again keeps its code for one more attempt.
|
||||
// so a retry that fails again keeps its code for one more attempt. A charge
|
||||
// carrying a Square verification_token (SCA performed) skips the gate; a
|
||||
// token-less charge falls back to 2FA and twoFAFallbackUsed is set for the
|
||||
// charge-success audit.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, !reusePendingRecord) {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, bookingVerificationToken, !reusePendingRecord)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2348,11 +2450,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// Step 2: DB transaction committed — safe to call Square now. If Square
|
||||
// fails, the record stays 'pending' and a same-key retry reuses it.
|
||||
|
||||
var verificationToken string
|
||||
if req.VerificationToken != nil {
|
||||
verificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: chargeAmount,
|
||||
Currency: "GBP",
|
||||
@@ -2362,7 +2459,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
ReferenceID: bookingID,
|
||||
Note: req.PaymentType,
|
||||
BuyerEmail: bookingBuyerEmail,
|
||||
VerificationToken: verificationToken,
|
||||
VerificationToken: bookingVerificationToken,
|
||||
// C3: every online charge here is cardholder-initiated — a saved-card
|
||||
// (ccof) source MUST carry customer_details for Square's stored-
|
||||
// credential rules, and a new-card (cnon) nonce is entered by the
|
||||
@@ -2399,6 +2496,12 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
|
||||
}
|
||||
// SCA-required failures must surface the structured verification_required
|
||||
// body so the frontend triggers the 3DS challenge, not a plain decline.
|
||||
if isVerificationRequiredError(err) {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||
return
|
||||
}
|
||||
@@ -2630,6 +2733,15 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The 2FA BACKUP authorized this token-less saved-card charge (SCA was
|
||||
// unavailable) — record the strict fallback audit row AFTER the money
|
||||
// transaction commits (a failed audit write must never roll back a
|
||||
// completed charge). The actor is the customer's own userID
|
||||
// (customer-initiated online charge).
|
||||
if twoFAFallbackUsed {
|
||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card charge authorized via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
|
||||
// B13: a campaign was exhausted between the preview and the apply-time
|
||||
// re-check. The charge already succeeded at Square and the payment record
|
||||
// is committed, so the customer's promised discount must not silently
|
||||
@@ -3136,9 +3248,16 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
// exactly what the PSD2 SCA stand-in protects, so the dedicated save-card
|
||||
// endpoint must not be the un-gated side door. consume=true: saving a card
|
||||
// is a terminal operation with no downstream charge to attach consumption
|
||||
// to (MEDIUM-2).
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
// to (MEDIUM-2). This request type carries no verification_token field, so
|
||||
// the 2FA fallback is the only authorization path; a fallback success is
|
||||
// recorded by the strict audit below.
|
||||
twoFAFallbackUsed := false
|
||||
{
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, "", true)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
card, err := service.CreatePaymentMethodFromToken(r.Context(), userID, req.CardToken)
|
||||
@@ -3153,6 +3272,12 @@ func CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The 2FA BACKUP authorized persisting this card (no SCA was performed on
|
||||
// the add-card endpoint); the actor is the customer's own userID.
|
||||
if twoFAFallbackUsed && card != nil {
|
||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, card.Last4, "", "card persisted via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(card); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
}
|
||||
@@ -4239,9 +4364,20 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// consume=true: saving a card is a terminal operation (the card row is
|
||||
// created right here), so the verified code is single-use immediately —
|
||||
// unlike the saved-card CHARGE gate below, which defers consumption to the
|
||||
// charge's terminal success (MEDIUM-2).
|
||||
if req.SaveCard && !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, true) {
|
||||
return
|
||||
// charge's terminal success (MEDIUM-2). A request carrying a Square
|
||||
// verification_token (SCA performed) skips the gate; a token-less save falls
|
||||
// back to 2FA and twoFAFallbackUsed is set for the charge-success audit.
|
||||
twoFAFallbackUsed := false
|
||||
tipVerificationToken := ""
|
||||
if req.VerificationToken != nil {
|
||||
tipVerificationToken = *req.VerificationToken
|
||||
}
|
||||
if req.SaveCard {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, true)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateAmount(req.Amount); err != nil {
|
||||
@@ -4478,9 +4614,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
// unconsumed code could authorize a second charge — and a pending-reuse
|
||||
// retry verifies WITHOUT consuming, so a retry that fails again keeps its
|
||||
// code for one more attempt (the completed-charge transaction consumes it
|
||||
// on terminal success).
|
||||
// on terminal success). A charge carrying a Square verification_token (SCA
|
||||
// performed) skips the gate; a token-less charge falls back to 2FA and
|
||||
// twoFAFallbackUsed is set for the charge-success audit.
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
if !requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, !reusePendingRecord) {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, userID, req.VerificationCode, tipVerificationToken, !reusePendingRecord)
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -4552,11 +4692,6 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
|
||||
}
|
||||
|
||||
var verificationToken string
|
||||
if req.VerificationToken != nil {
|
||||
verificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: req.Amount,
|
||||
Currency: "GBP",
|
||||
@@ -4566,7 +4701,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
ReferenceID: bookingID,
|
||||
Note: "tip",
|
||||
BuyerEmail: buyerEmail,
|
||||
VerificationToken: verificationToken,
|
||||
VerificationToken: tipVerificationToken,
|
||||
// C3: the tip charge is cardholder-initiated whether it uses a saved
|
||||
// card (ccof — customer_details required) or a freshly entered card
|
||||
// (cnon — buyer present), so the flag is true either way.
|
||||
@@ -4600,6 +4735,12 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
if req.CardID != nil && *req.CardID != "" {
|
||||
reissueTwoFACodeAfterFailedCharge(r.Context(), userID)
|
||||
}
|
||||
// SCA-required failures must surface the structured verification_required
|
||||
// body so the frontend triggers the 3DS challenge, not a plain decline.
|
||||
if isVerificationRequiredError(err) {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(err))
|
||||
return
|
||||
}
|
||||
@@ -4682,6 +4823,13 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The 2FA BACKUP authorized this token-less saved-card tip charge (SCA was
|
||||
// unavailable) — record the strict fallback audit row. The actor is the
|
||||
// customer's own userID (customer-initiated online charge).
|
||||
if twoFAFallbackUsed {
|
||||
insertTwoFAFallbackAudit(r.Context(), userID, userID, paymentResult.CardLast4, bookingID, "saved-card tip charge authorized via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(PaymentResponse{
|
||||
ID: paymentID,
|
||||
BookingID: bookingID,
|
||||
|
||||
@@ -894,6 +894,19 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
// the post-charge 2FA consumption + audit after the Square call need it.
|
||||
var cardUserID sql.NullString
|
||||
|
||||
// twoFAFallbackUsed records whether the 2FA BACKUP authorized a token-less
|
||||
// saved-card charge (SCA unavailable). Hoisted: set in the saved_card case
|
||||
// below, consumed by the strict fallback audit after the Square call.
|
||||
twoFAFallbackUsed := false
|
||||
|
||||
// SCA verification token (if any) — hoisted so the 2FA gate in the
|
||||
// saved_card case (a present token skips the gate: SCA-primary) and the
|
||||
// CreatePaymentReq after the switch share one extraction.
|
||||
tillVerificationToken := ""
|
||||
if req.VerificationToken != nil {
|
||||
tillVerificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case "cash":
|
||||
saleStatus = "completed"
|
||||
@@ -963,9 +976,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
// WITHOUT consuming: the code was re-issued for exactly this retry and
|
||||
// the post-charge success path below (ConsumePendingCode) burns it on
|
||||
// terminal success, so a retry that fails again keeps its code for one
|
||||
// more attempt.
|
||||
if cardUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, existingPendingID == "") {
|
||||
return
|
||||
// more attempt. A charge carrying a Square verification_token (SCA
|
||||
// performed) skips the gate; a token-less charge falls back to 2FA and
|
||||
// twoFAFallbackUsed is set for the charge-success audit.
|
||||
if cardUserID.Valid {
|
||||
var gateOK bool
|
||||
gateOK, twoFAFallbackUsed = requireTwoFactorForCardAccess(w, r, service, cardUserID.String, req.VerificationCode, tillVerificationToken, existingPendingID == "")
|
||||
if !gateOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tillSquareSourceID = savedCardSqCardID
|
||||
@@ -1143,11 +1162,6 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
var paymentResult *square.PaymentResult
|
||||
var squareErr error
|
||||
|
||||
var verificationToken string
|
||||
if req.VerificationToken != nil {
|
||||
verificationToken = *req.VerificationToken
|
||||
}
|
||||
|
||||
if req.PaymentMethod == "saved_card" {
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: penceAmount,
|
||||
@@ -1157,14 +1171,16 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card " + req.Action,
|
||||
BuyerEmail: buyerEmail,
|
||||
// C3: a saved-card (ccof) charge is customer-initiated — Square
|
||||
// requires customer_details on stored-credential payments, and
|
||||
// omitting it can fail/quietly strip the charge.
|
||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: true},
|
||||
// MIT (merchant-initiated): the admin till charging a
|
||||
// customer's saved card is merchant-initiated, not
|
||||
// cardholder-initiated. customer_initiated=false classifies it
|
||||
// MIT for Square: no SCA is demanded and no liability shift
|
||||
// applies — correct for an operator-initiated gift-card top-up.
|
||||
CustomerDetails: &square.CreateCustomerDetails{CustomerInitiated: false},
|
||||
// Forward the 3DS/SCA verification token when the request
|
||||
// carries one (the online_square branch already did; the
|
||||
// saved-card branch did not).
|
||||
VerificationToken: verificationToken,
|
||||
VerificationToken: tillVerificationToken,
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the
|
||||
// charge with an IDENTICAL body under the same key — Square
|
||||
@@ -1212,7 +1228,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card " + req.Action,
|
||||
BuyerEmail: buyerEmail,
|
||||
VerificationToken: verificationToken,
|
||||
VerificationToken: tillVerificationToken,
|
||||
}
|
||||
// M1: store the verbatim request JSON so the sweep can replay the
|
||||
// charge with an IDENTICAL body under the same key — Square
|
||||
@@ -1268,6 +1284,13 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
// 503 so the pending sale stays resumable on a same-key retry
|
||||
// (M3). The clawback decision above stays keyed on
|
||||
// isDefinitiveChargeFailure, unchanged.
|
||||
// SCA-required failures must surface the structured
|
||||
// verification_required body so the frontend triggers the 3DS
|
||||
// challenge, not a plain decline.
|
||||
if isVerificationRequiredError(squareErr) {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Payment failed", chargeFailureStatus(squareErr))
|
||||
return
|
||||
}
|
||||
@@ -1317,6 +1340,11 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
"card_last4": paymentResult.CardLast4,
|
||||
"square_payment_id": paymentResult.SquarePayID,
|
||||
})
|
||||
// The 2FA BACKUP authorized this token-less saved-card till charge
|
||||
// (SCA was unavailable); the actor is the admin from context.
|
||||
if twoFAFallbackUsed {
|
||||
insertTwoFAFallbackAudit(ctx, adminID, cardUserID.String, paymentResult.CardLast4, tillSaleID, "admin till saved-card charge authorized via 2FA fallback (SCA unavailable)")
|
||||
}
|
||||
}
|
||||
|
||||
saleStatus = "completed"
|
||||
|
||||
@@ -89,12 +89,61 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
|
||||
return twofa.VerifyForUser(ctx, userID, code, consume)
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths
|
||||
// (PSD2 SCA stand-in until real SCA infra lands). It returns true when the
|
||||
// request may proceed:
|
||||
// twoFactorFallbackEnabled reports whether the homegrown 2FA may act as a
|
||||
// BACKUP authorization for a saved-card charge when SCA is unavailable (the
|
||||
// charge carries no Square verification_token). The parse is case-insensitive
|
||||
// and alias-tolerant (false/0/off/no) — a value like "False" or "OFF" never
|
||||
// silently leaves the fallback ON. Any other value — including empty and
|
||||
// unknown — keeps the fallback enabled (the shipped default). It is the
|
||||
// TWO_FACTOR_FALLBACK policy switch read at startup by main.go and exposed via
|
||||
// PaymentService.TwoFactorFallbackEnabled.
|
||||
func twoFactorFallbackEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("TWO_FACTOR_FALLBACK"))) {
|
||||
case "false", "0", "off", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// TwoFactorFallbackEnabled is the exported form of twoFactorFallbackEnabled, so
|
||||
// main.go can log the SCA-primary/2FA-backup posture at startup without
|
||||
// re-implementing the env logic.
|
||||
func (s *PaymentService) TwoFactorFallbackEnabled() bool {
|
||||
return twoFactorFallbackEnabled()
|
||||
}
|
||||
|
||||
// requireTwoFactorForCardAccess gates the saved-card online payment paths under
|
||||
// the SCA-primary / 2FA-backup decision model. It returns (allowed, fallbackUsed):
|
||||
// allowed is true when the request may proceed; fallbackUsed is true when the
|
||||
// authorization was granted by the homegrown 2FA BACKUP (SCA was unavailable and
|
||||
// the customer's 2FA code verified) — the caller must then write a strict
|
||||
// insertTwoFAFallbackAudit row for the charge.
|
||||
//
|
||||
// - 2FA is not enforced (dev/mock), OR
|
||||
// - the user has completed 2FA setup (two_factor_enabled) AND the request
|
||||
// The decision model, in order:
|
||||
//
|
||||
// - 2FA is not enforced (dev/mock) → allowed, no fallback.
|
||||
//
|
||||
// - The request carries a Square verification_token (SCA performed — the
|
||||
// issuer has already authenticated the buyer): SKIP the 2FA gate entirely.
|
||||
// SCA is PRIMARY; the issuer did the job, so the homegrown gate is never
|
||||
// consulted (fallbackUsed=false). A charge that carries a token passes even
|
||||
// for a user who has not enabled 2FA.
|
||||
//
|
||||
// - Otherwise the gate is the FALLBACK authorization for a ccof charge with
|
||||
// no verification token. It only runs when the fallback is permitted:
|
||||
//
|
||||
// (a) TWO_FACTOR_FALLBACK is enabled (see twoFactorFallbackEnabled) — when
|
||||
// the deployment opts out, a token-less charge is denied 402
|
||||
// verification_required: the frontend shows the SCA challenge, and if the
|
||||
// bank cannot do SCA the payment cannot proceed (security-first); and
|
||||
//
|
||||
// (b) a 2FA code delivery channel exists (twoFADeliveryAvailable, build-
|
||||
// dependent like the user package's) — a code the customer can never
|
||||
// receive would silently lock the gate, so it is denied 503
|
||||
// ("2FA requires an email or SMS delivery channel").
|
||||
//
|
||||
// - The user has completed 2FA setup (two_factor_enabled) AND the request
|
||||
// carries a verification_code matching the user's stored pending code.
|
||||
//
|
||||
// B10: the setup flag alone must NOT unlock saved-card charges — an enforced
|
||||
@@ -119,49 +168,69 @@ func verifyPendingTwoFactorCode(ctx context.Context, userID, code string, consum
|
||||
// expired → 400, DB failure → 500.
|
||||
//
|
||||
// On any denial an error JSON is written (parseable by the frontend via
|
||||
// extractErrorMessage) and false is returned — the caller must abort the charge.
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode string, consume bool) bool {
|
||||
// extractErrorMessage) and allowed=false is returned — the caller must abort
|
||||
// the charge.
|
||||
func requireTwoFactorForCardAccess(w http.ResponseWriter, r *http.Request, service *PaymentService, userID, verificationCode, verificationToken string, consume bool) (allowed, fallbackUsed bool) {
|
||||
if !twoFactorEnforced() {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
if service == nil {
|
||||
service = &PaymentService{}
|
||||
}
|
||||
// SCA-primary: a Square verification_token means the issuer already
|
||||
// completed Strong Customer Authentication — the 2FA gate is skipped and no
|
||||
// fallback audit applies.
|
||||
if verificationToken != "" {
|
||||
return true, false
|
||||
}
|
||||
// 2FA is now the BACKUP authorization for a token-less ccof charge. Fail
|
||||
// closed when the deployment disabled the fallback (TWO_FACTOR_FALLBACK) or
|
||||
// has no delivery channel for its codes (twoFADeliveryAvailable).
|
||||
if !twoFactorFallbackEnabled() {
|
||||
writeVerificationRequiredResponse(w)
|
||||
return false, false
|
||||
}
|
||||
if !twoFADeliveryAvailable() {
|
||||
mw.RespondError(w, http.StatusServiceUnavailable, "2FA requires an email or SMS delivery channel; contact the salon")
|
||||
return false, false
|
||||
}
|
||||
enabled, err := service.UserTwoFactorEnabled(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
log.Printf("failed to check two-factor status for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor status")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
if !enabled {
|
||||
mw.RespondError(w, http.StatusForbidden, "Two-factor authentication is required to use online card payments. Enable it in your account settings.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
// B10: an enforced charge of a saved card needs a live one-time code, not
|
||||
// just the enabled setup flag.
|
||||
if verificationCode == "" {
|
||||
mw.RespondError(w, http.StatusForbidden, "A two-factor verification code is required to use this saved card. Ask the customer for their current code.")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
switch err := verifyPendingTwoFactorCode(r.Context(), userID, verificationCode, consume); {
|
||||
case err == nil:
|
||||
return true
|
||||
// The 2FA BACKUP authorized this token-less saved-card charge. The
|
||||
// caller writes the strict fallback audit row on the charge's success.
|
||||
return true, true
|
||||
case errors.Is(err, twofa.ErrIncorrect):
|
||||
mw.RespondError(w, http.StatusBadRequest, "Invalid verification code")
|
||||
return false
|
||||
return false, false
|
||||
case errors.Is(err, twofa.ErrLockedOut):
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "Too many attempts")
|
||||
return false
|
||||
return false, false
|
||||
case errors.Is(err, twofa.ErrMissingOrExpired):
|
||||
mw.RespondError(w, http.StatusBadRequest, "Verification code expired — request a new one")
|
||||
return false
|
||||
return false, false
|
||||
default:
|
||||
log.Printf("failed to check two-factor verification code for user %s: %v", userID, err)
|
||||
mw.RespondError(w, http.StatusInternalServerError, "failed to check two-factor verification code")
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build dev || test
|
||||
|
||||
package payments
|
||||
|
||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||
// this build. Dev/test builds always have one — the [2FA] log line is the
|
||||
// documented loose-fake delivery channel — so the 2FA BACKUP authorization
|
||||
// (the saved-card gate when SCA is unavailable) is always usable here. Mirrors
|
||||
// handlers/user/twofa_dev.go; production builds decide in
|
||||
// twofa_delivery_prod.go.
|
||||
func twoFADeliveryAvailable() bool { return true }
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !dev && !test
|
||||
|
||||
package payments
|
||||
|
||||
import "os"
|
||||
|
||||
// twoFADeliveryAvailable reports whether a 2FA code delivery channel exists in
|
||||
// this build. Production has no wired email/SMS transport (P6), so the ONLY
|
||||
// channel is the operator's explicit opt-in to insecure log delivery
|
||||
// (TWO_FACTOR_ALLOW_LOG_DELIVERY=true). Without a channel, codes can never
|
||||
// reach the customer, so the 2FA BACKUP authorization (the saved-card gate
|
||||
// when SCA is unavailable) cannot operate and a token-less saved-card charge is
|
||||
// denied 503 (see requireTwoFactorForCardAccess). Mirrors
|
||||
// handlers/user/twofa_prod.go; dev/test builds always deliver (twofa_delivery_dev.go).
|
||||
func twoFADeliveryAvailable() bool {
|
||||
return os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true"
|
||||
}
|
||||
@@ -99,6 +99,109 @@ func TestTwoFactorEnforced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireTwoFactorForCardAccess_VerificationTokenSkips pins the SCA-primary
|
||||
// leg of the decision model: when the request carries a Square verification_token
|
||||
// (the issuer already completed SCA), the 2FA gate is skipped entirely — even a
|
||||
// user with NO 2FA setup passes, no code is demanded, and no fallback is used.
|
||||
func TestRequireTwoFactorForCardAccess_VerificationTokenSkips(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "vrf_sca_token_123", false)
|
||||
require.True(t, allowed, "SCA performed — the 2FA gate must be skipped")
|
||||
require.False(t, fallbackUsed, "SCA is primary — the 2FA fallback was not used")
|
||||
require.Equal(t, http.StatusOK, w.Code, "no denial response may be written when the token skips the gate")
|
||||
}
|
||||
|
||||
// TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured pins the
|
||||
// SCA-only posture (TWO_FACTOR_FALLBACK=false): a token-less saved-card charge
|
||||
// has no 2FA fallback, so the gate denies 402 with the structured
|
||||
// verification_required body the frontend keys on to trigger the SCA challenge.
|
||||
func TestRequireTwoFactorForCardAccess_FallbackDisabled_402Structured(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
t.Setenv("TWO_FACTOR_FALLBACK", "false")
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
||||
require.False(t, allowed, "SCA-only posture: no 2FA fallback for a token-less charge")
|
||||
require.False(t, fallbackUsed)
|
||||
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.Equal(t, "verification_required", body["code"])
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits pins the strict audit
|
||||
// trail: a 2FA-fallback saved-card charge (no verification token, code verified)
|
||||
// must write an admin_audit_log row with action_type '2fa_fallback_charge' for
|
||||
// the customer actor — the operator can distinguish SCA-authorized charges from
|
||||
// fallback-authorized ones.
|
||||
func TestTwoFactorEnforced_BookingSavedCard_Fallback_Audits(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
seedTwoFAPendingCode(t, tx, userID, "445566")
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_audit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-fallback-audit",
|
||||
VerificationCode: "445566",
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
var auditCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log
|
||||
WHERE action_type = '2fa_fallback_charge' AND target_user_id = $1 AND admin_id = $1
|
||||
`, userID).Scan(&auditCount))
|
||||
require.Equal(t, 1, auditCount, "a 2FA-fallback saved-card charge must write a strict audit row for the customer actor")
|
||||
}
|
||||
|
||||
// TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit pins that an SCA-
|
||||
// authorized charge (verification token present) writes NO 2fa_fallback_charge
|
||||
// audit row: SCA is primary and the homegrown fallback was not used.
|
||||
func TestTwoFactorEnforced_BookingSavedCard_SCA_Skips_Audit(t *testing.T) {
|
||||
helperEnvEnforce2FA(t)
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_sca_audit", "VISA", "4242")
|
||||
require.NoError(t, err)
|
||||
|
||||
vrf := "vrf_sca_booking_audit"
|
||||
req := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
CardID: &cardID,
|
||||
IdempotencyKey: "2fa-sca-audit",
|
||||
VerificationToken: &vrf,
|
||||
}
|
||||
|
||||
w := makePaymentRequest(withNonGuest(CreateBookingPayment), "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
var auditCount int
|
||||
require.NoError(t, tx.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_log WHERE action_type = '2fa_fallback_charge'
|
||||
`).Scan(&auditCount))
|
||||
require.Zero(t, auditCount, "an SCA-authorized charge must not write a 2FA-fallback audit row")
|
||||
}
|
||||
|
||||
// TestRequireTwoFactorForCardAccess_NotEnforced verifies the dev/mock path
|
||||
// allows every request without touching the DB (no user rows are consulted).
|
||||
// Uses an explicit mock env: empty SQUARE_ENVIRONMENT now defaults to ENFORCED
|
||||
@@ -108,8 +211,10 @@ func TestRequireTwoFactorForCardAccess_NotEnforced(t *testing.T) {
|
||||
t.Setenv("SQUARE_ENVIRONMENT", "mock")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", false))
|
||||
require.Equal(t, http.StatusOK, w.Code, "no response must be written when not enforced")
|
||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, nil, "000000000001", "", "", false)
|
||||
require.True(t, allowed, "no response must be written when not enforced")
|
||||
require.False(t, fallbackUsed, "not enforced — the 2FA fallback did not authorize anything")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
@@ -121,7 +226,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", false)
|
||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "123456", "", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]string
|
||||
@@ -137,7 +242,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
// B10: the enabled setup flag alone must NOT unlock the gate.
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", false)
|
||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "", "", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
@@ -148,7 +253,9 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false))
|
||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
||||
require.True(t, allowed)
|
||||
require.True(t, fallbackUsed, "a code-verified token-less charge uses the 2FA fallback")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
@@ -158,7 +265,7 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
seedTwoFAPendingCode(t, tx, userID, "424242")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
ok := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", false)
|
||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "000000", "", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var body map[string]string
|
||||
@@ -169,7 +276,8 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
|
||||
t.Run("unknown_user_writes_403_json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", false))
|
||||
ok, _ := requireTwoFactorForCardAccess(w, req, NewPaymentService(), "000000000000", "123456", "", false)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "403 body must be mw.RespondError JSON")
|
||||
@@ -193,7 +301,9 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "first gate pass must succeed")
|
||||
allowed, fallbackUsed := requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
||||
require.True(t, allowed, "first gate pass must succeed")
|
||||
require.True(t, fallbackUsed, "the code verification is the 2FA fallback")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// The code must still be present — the gate verified WITHOUT consuming.
|
||||
@@ -203,7 +313,8 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
||||
|
||||
// A same-key retry (e.g. after a failed Square charge) re-verifies the SAME code.
|
||||
w = httptest.NewRecorder()
|
||||
require.True(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a not-yet-consumed code must pass the gate again on retry")
|
||||
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
||||
require.True(t, allowed, "a not-yet-consumed code must pass the gate again on retry")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Consumption happens at the charge's terminal SUCCESS state.
|
||||
@@ -213,7 +324,8 @@ func TestRequireTwoFactorForCardAccess_VerifyDoesNotConsume(t *testing.T) {
|
||||
|
||||
// A further attempt with the consumed code is denied as expired.
|
||||
w = httptest.NewRecorder()
|
||||
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", false), "a consumed code must not pass the gate")
|
||||
allowed, _ = requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242", "", false)
|
||||
require.False(t, allowed, "a consumed code must not pass the gate")
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var body map[string]string
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
|
||||
@@ -23,7 +23,8 @@ package square
|
||||
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
|
||||
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
|
||||
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
|
||||
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
|
||||
// SimulateVerificationRequired, SimulateSavedCardVerificationRequired,
|
||||
// ChallengeResult) that let dev/tests drive Square failure modes
|
||||
// that are otherwise only reachable against the real API. FailAfterCommit
|
||||
// simulates the exact "charged but response lost → same-key retry" prod
|
||||
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
|
||||
@@ -58,6 +59,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -156,10 +158,61 @@ type MockClient struct {
|
||||
// the charge succeeds. Off by default — existing dev/test flows charge
|
||||
// plain "cnon:test-card"-style tokens without verification tokens.
|
||||
SimulateVerificationRequired bool
|
||||
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
|
||||
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
|
||||
// platform is moving toward (buyer verification on card-on-file charges,
|
||||
// not just new-card nonces). When true, CreatePayment with a ccof: source
|
||||
// and NO VerificationToken is rejected with the same structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and a pending
|
||||
// buyer-verification challenge is recorded for the card. A subsequent
|
||||
// charge WITH a verification token resolves that challenge (see
|
||||
// resolveVerificationToken): an explicitly approved challenge, or a
|
||||
// stateless verify_mock_<prefix>_<amount>_ok token, lets the charge
|
||||
// succeed; a denied challenge / _deny token is rejected with 400
|
||||
// VERIFICATION_TOKEN_INVALID. Cards marked via GrandfatherSavedCard bypass
|
||||
// the gate entirely. Off by default — existing dev/test flows charge
|
||||
// saved cards without verification tokens, so flipping it on in a
|
||||
// prod-like test setup intentionally surfaces every ccof charge that would
|
||||
// be rejected by Square's SCA.
|
||||
SimulateSavedCardVerificationRequired bool
|
||||
// ChallengeResult configures the mock's SCA challenge outcome when a
|
||||
// verification token is supplied on a gated charge. "" or "approve"
|
||||
// (default) accepts a valid token / approved challenge; "deny" simulates
|
||||
// the buyer denying EVERY banking-app challenge (any token →
|
||||
// VERIFICATION_TOKEN_INVALID); "auto" auto-resolves a gate rejection's
|
||||
// pending challenge as approved, so the next tokenized retry succeeds
|
||||
// without an explicit ApprovePendingVerification call.
|
||||
ChallengeResult string
|
||||
// grandfatheredCards marks ccof: tokens that are exempt from the saved-card
|
||||
// verification gate (GrandfatherSavedCard). An exempt card charges without
|
||||
// a verification token even while SimulateSavedCardVerificationRequired is
|
||||
// on — mirroring cards Square has already verified / stored with a standing
|
||||
// SCA exemption.
|
||||
grandfatheredCards map[string]bool
|
||||
// pendingChallenges records the per-card buyer-verification challenge state
|
||||
// that the saved-card gate creates when it rejects a ccof charge without a
|
||||
// token. An opaque (real-Square-shaped) verification token is resolved
|
||||
// against this ledger; the deterministic verify_mock_... tokens carry their
|
||||
// own outcome and only consult the ledger to honour an explicit denial.
|
||||
pendingChallenges map[string]*pendingChallenge
|
||||
// verifyTokens is a one-time-use ledger of verify_mock_* verification
|
||||
// tokens consumed by a successful charge or a definitive
|
||||
// VERIFICATION_TOKEN_INVALID rejection — mirroring real Square, which
|
||||
// consumes a verification token on use so a replayed token is rejected
|
||||
// (VERIFICATION_TOKEN_INVALID). Mirrors the usedSources ledger pattern.
|
||||
verifyTokens map[string]bool
|
||||
}
|
||||
|
||||
type devProdClient struct{}
|
||||
|
||||
// pendingChallenge is the recorded buyer-verification challenge state for one
|
||||
// saved-card (ccof:) token. outcome "" = pending (recorded by the gate's
|
||||
// rejection, not yet resolved); "approved" / "denied" = resolved via
|
||||
// ApprovePendingVerification / DenyPendingVerification.
|
||||
type pendingChallenge struct {
|
||||
outcome string
|
||||
}
|
||||
|
||||
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
return createPaymentHTTP(ctx, req)
|
||||
}
|
||||
@@ -251,6 +304,9 @@ func NewDevClient() SquareClient {
|
||||
customers: make(map[string]*CustomerResult),
|
||||
completed: make(map[string]*PaymentResult),
|
||||
usedSources: make(map[string]bool),
|
||||
grandfatheredCards: make(map[string]bool),
|
||||
pendingChallenges: make(map[string]*pendingChallenge),
|
||||
verifyTokens: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +343,160 @@ func keyReuseError(key string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// verificationTokenInvalidError is Square's VERIFICATION_TOKEN_INVALID
|
||||
// rejection (definitivePaymentCodes): the supplied 3DS/SCA verification token
|
||||
// is invalid, expired, already used, denied by the buyer, or not bound to this
|
||||
// card + amount. A definitive payment error — retrying the same request can
|
||||
// never succeed.
|
||||
func verificationTokenInvalidError(token, sourceID string) error {
|
||||
return &squareAPIError{
|
||||
Code: "VERIFICATION_TOKEN_INVALID",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "The verification token is invalid, expired, already used, or not valid for this charge",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: verification token %s is not valid for card-on-file charge on %s", tokenPrefix(token), tokenPrefix(sourceID)),
|
||||
}
|
||||
}
|
||||
|
||||
// parsedVerifyToken is the deterministic verify_mock_<prefix>_<amount>[_ok|_deny]
|
||||
// verification-token encoding shared by the dev frontend and the mock, so the
|
||||
// two sides can exercise approve/deny outcomes WITHOUT shared state.
|
||||
type parsedVerifyToken struct {
|
||||
prefix string
|
||||
amount int64
|
||||
denied bool
|
||||
}
|
||||
|
||||
// parseVerifyToken parses a deterministic mock verification token of the form
|
||||
// verify_mock_<prefix>_<amount>[_ok|_deny] (outcome suffix defaults to ok).
|
||||
// Returns ok=false for anything that is not parseable (an opaque token — the
|
||||
// same shape as real Square's verification tokens — which resolves against the
|
||||
// recorded pending challenge instead).
|
||||
func parseVerifyToken(token string) (parsedVerifyToken, bool) {
|
||||
const marker = "verify_mock_"
|
||||
if !strings.HasPrefix(token, marker) {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
rest := strings.TrimPrefix(token, marker)
|
||||
if rest == "" {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
parts := strings.Split(rest, "_")
|
||||
denied := false
|
||||
if n := len(parts); n > 1 {
|
||||
switch parts[n-1] {
|
||||
case "ok", "deny":
|
||||
denied = parts[n-1] == "deny"
|
||||
parts = parts[:n-1]
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
if err != nil {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
return parsedVerifyToken{prefix: strings.Join(parts[:len(parts)-1], "_"), amount: amount, denied: denied}, true
|
||||
}
|
||||
|
||||
// verificationTokenPrefixForSource returns the card prefix the mock binds an
|
||||
// SCA verification token to for a source_id — the SAME derivation the dev
|
||||
// frontend uses when minting verify_mock_... tokens, so the binding check
|
||||
// cannot drift between the two sides. New-card (cnon:) nonces bind to the
|
||||
// first four digits of the PAN the user typed (MockCardForm MOCK_TOKENS);
|
||||
// saved-card (ccof:) tokens bind to the first four chars after the ccof:
|
||||
// prefix (MockCardForm.verifySavedCard).
|
||||
func verificationTokenPrefixForSource(sourceID string) string {
|
||||
switch sourceID {
|
||||
case "cnon:test-card":
|
||||
return "4242"
|
||||
case "cnon:visa":
|
||||
return "4111"
|
||||
case "cnon:mastercard":
|
||||
return "5555"
|
||||
case "cnon:amex":
|
||||
return "3782"
|
||||
}
|
||||
if strings.HasPrefix(sourceID, "ccof:") {
|
||||
rest := strings.TrimPrefix(sourceID, "ccof:")
|
||||
if len(rest) > 4 {
|
||||
return rest[:4]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
|
||||
// a charge. savedCard=true resolves against the saved-card challenge ledger;
|
||||
// savedCard=false (new-card nonce) treats any present token as satisfying the
|
||||
// gate. Returns nil when the token is accepted (consuming it in the one-time-use
|
||||
// ledger), or a definitive 400 VERIFICATION_TOKEN_INVALID. Must be called under
|
||||
// m.mu (CreatePayment holds the write lock).
|
||||
func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int64, savedCard bool) error {
|
||||
isVerifyToken := strings.HasPrefix(token, "verify_mock_")
|
||||
|
||||
// ChallengeResult="deny" simulates the buyer denying every banking-app
|
||||
// challenge: ANY token is definitively rejected.
|
||||
if m.ChallengeResult == "deny" {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
|
||||
// One-time-use ledger: a verify_mock_* token is consumed on its first use;
|
||||
// a second use of the same token is definitively invalid.
|
||||
if isVerifyToken && m.verifyTokens[token] {
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
|
||||
// Deterministic tokens carry their own binding and outcome.
|
||||
if parsed, ok := parseVerifyToken(token); ok {
|
||||
if parsed.amount != amount || parsed.prefix != verificationTokenPrefixForSource(sourceID) {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
if parsed.denied {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
// Encoded approval. An explicitly DENIED pending challenge is still the
|
||||
// authority (shared-state denial overrides the stateless encoding).
|
||||
if savedCard {
|
||||
if ch := m.pendingChallenges[sourceID]; ch != nil && ch.outcome == "denied" {
|
||||
m.verifyTokens[token] = true
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
}
|
||||
m.verifyTokens[token] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opaque token (real-Square-shaped): a new-card charge accepts it (the cnon
|
||||
// gate requires only a present token); a saved-card charge resolves it
|
||||
// against the recorded pending challenge — a token for a challenge that was
|
||||
// never recorded, or that is still pending, is "never seen" → invalid.
|
||||
if !savedCard {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ch := m.pendingChallenges[sourceID]
|
||||
if ch == nil || ch.outcome != "approved" {
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return verificationTokenInvalidError(token, sourceID)
|
||||
}
|
||||
if isVerifyToken {
|
||||
m.verifyTokens[token] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||
if m.ShouldFail {
|
||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
||||
@@ -381,20 +591,64 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
|
||||
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
|
||||
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
|
||||
// must re-verify and re-tokenize, NOT retry the same request (the code is
|
||||
// in definitivePaymentCodes). A present verification token (e.g.
|
||||
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
|
||||
// by default): a cnon: charge without a 3DS/SCA verification token is
|
||||
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
|
||||
// buyer must re-verify and re-tokenize, NOT retry the same request (the
|
||||
// code is in definitivePaymentCodes). A present verification token (e.g.
|
||||
// verify_mock_...) satisfies the gate exactly as production accepts a
|
||||
// Square-issued verification_token on the CreatePayment body.
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
|
||||
if req.VerificationToken == "" {
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
}
|
||||
}
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror Square's SCA enforcement on SAVED-CARD (ccof:) charges — the
|
||||
// SCA-primary saved-card posture (opt-in toggle, off by default; placement
|
||||
// AFTER the customer_id gate above so a ccof charge without a customer is
|
||||
// still MISSING_REQUIRED_PARAMETER, never verification-required). A ccof:
|
||||
// charge without a 3DS/SCA verification token is rejected with the same
|
||||
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
|
||||
// a pending buyer-verification challenge is recorded for the card. A
|
||||
// subsequent charge WITH a verification token resolves the challenge (see
|
||||
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
|
||||
// bypass the gate.
|
||||
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
|
||||
if req.VerificationToken == "" {
|
||||
if m.grandfatheredCards[req.SourceID] {
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
|
||||
} else {
|
||||
if m.ChallengeResult == "auto" {
|
||||
// "auto" config: the banking-app challenge resolves itself
|
||||
// as approved, so the next tokenized retry succeeds without
|
||||
// an explicit ApprovePendingVerification call.
|
||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: "approved"}
|
||||
} else {
|
||||
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: ""}
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(req.SourceID))
|
||||
return nil, &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +1179,42 @@ func (m *MockClient) UsedSources() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// GrandfatherSavedCard marks a ccof: token exempt from the saved-card
|
||||
// verification gate (SimulateSavedCardVerificationRequired), so that card
|
||||
// charges without a verification token — mirroring a card Square has already
|
||||
// verified or holds a standing SCA exemption for.
|
||||
func (m *MockClient) GrandfatherSavedCard(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.grandfatheredCards[ccofToken] = true
|
||||
}
|
||||
|
||||
// ApprovePendingVerification marks the recorded buyer-verification challenge
|
||||
// for a saved card as approved (creating it if the gate never recorded one), so
|
||||
// a subsequent tokenized charge of that card succeeds.
|
||||
func (m *MockClient) ApprovePendingVerification(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if ch, ok := m.pendingChallenges[ccofToken]; ok {
|
||||
ch.outcome = "approved"
|
||||
return
|
||||
}
|
||||
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "approved"}
|
||||
}
|
||||
|
||||
// DenyPendingVerification marks the recorded buyer-verification challenge for
|
||||
// a saved card as denied, so a subsequent tokenized charge of that card is
|
||||
// rejected with VERIFICATION_TOKEN_INVALID (the token is consumed).
|
||||
func (m *MockClient) DenyPendingVerification(ccofToken string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if ch, ok := m.pendingChallenges[ccofToken]; ok {
|
||||
ch.outcome = "denied"
|
||||
return
|
||||
}
|
||||
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "denied"}
|
||||
}
|
||||
|
||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||
|
||||
|
||||
@@ -2257,3 +2257,202 @@ func TestDevClient_CreatePayment_SimulateSourceUsed_CnonConsumption(t *testing.T
|
||||
assert.Equal(t, "COMPLETED", second.Status, "with SimulateSourceUsed off (default), the same cnon must be reusable")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SavedCardVerificationRequired locks the mock's SCA
|
||||
// enforcement on SAVED-CARD (ccof:) charges: with
|
||||
// SimulateSavedCardVerificationRequired=true, a ccof charge without a 3DS/SCA
|
||||
// verification token is rejected with a structured 400
|
||||
// CARD_DECLINED_VERIFICATION_REQUIRED (a definitive payment error), a
|
||||
// deterministic verify_mock_<prefix>_<amount>_ok token satisfies the gate, a
|
||||
// grandfathered card charges without a token, and with the toggle off (default)
|
||||
// no verification is required. Placement check: a ccof charge WITHOUT a customer
|
||||
// id must still be MISSING_REQUIRED_PARAMETER, never verification-required.
|
||||
func TestDevClient_CreatePayment_SavedCardVerificationRequired(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
t.Run("ccof_without_verification_token_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-no-token",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
|
||||
assert.Equal(t, "PAYMENT_METHOD_ERROR", ErrorCategory(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.True(t, IsDefinitivePaymentError(err), "CARD_DECLINED_VERIFICATION_REQUIRED must classify as a definitive payment error")
|
||||
})
|
||||
|
||||
t.Run("verification_token_satisfies_gate", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-with-token",
|
||||
VerificationToken: "verify_mock_mock_5000_ok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
assert.Equal(t, "ON_FILE", result.EntryMethod)
|
||||
})
|
||||
|
||||
t.Run("customer_id_gate_fires_before_verification_gate", func(t *testing.T) {
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:other_card", IdempotencyKey: "sca-ccof-no-customer",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "MISSING_REQUIRED_PARAMETER", ErrorCode(err), "a ccof charge without a customer must stay MISSING_REQUIRED_PARAMETER, never verification-required")
|
||||
})
|
||||
|
||||
t.Run("grandfathered_card_charges_without_token", func(t *testing.T) {
|
||||
client.GrandfatherSavedCard(ccof)
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-grandfathered",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status)
|
||||
})
|
||||
|
||||
t.Run("toggle_off_requires_no_verification", func(t *testing.T) {
|
||||
client.SimulateSavedCardVerificationRequired = false
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "sca-ccof-default-off",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status, "with SimulateSavedCardVerificationRequired off (default), a ccof charge needs no verification token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_OneTimeUse locks the mock's
|
||||
// one-time-use verification-token ledger: a verify_mock_* token is consumed on
|
||||
// its first successful charge, so a second charge with the SAME token (under a
|
||||
// DIFFERENT idempotency key) is rejected with a definitive 400
|
||||
// VERIFICATION_TOKEN_INVALID — mirroring real Square consuming a verification
|
||||
// token on use.
|
||||
func TestDevClient_CreatePayment_VerificationToken_OneTimeUse(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
const token = "verify_mock_mock_5000_ok"
|
||||
|
||||
first, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-1",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", first.Status)
|
||||
|
||||
client.mu.RLock()
|
||||
consumed := client.verifyTokens[token]
|
||||
client.mu.RUnlock()
|
||||
assert.True(t, consumed, "a successfully used verification token must be consumed")
|
||||
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "one-time-key-2",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
assert.True(t, IsDefinitivePaymentError(err), "VERIFICATION_TOKEN_INVALID must classify as a definitive payment error")
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_Denied locks the shared-state
|
||||
// challenge resolution: DenyPendingVerification marks the recorded challenge for
|
||||
// a saved card as denied, so a subsequent tokenized charge is rejected with
|
||||
// VERIFICATION_TOKEN_INVALID and the token is consumed (a second use of the same
|
||||
// token is also invalid).
|
||||
func TestDevClient_CreatePayment_VerificationToken_Denied(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
const token = "verify_mock_mock_5000_ok"
|
||||
|
||||
// The gate rejects the no-token charge and records a pending challenge.
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-gate",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err))
|
||||
|
||||
// The buyer denies the challenge in the banking app.
|
||||
client.DenyPendingVerification(ccof)
|
||||
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
|
||||
// The denied token is consumed: a second use of the SAME token is also invalid.
|
||||
result2, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "deny-retry-2",
|
||||
VerificationToken: token,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result2)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_VerificationToken_AmountMismatch locks the
|
||||
// deterministic token's amount/source binding: a verify_mock_<prefix>_<amount>
|
||||
// token is bound to the card + amount it was issued for, so charging a DIFFERENT
|
||||
// amount (or a different card) with it is rejected with VERIFICATION_TOKEN_INVALID.
|
||||
func TestDevClient_CreatePayment_VerificationToken_AmountMismatch(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
t.Run("token_for_wrong_amount_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "amount-mismatch",
|
||||
VerificationToken: "verify_mock_mock_9999_ok", // bound to £99.99, the charge is £50.00
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
|
||||
})
|
||||
|
||||
t.Run("token_for_wrong_card_is_rejected", func(t *testing.T) {
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "source-mismatch",
|
||||
VerificationToken: "verify_mock_other_5000_ok", // bound to a different card
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Equal(t, "VERIFICATION_TOKEN_INVALID", ErrorCode(err))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDevClient_CreatePayment_SavedCardVerification_Grandfathered locks the
|
||||
// GrandfatherSavedCard exemption: marking a ccof token exempt lets it charge
|
||||
// without a verification token even while the saved-card SCA gate is on, while
|
||||
// a DIFFERENT (non-grandfathered) card stays gated.
|
||||
func TestDevClient_CreatePayment_SavedCardVerification_Grandfathered(t *testing.T) {
|
||||
client := NewDevClient().(*MockClient)
|
||||
client.SimulateSavedCardVerificationRequired = true
|
||||
ctx := context.Background()
|
||||
const ccof = "ccof:mock_saved"
|
||||
|
||||
_, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-before",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "before grandfathering the card must be gated")
|
||||
|
||||
client.GrandfatherSavedCard(ccof)
|
||||
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: ccof, CustomerID: "cus_test123", IdempotencyKey: "grand-after",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "COMPLETED", result.Status, "a grandfathered card must charge without a verification token")
|
||||
|
||||
_, err = client.CreatePayment(ctx, CreatePaymentReq{
|
||||
Amount: 5000, Currency: "GBP", SourceID: "ccof:mock_other", CustomerID: "cus_test123", IdempotencyKey: "grand-other",
|
||||
})
|
||||
require.Equal(t, "CARD_DECLINED_VERIFICATION_REQUIRED", ErrorCode(err), "a non-grandfathered card must stay gated")
|
||||
}
|
||||
|
||||
@@ -214,6 +214,16 @@ func initSquare() {
|
||||
if !enforced && !payments.IsExplicitDevOrMockEnv() {
|
||||
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
|
||||
}
|
||||
// SCA-primary / 2FA-backup posture (TWO_FACTOR_FALLBACK, default "true"):
|
||||
// the homegrown 2FA gate is the BACKUP authorization for saved-card charges
|
||||
// that carry no Square verification_token. TWO_FACTOR_FALLBACK=false makes
|
||||
// the deployment SCA-only: a token-less saved-card charge is then rejected
|
||||
// 402 verification_required (the frontend shows the 3DS challenge; if the
|
||||
// bank cannot do SCA the payment cannot proceed). Warn when the operator
|
||||
// opted out so the SCA-only posture is a deliberate, visible choice.
|
||||
if !payments.NewPaymentService().TwoFactorFallbackEnabled() && !payments.IsExplicitDevOrMockEnv() {
|
||||
log.Printf("WARNING: TWO_FACTOR_FALLBACK=false with SQUARE_ENVIRONMENT=%q — the homegrown 2FA is DISABLED as a saved-card charge fallback. Only charges carrying a Square verification_token (SCA performed) can proceed; a token-less charge is rejected 402 verification_required.", env)
|
||||
}
|
||||
// The mirror-image confusion: enforcement is ON but the Square client fell
|
||||
// back to the in-memory mock (internal/square.NewDevClient only picks the
|
||||
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or
|
||||
|
||||
Reference in New Issue
Block a user