feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance
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.
This commit is contained in:
@@ -43,6 +43,52 @@ import (
|
||||
// On any error the helper writes the HTTP response and returns ok=false — the
|
||||
// caller must return immediately.
|
||||
func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *PaymentService, userID string, newCardToken, cardID *string, saveCard bool, notFoundMsg string) (sourceID string, savedCardID *string, squareCustomerID string, ok bool) {
|
||||
// The saved-card branch is resolved FIRST so a request carrying BOTH a
|
||||
// new-card token and a saved card id — the SCA tokenize-result wire
|
||||
// contract (Square's CURRENT "Charge a Card on File" flow, where
|
||||
// card.tokenize(verificationDetails, cardId) returns a token that must be
|
||||
// sent as source_id, NOT a separate verification_token) — resolves the
|
||||
// customer from the saved-card row and uses the fresh one-time token as
|
||||
// the source. Without the token it is a plain saved-card (ccof:) charge.
|
||||
// Only a token with NO saved card falls through to the new-card path below.
|
||||
if cardID != nil {
|
||||
card, err := svc.GetCardByID(ctx, *cardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, notFoundMsg, http.StatusNotFound)
|
||||
return "", nil, "", false
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
if card.SquareCustomerID == "" {
|
||||
if userID == "" {
|
||||
// Defensive parity with the original saved-card block: a card
|
||||
// with no bookable owner cannot be provisioned. Unreachable in
|
||||
// practice — GetCardByID above filters on user_id and would
|
||||
// have 404'd for an empty owner.
|
||||
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
||||
return "", nil, "", false
|
||||
}
|
||||
provisioned, provErr := svc.EnsureSquareCustomerForSavedCard(ctx, *cardID, userID)
|
||||
if provErr != nil {
|
||||
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *cardID, userID, provErr)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
card.SquareCustomerID = provisioned
|
||||
}
|
||||
if newCardToken != nil && *newCardToken != "" {
|
||||
// SCA tokenize-result for a saved card: the token is the charge
|
||||
// source (a fresh one-time token minted only after the issuer
|
||||
// completed buyer verification for THIS card + amount); the stored
|
||||
// ccof: id is NOT sent. customer_id still derives from the saved
|
||||
// card row — Square requires it for the card-on-file charge.
|
||||
return *newCardToken, cardID, card.SquareCustomerID, true
|
||||
}
|
||||
return card.SquareCardID, cardID, card.SquareCustomerID, true
|
||||
}
|
||||
if newCardToken != nil && *newCardToken != "" {
|
||||
if saveCard {
|
||||
sqCustomerID, custErr := svc.EnsureSquareCustomer(ctx, userID)
|
||||
@@ -96,36 +142,6 @@ func resolveChargeSource(ctx context.Context, w http.ResponseWriter, svc *Paymen
|
||||
}
|
||||
return sourceID, savedCardID, squareCustomerID, true
|
||||
}
|
||||
if cardID != nil {
|
||||
card, err := svc.GetCardByID(ctx, *cardID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, notFoundMsg, http.StatusNotFound)
|
||||
return "", nil, "", false
|
||||
}
|
||||
log.Printf("Failed to get card: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
if card.SquareCustomerID == "" {
|
||||
if userID == "" {
|
||||
// Defensive parity with the original saved-card block: a card
|
||||
// with no bookable owner cannot be provisioned. Unreachable in
|
||||
// practice — GetCardByID above filters on user_id and would
|
||||
// have 404'd for an empty owner.
|
||||
http.Error(w, "Saved card has no owner and cannot be charged", http.StatusBadRequest)
|
||||
return "", nil, "", false
|
||||
}
|
||||
provisioned, provErr := svc.EnsureSquareCustomerForSavedCard(ctx, *cardID, userID)
|
||||
if provErr != nil {
|
||||
log.Printf("Failed to provision Square customer for saved card %s (user %s): %v", *cardID, userID, provErr)
|
||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
||||
return "", nil, "", false
|
||||
}
|
||||
card.SquareCustomerID = provisioned
|
||||
}
|
||||
return card.SquareCardID, cardID, card.SquareCustomerID, true
|
||||
}
|
||||
// Neither a new-card token nor a saved card — validation upstream
|
||||
// (ValidateCardInfo) guarantees one of them is present.
|
||||
return "", nil, "", false
|
||||
|
||||
Reference in New Issue
Block a user