fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX

Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent fe88f2084d
commit 4d5d2cd381
28 changed files with 2047 additions and 341 deletions
+108 -18
View File
@@ -231,19 +231,26 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
return token, nil return token, nil
} }
// VerifyRefreshToken checks a refresh token and returns user details if valid // GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation
// The token is consumed (deleted) upon successful verification, implementing rotation. // family as its parent (the family_id returned by VerifyRefreshToken). Rotation
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, err error) { // must mint the descendant in the parent's family so a replayed (already-used)
// ancestor can revoke the ENTIRE lineage — the descendant included — instead of
// leaving a fresh 90-day token alive after theft is detected.
func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role string, familyID string) (string, error) {
token, err := generateRefreshTokenString()
if err != nil {
return "", err
}
// Store hashed version in DB with 90-day expiry, in the given family
query := ` query := `
DELETE FROM refresh_tokens INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
WHERE token_hash = encode(sha256($1::bytea), 'hex') VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + INTERVAL '90 days')
AND expires_at > NOW() RETURNING id`
AND NOT revoked
RETURNING user_id, role`
tx, err := db.Conn.Begin(ctx) tx, err := db.Conn.Begin(ctx)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to begin transaction: %w", err) return "", fmt.Errorf("failed to begin transaction: %w", err)
} }
defer func() { defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
@@ -251,19 +258,102 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
} }
}() }()
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role) var tokenID int64
err = tx.QueryRow(ctx, query, userID, token, role, familyID).Scan(&tokenID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { return "", fmt.Errorf("failed to store refresh token: %w", err)
return "", "", fmt.Errorf("invalid or expired refresh token")
}
return "", "", fmt.Errorf("failed to verify refresh token: %w", err)
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return "", "", fmt.Errorf("failed to commit transaction: %w", err) return "", fmt.Errorf("failed to commit transaction: %w", err)
} }
// Token was consumed (DELETE returned it) — this is rotation return token, nil
// If a token is used twice, the second DELETE returns no rows = invalid }
return userID, role, nil
// VerifyRefreshToken checks a refresh token and returns user details if valid.
// The token is consumed (marked used) upon successful verification — rotation —
// and its family_id is returned so the caller can mint the descendant in the
// SAME family. If an ALREADY-ROTATED token is presented again (a replay: the
// attacker rotated it, then the victim replayed it), the entire rotation family
// is revoked (the descendant minted at rotation dies too) and a critical admin
// notification (reason 'refresh_token_reuse') is raised. The caller always gets
// the generic "invalid or expired refresh token" error so reuse is never leaked.
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, familyID string, err error) {
query := `
UPDATE refresh_tokens SET used_at = NOW()
WHERE token_hash = encode(sha256($1::bytea), 'hex')
AND expires_at > NOW()
AND NOT revoked
AND used_at IS NULL
RETURNING user_id, role, family_id`
tx, err := db.Conn.Begin(ctx)
if err != nil {
return "", "", "", fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role, &familyID)
if err == nil {
// Rotation: the token is marked used (kept in the row) so a later
// replay can be detected, and its family_id is returned.
if err := tx.Commit(ctx); err != nil {
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
return userID, role, familyID, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", err)
}
// The rotation UPDATE matched nothing: the token is expired, revoked, or
// never issued — OR it was already used (replayed). A used token is theft:
// the descendant minted at rotation would otherwise stay valid for 90 days.
var reusedUserID, reusedFamilyID string
reuseErr := tx.QueryRow(ctx, `
SELECT user_id, family_id FROM refresh_tokens
WHERE token_hash = encode(sha256($1::bytea), 'hex')
AND used_at IS NOT NULL
`, tokenString).Scan(&reusedUserID, &reusedFamilyID)
if reuseErr == nil {
// (i) Revoke the ENTIRE family — the reused token and every descendant.
if _, err := tx.Exec(ctx, `DELETE FROM refresh_tokens WHERE family_id = $1`, reusedFamilyID); err != nil {
slog.Error("CRITICAL: refresh token reuse detected but family revocation failed", "userID", reusedUserID, "familyID", reusedFamilyID, "err", err)
}
// (ii) Surface the theft in the admin notification centre. The NOT
// EXISTS guard keeps ONE alert per reused family until an admin
// acknowledges it — mirroring insertCriticalPaymentNotification.
if _, err := tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id, created_at)
SELECT 'refresh_token_reuse', $1, NOW()
WHERE NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.reason = 'refresh_token_reuse'
AND an.user_id = $1
AND an.acknowledged_at IS NULL
)
`, reusedUserID); err != nil {
slog.Error("CRITICAL: refresh token reuse detected but admin alert insert failed", "userID", reusedUserID, "err", err)
}
// Commit the family revocation + alert — NOT the deferred rollback.
if err := tx.Commit(ctx); err != nil {
return "", "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
// (iii) CRITICAL log; (iv) generic error — never leak that reuse was seen.
slog.Error("CRITICAL: refresh token reuse detected — rotation family revoked", "userID", reusedUserID, "familyID", reusedFamilyID)
return "", "", "", fmt.Errorf("invalid or expired refresh token")
}
if !errors.Is(reuseErr, pgx.ErrNoRows) {
return "", "", "", fmt.Errorf("failed to verify refresh token: %w", reuseErr)
}
// Never-issued / expired / revoked token — indistinguishable from a replay
// to the client, as before.
return "", "", "", fmt.Errorf("invalid or expired refresh token")
} }
+89 -5
View File
@@ -424,7 +424,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
} }
// First verify should succeed // First verify should succeed
retUserID, retRole, err := VerifyRefreshToken(ctx, token) retUserID, retRole, _, err := VerifyRefreshToken(ctx, token)
if err != nil { if err != nil {
t.Fatalf("VerifyRefreshToken() failed: %v", err) t.Fatalf("VerifyRefreshToken() failed: %v", err)
} }
@@ -436,7 +436,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
} }
// Second verify with same token must fail (rotation — token consumed) // Second verify with same token must fail (rotation — token consumed)
_, _, err = VerifyRefreshToken(ctx, token) _, _, _, err = VerifyRefreshToken(ctx, token)
if err == nil { if err == nil {
t.Fatal("expected error for consumed token, got nil") t.Fatal("expected error for consumed token, got nil")
} }
@@ -461,13 +461,13 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
} }
// First call should succeed // First call should succeed
_, _, err = VerifyRefreshToken(ctx, token) _, _, _, err = VerifyRefreshToken(ctx, token)
if err != nil { if err != nil {
t.Fatalf("first verification should succeed, got: %v", err) t.Fatalf("first verification should succeed, got: %v", err)
} }
// Second call with the same token must fail // Second call with the same token must fail
_, _, err = VerifyRefreshToken(ctx, token) _, _, _, err = VerifyRefreshToken(ctx, token)
if err == nil { if err == nil {
t.Fatal("expected error for rotated token, got nil") t.Fatal("expected error for rotated token, got nil")
} }
@@ -476,12 +476,96 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
} }
} }
// TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts verifies the reuse
// detection: generate a token → rotate it once (minting a descendant in the
// SAME family via GenerateRefreshTokenInFamily) → present the ORIGINAL token
// again. The replay must (i) delete the ENTIRE rotation family (the descendant
// included) from refresh_tokens and (ii) insert an admin_notifications row with
// reason 'refresh_token_reuse' for the user.
func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
ctx, tx := testtx.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
// 1. Generate a refresh token (new family) and rotate it once.
original, err := GenerateRefreshToken(ctx, userID, "verified_email")
if err != nil {
t.Fatalf("GenerateRefreshToken() failed: %v", err)
}
_, _, familyID, err := VerifyRefreshToken(ctx, original)
if err != nil {
t.Fatalf("first verification should succeed, got: %v", err)
}
if familyID == "" {
t.Fatal("expected non-empty family_id from rotation")
}
// 2. Mint the descendant in the SAME family (as RefreshTokenHandler does).
descendant, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
if err != nil {
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
}
var famCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
t.Fatalf("failed to count family rows: %v", err)
}
if famCount != 2 {
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
}
// 3. Replay the ORIGINAL token — theft.
_, _, _, err = VerifyRefreshToken(ctx, original)
if err == nil {
t.Fatal("expected error for replayed token, got nil")
}
if !strings.Contains(err.Error(), "invalid or expired") {
t.Errorf("expected 'invalid or expired' error, got: %v", err)
}
// (i) The entire family is revoked: the used original AND the descendant.
var famAfter int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famAfter); err != nil {
t.Fatalf("failed to count family rows after replay: %v", err)
}
if famAfter != 0 {
t.Errorf("expected 0 refresh tokens in family after reuse (descendant killed), got %d", famAfter)
}
var descHashCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
descendant).Scan(&descHashCount); err != nil {
t.Fatalf("failed to check descendant: %v", err)
}
if descHashCount != 0 {
t.Errorf("expected descendant to be deleted, got %d rows", descHashCount)
}
// (ii) An admin alert with reason 'refresh_token_reuse' exists for the user.
var alertCount int
if err := tx.QueryRow(ctx,
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
userID).Scan(&alertCount); err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if alertCount != 1 {
t.Errorf("expected 1 'refresh_token_reuse' alert, got %d", alertCount)
}
}
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake // TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
// token string and expects it to fail with "invalid or expired". // token string and expects it to fail with "invalid or expired".
func TestVerifyRefreshToken_InvalidToken(t *testing.T) { func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
ctx, _ := testtx.SetupTestTx(t) ctx, _ := testtx.SetupTestTx(t)
_, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string") _, _, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
if err == nil { if err == nil {
t.Fatal("expected error for invalid token, got nil") t.Fatal("expected error for invalid token, got nil")
} }
+10 -7
View File
@@ -1781,7 +1781,7 @@ func TestRefreshToken_Generation(t *testing.T) {
t.Errorf("expected 1 refresh_token, got %d", count) t.Errorf("expected 1 refresh_token, got %d", count)
} }
retrievedUserID, retrievedRole, err := auth.VerifyRefreshToken(ctx, refreshToken) retrievedUserID, retrievedRole, _, err := auth.VerifyRefreshToken(ctx, refreshToken)
if err != nil { if err != nil {
t.Fatalf("failed to verify refresh token: %v", err) t.Fatalf("failed to verify refresh token: %v", err)
} }
@@ -1792,7 +1792,7 @@ func TestRefreshToken_Generation(t *testing.T) {
t.Errorf("expected role 'verified_email', got %q", retrievedRole) t.Errorf("expected role 'verified_email', got %q", retrievedRole)
} }
_, _, err = auth.VerifyRefreshToken(ctx, refreshToken) _, _, _, err = auth.VerifyRefreshToken(ctx, refreshToken)
if err == nil { if err == nil {
t.Error("expected error on second refresh token verification (rotated)") t.Error("expected error on second refresh token verification (rotated)")
} }
@@ -1942,17 +1942,20 @@ func TestRefreshToken_RotatesRefreshToken_DBBacked(t *testing.T) {
t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String()) t.Fatalf("refresh failed: %d. body: %s", w.Code, w.Body.String())
} }
// The used token was deleted (rotated) and a fresh one inserted — the row // Rotation is DB-backed: the presented token was consumed (marked used, so a
// count is unchanged at 1, but the consumed token no longer verifies. // replay can be detected and the whole family revoked) and a fresh descendant
// minted in the same family. The used row is RETAINED for reuse detection, so
// 2 rows now exist for the user (used original + new descendant), and the
// consumed token no longer verifies.
var after int var after int
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&after); err != nil { if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM refresh_tokens WHERE user_id = $1", userID).Scan(&after); err != nil {
t.Fatalf("failed to count refresh tokens after refresh: %v", err) t.Fatalf("failed to count refresh tokens after refresh: %v", err)
} }
if after != 1 { if after != 2 {
t.Errorf("expected exactly 1 refresh token after rotation, got %d", after) t.Errorf("expected 2 refresh tokens after rotation (used original retained + descendant), got %d", after)
} }
if _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil { if _, _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil {
t.Error("the consumed refresh token must no longer verify (rotated)") t.Error("the consumed refresh token must no longer verify (rotated)")
} }
} }
+14 -9
View File
@@ -485,10 +485,11 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// (rotated), and exchanged for a fresh access token + a NEW refresh token. // (rotated), and exchanged for a fresh access token + a NEW refresh token.
// //
// B5 (security): the handler deliberately does NOT accept the access token. // B5 (security): the handler deliberately does NOT accept the access token.
// VerifyRefreshToken rotates (DELETEs) the presented refresh token, so a stolen // VerifyRefreshToken rotates (marks used) the presented refresh token, so a
// access token can never self-renew — it expires in 1 hour and only a valid, // stolen access token can never self-renew — it expires in 1 hour and only a
// unexpired, unrevoked refresh token can mint a new pair. A replayed refresh // valid, unexpired, unrevoked refresh token can mint a new pair. A replayed
// token (used twice) returns 401, detecting theft via rotation. // refresh token (used twice) returns 401, detecting theft via rotation and
// revoking the entire rotation family with a critical admin alert.
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
@@ -498,9 +499,11 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
refreshToken := strings.TrimPrefix(authHeader, "Bearer ") refreshToken := strings.TrimPrefix(authHeader, "Bearer ")
// VerifyRefreshToken consumes (rotates) the refresh token: the used token // VerifyRefreshToken consumes (rotates) the refresh token: the used token
// is deleted from refresh_tokens, so a stolen/leaked refresh token cannot // is marked used in refresh_tokens, so a stolen/leaked refresh token cannot
// be replayed and an access token alone can never mint a new session. // be replayed and an access token alone can never mint a new session. A
userID, role, err := auth.VerifyRefreshToken(r.Context(), refreshToken) // replayed (already-rotated) token revokes the entire rotation family and
// raises a critical admin alert, but still surfaces as this generic 401.
userID, role, familyID, err := auth.VerifyRefreshToken(r.Context(), refreshToken)
if err != nil { if err != nil {
mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"}) mw.RespondJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
return return
@@ -522,13 +525,15 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Issue a fresh access token + refresh token pair. // Issue a fresh access token + refresh token pair. The rotated refresh
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
// a replayed ancestor can revoke the whole lineage, descendants included.
newToken, jti, err := auth.GenerateToken(userID, currentRole) newToken, jti, err := auth.GenerateToken(userID, currentRole)
if err != nil { if err != nil {
mw.RespondError(w, http.StatusInternalServerError, "could not generate token") mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
return return
} }
newRefreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, currentRole) newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
if err != nil { if err != nil {
log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err) log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err)
mw.RespondError(w, http.StatusInternalServerError, "could not generate refresh token") mw.RespondError(w, http.StatusInternalServerError, "could not generate refresh token")
+51 -14
View File
@@ -2,10 +2,14 @@ package payments
import ( import (
"context" "context"
"errors"
"log" "log"
"math"
"time" "time"
"crussell/db" "crussell/db"
"github.com/jackc/pgx/v5"
) )
// EligibleDiscount describes a single discount that is currently eligible for a // EligibleDiscount describes a single discount that is currently eligible for a
@@ -266,14 +270,25 @@ func ComputeEligibleDiscounts(ctx context.Context, q db.Querier, bookingID, user
// transaction so these writes commit atomically with the payment. It is // transaction so these writes commit atomically with the payment. It is
// idempotent per booking because ComputeEligibleDiscounts excludes discounts // idempotent per booking because ComputeEligibleDiscounts excludes discounts
// whose source_id is already recorded for the booking. // whose source_id is already recorded for the booking.
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) { //
// For campaign discounts the redemption counter is incremented FIRST, as an
// ATOMIC CONDITIONAL UPDATE guarded by max_redemptions (B13): two concurrent
// payments on different bookings for the same campaign can both pass the
// caller's unlocked "is it exhausted?" read, but only the first conditional
// increment matches — the loser's UPDATE affects zero rows (a 0-row result is
// returned) and this function returns a *campaignExhaustedAtApplyError with
// NOTHING written, so the caller can surface campaign_fully_redeemed. Doing the
// reservation before the booking_discounts/payment inserts keeps the
// transaction clean when a campaign is exhausted at apply time: no discount
// rows are minted for a redemption that never happened.
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) error {
if d.IsReferral { if d.IsReferral {
if _, err := q.Exec(ctx, ` if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6) VALUES ($1, $2, 'referral', $3, NULL, NULL, $4, $5, $6)
`, bookingID, userID, d.SourceID, d.Percent, bookingTotal, d.Amount); err != nil { `, bookingID, userID, d.SourceID, d.Percent, bookingTotal, d.Amount); err != nil {
log.Printf("Failed to insert referral discount: %v", err) log.Printf("Failed to insert referral discount: %v", err)
return return nil
} }
if _, err := q.Exec(ctx, ` if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
@@ -291,34 +306,56 @@ func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID
`, d.SourceID); err != nil { `, d.SourceID); err != nil {
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err) log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
} }
return return nil
} }
var milestoneType any var milestoneType any
if d.MilestoneType != nil { if d.MilestoneType != nil {
milestoneType = *d.MilestoneType milestoneType = *d.MilestoneType
} }
// B13: atomic conditional reservation. The UPDATE increments the counter
// ONLY while the campaign still has headroom; PostgreSQL makes this safe
// under READ COMMITTED — a concurrent same-row UPDATE blocks, then
// re-evaluates this WHERE against the post-increment row, so the loser
// matches zero rows instead of over-redeeming past max_redemptions. Zero
// rows means a concurrent redemption on another booking exhausted the
// campaign between the caller's preview computation and this apply-time
// re-check; nothing has been written yet, so the caller surfaces the
// campaign_fully_redeemed path (B13).
var reservedID string
if err := q.QueryRow(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1
WHERE id = $1 AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
RETURNING id
`, d.SourceID).Scan(&reservedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &campaignExhaustedAtApplyError{campaignID: d.SourceID, lostPence: int64(math.Round(d.Amount * 100))}
}
log.Printf("ALERT: failed to reserve redemption for campaign %s, booking %s: %v — discount NOT applied", d.SourceID, bookingID, err)
return nil
}
if _, err := q.Exec(ctx, ` if _, err := q.Exec(ctx, `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, $4, $5, $6, $7, $8) VALUES ($1, $2, 'campaign', $3, $4, $5, $6, $7, $8)
`, bookingID, userID, d.SourceID, d.CampaignType, milestoneType, d.Percent, bookingTotal, d.Amount); err != nil { `, bookingID, userID, d.SourceID, d.CampaignType, milestoneType, d.Percent, bookingTotal, d.Amount); err != nil {
log.Printf("Failed to insert %s campaign discount: %v", d.CampaignType, err) // The reservation (counter increment) already stands in this tx, so the
return // redemption was consumed. Log ALERT and skip the payment record — a
// discount payment row without a booking_discounts row would be a
// ledger anomaly. max_redemptions bounds the lost reservation: the next
// eligible booking finds the campaign with one fewer redemption.
log.Printf("ALERT: failed to insert campaign discount for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
return nil
} }
if _, err := q.Exec(ctx, ` if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3) VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, d.Amount, userID); err != nil { `, bookingID, d.Amount, userID); err != nil {
// The booking_discounts row was already inserted in this tx, so the // The booking_discounts row was already inserted in this tx, so the
// campaign WAS redeemed the times_redeemed counter must still be // campaign WAS redeemed and the reservation already stands. Log ALERT
// incremented below. Log ALERT and fall through to the UPDATE instead // and return (a lost record would hide the discount from the ledger).
// of returning early (a lost increment would let the campaign exceed
// its max_redemptions cap).
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err) log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
} }
if _, err := q.Exec(ctx, ` return nil
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, d.SourceID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
} }
+8 -6
View File
@@ -527,7 +527,9 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) {
// Setup user account with some balance first // Setup user account with some balance first
_, _ = tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID) _, _ = tx.Exec(ctx, "INSERT INTO user_giftcard_balances (user_id, balance) VALUES ($1, 60.00)", adminID)
// Now pay £25 using user account balance // Now try to pay £25 using user account balance — the booking is ALREADY
// fully paid (£30 cash + £20 clamped gift card = £50), so B3 rejects the
// overcharge instead of recording it (the user balance must be untouched).
reqBody3, _ := json.Marshal(map[string]interface{}{ reqBody3, _ := json.Marshal(map[string]interface{}{
"amount": 2500, // £25.00 in pence "amount": 2500, // £25.00 in pence
"payment_type": "full", "payment_type": "full",
@@ -544,18 +546,18 @@ func TestAdminRecordPayment_CashAndGiftCard(t *testing.T) {
r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) r3.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment)
r3.ServeHTTP(w3, req3) r3.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK { if w3.Code != http.StatusBadRequest {
t.Errorf("expected status 200, got %d. Body: %s", w3.Code, w3.Body.String()) t.Errorf("expected status 400 (booking already fully paid), got %d. Body: %s", w3.Code, w3.Body.String())
} }
// Verify user account balance was deducted // Verify user account balance was NOT deducted.
var userBalance float64 var userBalance float64
err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance) err = tx.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", adminID).Scan(&userBalance)
if err != nil { if err != nil {
t.Fatalf("failed to query user balance: %v", err) t.Fatalf("failed to query user balance: %v", err)
} }
if userBalance != 35.00 { if userBalance != 60.00 {
t.Errorf("expected user balance to be 35.00, got %.2f", userBalance) t.Errorf("expected user balance unchanged at 60.00 (overcharge rejected), got %.2f", userBalance)
} }
} }
+130 -42
View File
@@ -282,6 +282,13 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
return resp return resp
} }
// maxTerminalTipPence caps the gratuity portion of a tip-enabled terminal
// checkout (B3): the frontend embeds the tip in the charge amount, so the
// booking portion must still not exceed the remaining obligation. £50 is a
// generous single-tip bound for this business; the total charge is capped at
// remaining + this bound.
const maxTerminalTipPence = int64(5000) // £50
// clampTerminalChargeToRemainingBalance caps a requested terminal charge at the // clampTerminalChargeToRemainingBalance caps a requested terminal charge at the
// booking's remaining obligation (B3). The admin "Take Payment" PaymentModal // booking's remaining obligation (B3). The admin "Take Payment" PaymentModal
// sends subtotal - discounts - campaignDiscountPence, which ignores PRIOR // sends subtotal - discounts - campaignDiscountPence, which ignores PRIOR
@@ -293,11 +300,13 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
// concurrent same-booking payment. The frontend must handle the discrepancy // concurrent same-booking payment. The frontend must handle the discrepancy
// between the amount it displayed and the clamped amount that was charged. // between the amount it displayed and the clamped amount that was charged.
// //
// A fully-paid booking (remaining <= 0) is NOT clamped: a deliberate // A fully-paid booking (remaining <= 0) is clamped to 0 (clamped=true,
// overpayment the admin records is real money received and must stay on the // effective=0): no obligation remains, so recording the requested amount
// ledger (the app's documented "overpayment handled manually at the counter" // verbatim would overcharge a customer who already paid in full. The callers
// semantics — e.g. two identical cash receipts). The clamp protects the common // reject the resulting zero-charge with 400 "already fully paid" — the only
// B3 case where prior payments left a POSITIVE remaining obligation. // legitimate money on a fully-paid booking is an EXPLICIT tip, which the
// tip-enabled terminal path handles separately (it caps the total at
// remaining + maxTerminalTipPence instead of clamping here).
func clampTerminalChargeToRemainingBalance(ctx context.Context, bookingID string, amount int64) (effective, remaining int64, clamped bool, err error) { func clampTerminalChargeToRemainingBalance(ctx context.Context, bookingID string, amount int64) (effective, remaining int64, clamped bool, err error) {
remaining, err = NewPaymentService().GetBookingRemainingBalancePence(ctx, bookingID) remaining, err = NewPaymentService().GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil { if err != nil {
@@ -306,6 +315,9 @@ func clampTerminalChargeToRemainingBalance(ctx context.Context, bookingID string
if amount > remaining && remaining > 0 { if amount > remaining && remaining > 0 {
return remaining, remaining, true, nil return remaining, remaining, true, nil
} }
if remaining <= 0 {
return 0, remaining, true, nil
}
return amount, remaining, false, nil return amount, remaining, false, nil
} }
@@ -421,14 +433,23 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// B3: clamp the recorded amount to the booking's remaining obligation. // B3: clamp the recorded amount to the booking's remaining obligation.
// The booking row FOR UPDATE lock above serializes concurrent cash/ // The booking row FOR UPDATE lock above serializes concurrent cash/
// giftcard payments on this booking, so this read races no same-method // giftcard payments on this booking, so this read races no same-method
// payment. A fully-paid booking records the requested amount verbatim // payment. A fully-paid booking is rejected below (nothing left to
// (the helper returns clamped=false for remaining <= 0). // record).
effectiveAmount, _, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount) effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil { if cErr != nil {
log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr) log.Printf("Failed to compute remaining balance for terminal %s payment on booking %s: %v", *req.PaymentMethod, bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject rather than record a phantom £0 payment —
// an overpayment is handled manually at the counter, not minted
// into the ledger.
log.Printf("Terminal %s payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", *req.PaymentMethod, bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped { if clamped {
log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount) log.Printf("Terminal %s payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", *req.PaymentMethod, bookingID, amount, effectiveAmount)
amount = effectiveAmount amount = effectiveAmount
@@ -622,12 +643,6 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
// 2FA gating (C5): charging the customer's SAVED card requires 2FA when
// the feature is enforced. Gate on the card's owner — the booking's
// user, not the admin. New-card/terminal paths are not gated.
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode) {
return
}
// Resolve the saved-card Square source for the booking's user (the // Resolve the saved-card Square source for the booking's user (the
// card's owner, not the admin) — shared new-card-vs-saved-card // card's owner, not the admin) — shared new-card-vs-saved-card
@@ -650,21 +665,6 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
} }
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID) defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
// B3: clamp the amount to the booking's remaining obligation. The
// advisory lock above serializes all same-booking payment attempts, so
// this read races no concurrent charge. A fully-paid booking records
// the requested amount verbatim (clamped=false for remaining <= 0).
effectiveAmount, _, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for saved-card payment on booking %s: %v", bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if clamped {
log.Printf("Saved-card payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", bookingID, amount, effectiveAmount)
amount = effectiveAmount
}
// Idempotency key — two tiers: // Idempotency key — two tiers:
// 1. Client-supplied per-attempt UUID (preferred): the frontend // 1. Client-supplied per-attempt UUID (preferred): the frontend
// generates one per DISTINCT charge and reuses it across retries of // generates one per DISTINCT charge and reuses it across retries of
@@ -680,6 +680,12 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// never a second charge (old-client retry safety). // never a second charge (old-client retry safety).
// Both stay ≤45 chars for Square's limit (36-char UUID / ~38-char // Both stay ≤45 chars for Square's limit (36-char UUID / ~38-char
// deterministic key). // deterministic key).
//
// The fallback is derived from the REQUEST amount (before the B3 clamp):
// a retry sends the same request and must derive the same key to hit the
// dedup SELECT below, and the clamp runs AFTER that SELECT's
// short-circuits — so a retry of an already-completed payment on a now
// fully-paid booking still dedups instead of being clamped/rejected.
scKey := req.IdempotencyKey scKey := req.IdempotencyKey
if scKey == "" { if scKey == "" {
// The candidate is built verbatim, then routed through // The candidate is built verbatim, then routed through
@@ -693,7 +699,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// Idempotency switch inside the lock: completed → dedup; pending → // Idempotency switch inside the lock: completed → dedup; pending →
// reuse (re-attempt Square with the same key, which dedups Square-side); // reuse (re-attempt Square with the same key, which dedups Square-side);
// failed → clean rejection. // failed → clean rejection. Runs BEFORE the B3 clamp so a same-key
// retry of a completed payment (on a now fully-paid booking) returns
// the existing result instead of being clamped/rejected — the money
// already moved, so the amount is no longer material.
var existingID, existingStatus sql.NullString var existingID, existingStatus sql.NullString
var existingAmount sql.NullFloat64 var existingAmount sql.NullFloat64
err = db.Conn.QueryRow(r.Context(), ` err = db.Conn.QueryRow(r.Context(), `
@@ -726,13 +735,8 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return return
case err == nil && existingStatus.String == "pending": case err == nil && existingStatus.String == "pending":
// Reuse the pending record: a prior attempt's Square outcome is // Reuse the pending record: a prior attempt's Square outcome is
// unknown. Guard the amount — a retry with a different amount must // unknown. The amount-match guard runs below, AFTER the clamp, so
// not reuse the old record's charge. // the clamped retry amount is compared against the original record.
if int64(math.Round(existingAmount.Float64*100)) != amount {
log.Printf("Saved-card retry amount mismatch: pending %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), amount)
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
return
}
paymentID = existingID.String paymentID = existingID.String
case err == nil && existingStatus.String == "failed": case err == nil && existingStatus.String == "failed":
log.Printf("Saved-card payment %s was previously marked failed (swept) — refusing retry", existingID.String) log.Printf("Saved-card payment %s was previously marked failed (swept) — refusing retry", existingID.String)
@@ -744,6 +748,56 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return return
} }
// B3: clamp the amount to the booking's remaining obligation. The
// advisory lock above serializes all same-booking payment attempts, so
// this read races no concurrent charge. Runs AFTER the idempotency
// short-circuits so a same-key retry of a completed payment (booking
// now fully paid) dedups above instead of being rejected here. A fully-
// paid booking is rejected below (nothing left to charge).
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil {
log.Printf("Failed to compute remaining balance for saved-card payment on booking %s: %v", bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject before any pending row or Square charge —
// charging £0 (or the requested overcharge) on a fully-paid booking
// is never legitimate.
log.Printf("Saved-card payment on booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped {
log.Printf("Saved-card payment on booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the customer is charged the remaining obligation only", bookingID, amount, effectiveAmount)
amount = effectiveAmount
}
// Pending-reuse amount-match guard (moved after the clamp so the
// CLAMPED retry amount is compared against the original pending record,
// which was itself created from the clamped amount): a retry with a
// different effective amount must not reuse the old record's charge.
if paymentID != "" {
if int64(math.Round(existingAmount.Float64*100)) != amount {
log.Printf("Saved-card retry amount mismatch: pending %s has %d pence, request has %d pence", existingID.String, int64(math.Round(existingAmount.Float64*100)), amount)
http.Error(w, "Amount does not match the pending payment", http.StatusBadRequest)
return
}
}
// 2FA gating (C5): charging the customer's SAVED card requires 2FA when
// the feature is enforced. Gate on the card's owner — the booking's
// user, not the admin. 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. Pending-reuse retries and fresh
// charges still pass through the gate.
if bookingUserID.Valid && !requireTwoFactorForCardAccess(w, r, service, bookingUserID.String, req.VerificationCode) {
return
}
// B13: the pre-charge discount SET for the post-charge apply-time // B13: the pre-charge discount SET for the post-charge apply-time
// re-check. The online booking path (CreateBookingPayment) keeps the set // re-check. The online booking path (CreateBookingPayment) keeps the set
// computed BEFORE the charge so applyEligibleCampaignsAtPayment can // computed BEFORE the charge so applyEligibleCampaignsAtPayment can
@@ -1051,16 +1105,39 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// An accidental overpayment must never be presented to the card reader as a // An accidental overpayment must never be presented to the card reader as a
// charge that the record path would later carve into an unintended tip. The // charge that the record path would later carve into an unintended tip. The
// advisory lock above serializes this read against concurrent same-booking // advisory lock above serializes this read against concurrent same-booking
// payments. A fully-paid booking keeps the requested amount verbatim // payments. A fully-paid booking is rejected below (nothing left to charge).
// (clamped=false for remaining <= 0).
checkoutAmount := amount checkoutAmount := amount
if !req.TipEnabled { if req.TipEnabled {
effectiveAmount, _, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount) // The tip is embedded in the amount (totalWithTip) and its value is
// unknown server-side, so cap the TOTAL at the remaining obligation
// plus a generous max tip bound: the booking portion can never exceed
// what is owed, and the tip portion can never exceed £50.
remainingPence, remErr := service.GetBookingRemainingBalancePence(r.Context(), bookingID)
if remErr != nil {
log.Printf("Failed to compute remaining balance for tip-enabled terminal checkout on booking %s: %v", bookingID, remErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
maxChargePence := remainingPence + maxTerminalTipPence
if checkoutAmount > maxChargePence {
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation %d + max tip bound £%.2f) — the requested total exceeded the booking remainder plus the tip cap", bookingID, amount, maxChargePence, remainingPence, float64(maxTerminalTipPence)/100.0)
checkoutAmount = maxChargePence
}
} else {
effectiveAmount, remaining, clamped, cErr := clampTerminalChargeToRemainingBalance(r.Context(), bookingID, amount)
if cErr != nil { if cErr != nil {
log.Printf("Failed to compute remaining balance for terminal checkout on booking %s: %v", bookingID, cErr) log.Printf("Failed to compute remaining balance for terminal checkout on booking %s: %v", bookingID, cErr)
http.Error(w, "internal server error", http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if clamped && effectiveAmount <= 0 {
// B3: the clamp zeroed the amount because the booking is fully paid
// (remaining <= 0). Reject rather than present a £0 (or overpaid)
// checkout to the card reader.
log.Printf("Terminal checkout for booking %s rejected: booking already fully paid (remaining %d pence, requested %d pence)", bookingID, remaining, amount)
http.Error(w, "Booking is already fully paid", http.StatusBadRequest)
return
}
if clamped { if clamped {
log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the card reader will present the remaining obligation only", bookingID, amount, effectiveAmount) log.Printf("Terminal checkout for booking %s clamped from %d to %d pence (remaining obligation) — the frontend PaymentModal sent an amount that ignored prior payments; the card reader will present the remaining obligation only", bookingID, amount, effectiveAmount)
checkoutAmount = effectiveAmount checkoutAmount = effectiveAmount
@@ -2367,6 +2444,11 @@ func refundLostCampaignAsBalanceCredit(ctx context.Context, bookingID, userID st
// discount row and the booking would silently not complete. In that case a // discount row and the booking would silently not complete. In that case a
// *campaignExhaustedAtApplyError is returned so the handler can surface a clear // *campaignExhaustedAtApplyError is returned so the handler can surface a clear
// "campaign fully redeemed" error and return the promised discount value. // "campaign fully redeemed" error and return the promised discount value.
// The pre-check loop below is a fast-fail only; the RACE is closed inside
// ApplyEligibleDiscount, whose atomic conditional increment (guarded by
// max_redemptions) is the real enforcement point — the loser of a concurrent
// same-campaign redemption gets a zero-row result there and the same error
// surfaces from the apply loop.
func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID, userID string, expected []EligibleDiscount) error { func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID, userID string, expected []EligibleDiscount) error {
for _, d := range expected { for _, d := range expected {
if d.Source != "campaign" { if d.Source != "campaign" {
@@ -2404,7 +2486,13 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI
continue continue
} }
d.Amount = capped d.Amount = capped
ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d) if applyErr := ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d); applyErr != nil {
var exErr *campaignExhaustedAtApplyError
if errors.As(applyErr, &exErr) {
return applyErr
}
log.Printf("Failed to apply %s discount %s for booking %s: %v", d.Source, d.SourceID, bookingID, applyErr)
}
} }
return nil return nil
} }
+10 -13
View File
@@ -68,13 +68,12 @@ func TestTerminalCash_ClampsToRemainingObligation(t *testing.T) {
assert.InDelta(t, 20.00, clamped, 0.001, "the cash payment must be clamped to the £20 remaining obligation") assert.InDelta(t, 20.00, clamped, 0.001, "the cash payment must be clamped to the £20 remaining obligation")
} }
// TestTerminalCash_FullyPaid_RecordsVerbatim locks the fully-paid edge of // TestTerminalCash_FullyPaid_RejectsOvercharge locks the fully-paid edge of
// B3(a): when the booking has no remaining obligation, a cash payment is // B3(a): when the booking has no remaining obligation, a no-tip charge is
// recorded VERBATIM (a deliberate admin overpayment is real money received and // rejected with 400 — recording the requested amount verbatim would overcharge
// must stay on the ledger — the app's "overpayment handled at the counter" // a customer who already paid in full (overpayment is handled manually at the
// semantics). The clamp only protects the common B3 case where prior payments // counter, not minted into the ledger).
// left a positive remaining obligation. func TestTerminalCash_FullyPaid_RejectsOvercharge(t *testing.T) {
func TestTerminalCash_FullyPaid_RecordsVerbatim(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -89,13 +88,11 @@ func TestTerminalCash_FullyPaid_RecordsVerbatim(t *testing.T) {
PaymentMethod: strPtr("cash"), PaymentMethod: strPtr("cash"),
} }
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "a fully-paid booking must still record the admin's deliberate overpayment, body: %s", w.Body.String()) require.Equal(t, http.StatusBadRequest, w.Code, "a charge on a fully-paid booking must be rejected, body: %s", w.Body.String())
var resp CheckoutResponse var payCount int
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "cash response must carry the payment id") require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount))
var lastCash float64 assert.Equal(t, 1, payCount, "only the £50 prior payment may exist — the overcharge must not be recorded")
require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE id = $1`, resp.CheckoutID).Scan(&lastCash))
assert.InDelta(t, 45.00, lastCash, 0.001, "the fully-paid cash receipt must be recorded verbatim")
} }
// TestTerminalSavedCard_ClampsToRemainingObligation locks B3(a) for the // TestTerminalSavedCard_ClampsToRemainingObligation locks B3(a) for the
@@ -323,6 +323,91 @@ func TestGetCheckoutStatus_TerminalTipSplit(t *testing.T) {
assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total") assert.Equal(t, int64(5000), refundable, "tips must not be part of the refundable total")
} }
// TestGetCheckoutStatus_TerminalTipSplit_DiscountedBooking locks the M4 fix
// for a discounted charge: a terminal charge priced to the DISCOUNTED amount
// plus an explicit tip must carve the tip against the DISCOUNTED obligation
// (total pending campaign discount paid), NOT the full total — otherwise
// the whole charge is booked as the booking portion and the tip is silently
// absorbed into deposit/balance as service revenue. £50 booking with a pending
// £5 (10%) campaign, charge £49.50 = £45 service + £4.50 tip → split into
// deposit £25 + balance £20 + tip £4.50; the £5 discount then mints and the
// booking completes fully paid.
func TestGetCheckoutStatus_TerminalTipSplit_DiscountedBooking(t *testing.T) {
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: square.NewDevClient(),
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
now := clock.Now()
var campaignID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0)
RETURNING id
`, "M4 Terminal Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID))
handler := CreateTerminalPayment
req := CreateTerminalPaymentRequest{
Amount: 4950, // £45 discounted service + £4.50 explicit tip
PaymentType: "full",
TipEnabled: true,
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var createResp CheckoutResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp))
require.NotEmpty(t, createResp.CheckoutID)
resp := pollCheckoutStatus(t, ctx, createResp.CheckoutID, bookingID, adminToken)
require.Equal(t, "COMPLETED", resp.Status)
// £49.50 charge on a £50 booking with a pending £5 discount → booking
// portion £45 (deposit £25 + balance £20) and tip £4.50 — the tip must NOT
// be absorbed into the booking portion. (The discount row is a separate
// payment_method='discount' ledger row and is excluded here.)
rows, err := tx.Query(ctx, `
SELECT payment_type, amount FROM payments
WHERE booking_id = $1 AND status = 'completed'
AND payment_method NOT IN ('discount', 'on_the_house')
ORDER BY payment_type
`, bookingID)
require.NoError(t, err)
defer rows.Close()
amounts := map[string]float64{}
for rows.Next() {
var pt string
var amt float64
require.NoError(t, rows.Scan(&pt, &amt))
amounts[pt] = amt
}
require.NoError(t, rows.Err())
require.Len(t, amounts, 3, "the discounted tip charge must split into deposit + balance + tip records")
assert.InDelta(t, 25.0, amounts["deposit"], 0.001, "deposit = 50% of the £50 booking total")
assert.InDelta(t, 20.0, amounts["balance"], 0.001, "balance = booking portion (£45) minus deposit")
assert.InDelta(t, 4.5, amounts["tip"], 0.001, "the £4.50 explicit tip must be carved out, not absorbed")
// The pending £5 campaign discount must still mint (real money £45 +
// discount £5 = £50) and the booking completes fully paid.
var discountPay float64
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' AND status = 'completed'`, bookingID).Scan(&discountPay))
assert.InDelta(t, 5.0, discountPay, 0.001, "the pending £5 campaign discount must mint")
var bookingStatus string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus))
assert.Equal(t, "completed", bookingStatus, "real money + discount must complete the booking")
var redeemed int
require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed))
assert.Equal(t, 1, redeemed, "the campaign redemption counter must be incremented exactly once")
}
func TestBuildTerminalSplitRecords_SplitsDepositBalanceTip(t *testing.T) { func TestBuildTerminalSplitRecords_SplitsDepositBalanceTip(t *testing.T) {
record := makeTestRecord("b-t-term", "full", 55) record := makeTestRecord("b-t-term", "full", 55)
info := &BookingPaymentInfo{ info := &BookingPaymentInfo{
@@ -348,11 +348,13 @@ func TestCreateTerminalPayment_InFlightGuard_AllowsAfterCompletion(t *testing.T)
_, bookingID, _ := setupTestData(t, ctx, tx) _, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken() adminToken := jwt.GenerateAdminToken()
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000)
pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken) pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
// Once the first checkout is recorded COMPLETED, a new charge is allowed. // Once the first checkout is recorded COMPLETED, a new charge is allowed
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000) // (B3: a second charge is clamped to the remaining obligation, so a £20
// charge after a £30 one on the £50 booking is fine).
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 2000)
if checkoutB == checkoutA { if checkoutB == checkoutA {
t.Error("expected a new checkout after the previous one completed") t.Error("expected a new checkout after the previous one completed")
} }
@@ -747,14 +749,16 @@ func TestApplyEligibleDiscount_CampaignPaymentInsertFailure_StillIncrementsCount
// The payments INSERT fails AFTER the booking_discounts row was inserted, // The payments INSERT fails AFTER the booking_discounts row was inserted,
// so the redemption happened — the counter MUST still increment. // so the redemption happened — the counter MUST still increment.
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"} failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "campaign", Source: "campaign",
Name: "Test Campaign", Name: "Test Campaign",
Percent: 10.00, Percent: 10.00,
Amount: 10.00, Amount: 10.00,
SourceID: campaignID, SourceID: campaignID,
CampaignType: "time_based", CampaignType: "time_based",
}) }); err != nil {
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
}
var redeemed int var redeemed int
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil { if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
@@ -805,14 +809,16 @@ func TestApplyEligibleDiscount_ReferralPaymentInsertFailure_StillMarksUsed(t *te
// The payments INSERT fails AFTER the referral's booking_discounts row was // The payments INSERT fails AFTER the referral's booking_discounts row was
// inserted, so the discount WAS redeemed — the used flag MUST still be set. // inserted, so the discount WAS redeemed — the used flag MUST still be set.
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"} failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "referral", Source: "referral",
Name: "Referral Discount (10%)", Name: "Referral Discount (10%)",
Percent: 10.00, Percent: 10.00,
Amount: 10.00, Amount: 10.00,
SourceID: rdID, SourceID: rdID,
IsReferral: true, IsReferral: true,
}) }); err != nil {
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
}
var used bool var used bool
if err := tx.QueryRow(ctx, `SELECT used FROM referral_discounts WHERE id = $1`, rdID).Scan(&used); err != nil { if err := tx.QueryRow(ctx, `SELECT used FROM referral_discounts WHERE id = $1`, rdID).Scan(&used); err != nil {
@@ -831,30 +837,43 @@ func TestApplyEligibleDiscount_ReferralPaymentInsertFailure_StillMarksUsed(t *te
} }
} }
func TestApplyEligibleDiscount_BookingDiscountsInsertFailure_NoCounterIncrement(t *testing.T) { func TestApplyEligibleDiscount_BookingDiscountsInsertFailure_CounterStillReserved(t *testing.T) {
// The booking_discounts-INSERT failure is the ONE early return that is // B13 reservation-first semantics: the campaign counter increment (the
// correct: nothing was recorded, so the redemption never happened and the // atomic conditional reservation) happens BEFORE the booking_discounts
// campaign counter must NOT increment. // insert, so a booking_discounts-INSERT failure no longer leaves the
// counter untouched — the redemption slot was consumed and max_redemptions
// bounds the lost reservation (the next eligible booking finds the campaign
// with one fewer redemption). No discount payment row is minted.
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx) userID, bookingID, _ := setupTestData(t, ctx, tx)
campaignID := seedTestCampaign(t, ctx, tx) campaignID := seedTestCampaign(t, ctx, tx)
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO booking_discounts"} failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO booking_discounts"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "campaign", Source: "campaign",
Name: "Test Campaign", Name: "Test Campaign",
Percent: 10.00, Percent: 10.00,
Amount: 10.00, Amount: 10.00,
SourceID: campaignID, SourceID: campaignID,
CampaignType: "time_based", CampaignType: "time_based",
}) }); err != nil {
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
}
var redeemed int var redeemed int
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil { if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
t.Fatalf("failed to read campaign counter: %v", err) t.Fatalf("failed to read campaign counter: %v", err)
} }
if redeemed != 0 { if redeemed != 1 {
t.Errorf("expected times_redeemed unchanged (0) when the booking_discounts insert fails, got %d", redeemed) t.Errorf("expected times_redeemed incremented to 1 by the reservation despite the booking_discounts insert failure, got %d", redeemed)
}
var discountPayments int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil {
t.Fatalf("failed to count discount payments: %v", err)
}
if discountPayments != 0 {
t.Errorf("expected NO discount payment row (the booking_discounts insert was simulated to fail), got %d", discountPayments)
} }
} }
+10 -6
View File
@@ -4366,11 +4366,13 @@ func TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup(t *testing.T) {
handler := CreateTerminalPayment handler := CreateTerminalPayment
// Two legitimately distinct £50 'full' charges on the same booking — the // Two legitimately distinct £20 'full' charges on the same booking — the
// frontend sends a different per-attempt UUID for each. // frontend sends a different per-attempt UUID for each. Each stays within
// the £50 booking's remaining obligation (a second £50 charge would be a
// B3 overcharge on the now fully-paid booking and correctly rejected).
for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} { for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} {
reqBody := CreateTerminalPaymentRequest{ reqBody := CreateTerminalPaymentRequest{
Amount: 5000, Amount: 2000,
PaymentType: "full", PaymentType: "full",
PaymentMethod: strPtr("saved_card"), PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID, UserSavedCardID: &cardID,
@@ -4450,9 +4452,11 @@ func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
} }
// TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/ // TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/
// giftcard branch: two identical £50 cash receipts on the same booking are // giftcard branch: two identical cash receipts on the same booking are
// legitimate distinct payments and must each insert their own row (there is // legitimate distinct payments and must each insert their own row (there is
// deliberately no idempotency dedup in this branch). // deliberately no idempotency dedup in this branch). Each £20 receipt stays
// within the £50 booking's remaining obligation (B3 clamps only what exceeds
// the remaining balance, and a fully-paid booking rejects further charges).
func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) { func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -4466,7 +4470,7 @@ func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) {
handler := CreateTerminalPayment handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{ reqBody := CreateTerminalPaymentRequest{
Amount: 5000, Amount: 2000,
PaymentType: "full", PaymentType: "full",
PaymentMethod: strPtr("cash"), PaymentMethod: strPtr("cash"),
} }
+206 -2
View File
@@ -1457,8 +1457,27 @@ type manualPendingRow struct {
// otherwise stay 'pending' forever, blocking the over-refund guard. They are // otherwise stay 'pending' forever, blocking the over-refund guard. They are
// marked 'failed' and surfaced in the admin notification centre, mirroring the // marked 'failed' and surfaced in the admin notification centre, mirroring the
// Square-less cancellation pre-pass (refunds.go:554-583) with a DISTINCT // Square-less cancellation pre-pass (refunds.go:554-583) with a DISTINCT
// origin='manual' filter so the two passes never double-process a row. // origin='manual' filter so the two passes never double-process a row. Rows
// WITH a square_refund_id are exempt: a sweep auto-refund of a replay-induced
// duplicate charge (B1) attaches its refunds row to the still-pending parent
// payment (which has no square_payment_id) — demoting it here would kill an
// in-flight refund whose parent Square later settles.
//
// A B1 re-poll pass runs FIRST (sweepPendingB1Refunds): it re-polls sweep
// auto-refunds Square left PENDING by square_refund_id and, ONLY when Square
// reports the refund COMPLETED, marks the parent payment/till-sale row failed
// and claws back a funded gift card — the duplicate charge has been reversed
// and only then is the money state settled.
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
// (a0) B1-origin sweep auto-refunds (refunds rows with a square_refund_id
// that Square left PENDING): re-poll each by square_refund_id and
// resolve the parent row ONLY on COMPLETED. Runs before the passes
// below so an in-flight B1 refund is never demoted or re-issued.
b1Count, b1Err := sweepPendingB1Refunds(ctx)
if b1Err != nil {
log.Printf("Failed to re-poll B1 sweep auto-refunds: %v", b1Err)
}
// (a) Terminal pre-pass: legacy MANUAL card refunds whose payment has no // (a) Terminal pre-pass: legacy MANUAL card refunds whose payment has no
// Square reference can never be refunded via Square → mark them failed // Square reference can never be refunded via Square → mark them failed
// so they stop blocking the over-refund guard, and surface the affected // so they stop blocking the over-refund guard, and surface the affected
@@ -1467,7 +1486,9 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
// refunds.go:554-583) with origin='manual' so the filters stay distinct // refunds.go:554-583) with origin='manual' so the filters stay distinct
// and no row is swept by both passes. Must run OUTSIDE any GROUP BY — // and no row is swept by both passes. Must run OUTSIDE any GROUP BY —
// Postgres lumps NULLs together, so these rows can't be handled in the // Postgres lumps NULLs together, so these rows can't be handled in the
// per-payment grouping below. // per-payment grouping below. Rows WITH a square_refund_id are exempt —
// a B1 auto-refund attaches to a parent payment with no square_payment_id
// and its in-flight refund must not be demoted to failed.
rows, err := db.Conn.Query(ctx, fmt.Sprintf(` rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed' UPDATE refunds r SET status = 'failed'
FROM payments p FROM payments p
@@ -1476,6 +1497,7 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
AND p.payment_method IN ('online_square', 'in_person_card') AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL AND p.square_payment_id IS NULL
AND r.origin = 'manual' AND r.origin = 'manual'
AND r.square_refund_id IS NULL
RETURNING r.id RETURNING r.id
`, maxManualRefundAttempts)) `, maxManualRefundAttempts))
if err != nil { if err != nil {
@@ -1559,9 +1581,191 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
} }
processed += n processed += n
} }
return processed + b1Count, nil
}
// b1PendingRefund is one refunds row for a sweep auto-refund of a
// replay-induced duplicate charge (B1) that Square left PENDING.
type b1PendingRefund struct {
RefundID string
PaymentID string // refunds.payment_id — the parent payment row
Amount float64
SquareRefundID string
IdempotencyKey string // deterministic "sweepdup-" + duplicate payment id (payments-table rows)
Reason string // carries the parent till_sale id for till_sale rows
SquarePaymentID string // synthetic till-sale payment row's square_payment_id ("" for payments-table rows)
CreatedBy string
}
// sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of
// replay-induced duplicate charges (B1, refundSweepDuplicateCharge in sweep.go)
// that Square left PENDING. The refund is NON-terminal: the parent row must
// stay pending (never marked failed, a funded gift card never clawed back)
// until Square settles. When Square reports the refund COMPLETED the parent is
// finally resolved — the pending payment marked failed, and a till sale's
// funded gift card clawed back (the duplicate charge has been reversed; only
// then is the money state settled). FAILED/REJECTED refunds are left pending
// for the webhook FAILED-refund reconciliation (coordinated); a reconcile
// error is an UNKNOWN state and is never resolved here. The stale-pending
// sweeps skip rows with an in-flight B1 refund (hasInFlightSweepDuplicateRefund,
// sweep.go), so the parent is only ever resolved from here.
func sweepPendingB1Refunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.square_refund_id, COALESCE(r.idempotency_key, ''),
r.reason, COALESCE(p.square_payment_id, ''), COALESCE(r.created_by, '')
FROM refunds r
LEFT JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.square_refund_id IS NOT NULL
AND r.origin = 'manual' AND r.reason LIKE 'duplicate charge — sweep replay%'
ORDER BY r.id
`)
if err != nil {
log.Printf("Failed to query B1 sweep auto-refunds for re-poll: %v", err)
return 0, nil
}
defer rows.Close()
var pending []b1PendingRefund
for rows.Next() {
var pr b1PendingRefund
if err := rows.Scan(&pr.RefundID, &pr.PaymentID, &pr.Amount, &pr.SquareRefundID, &pr.IdempotencyKey, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedBy); err != nil {
log.Printf("Failed to scan B1 sweep auto-refund: %v", err)
continue
}
pending = append(pending, pr)
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate B1 sweep auto-refunds: %v", err)
return 0, nil
}
if len(pending) == 0 {
return 0, nil
}
processed := 0
for i := range pending {
pr := &pending[i]
// The Square payment id the refund targets. A till_sale's refund is
// attached to the synthetic payments row (square_payment_id = the
// duplicate charge); a payments-table refund is attached to the
// still-pending payment row (no square_payment_id), so the duplicate id
// is recovered from the deterministic refund idempotency key.
dupPayID := pr.SquarePaymentID
if dupPayID == "" {
dupPayID = strings.TrimPrefix(pr.IdempotencyKey, "sweepdup-")
if dupPayID == pr.IdempotencyKey {
log.Printf("Cannot re-poll B1 refund %s: no Square payment id and the stored key %q is not the sweepdup form — leaving pending", pr.RefundID, pr.IdempotencyKey)
continue
}
}
refundList, lErr := SquareClient.ListPaymentRefunds(ctx, dupPayID, time.Time{})
if lErr != nil {
log.Printf("Re-poll of B1 refund %s failed (%v) — leaving pending", pr.RefundID, lErr)
continue
}
status := ""
for j := range refundList {
if refundList[j].ID == pr.SquareRefundID {
status = refundList[j].Status
break
}
}
switch status {
case "COMPLETED":
if resolveB1RefundCompleted(ctx, pr) {
processed++
}
case "PENDING", "APPROVED":
log.Printf("B1 refund %s is still %q at Square — leaving the parent row pending", pr.RefundID, status)
case "":
log.Printf("B1 refund %s (%s) was not found at Square via ListPaymentRefunds — leaving pending; manual reconciliation may be required", pr.RefundID, dupPayID)
default:
// FAILED / REJECTED — the webhook FAILED-refund reconciliation
// (coordinated) owns this terminal state; never resolve it here.
log.Printf("B1 refund %s is %q at Square — leaving pending for the webhook FAILED-refund reconciliation", pr.RefundID, status)
}
}
return processed, nil return processed, nil
} }
// resolveB1RefundCompleted resolves a B1 sweep auto-refund that Square reports
// COMPLETED: the refunds row is marked completed (money moved), and the parent
// row — the pending payment, or the pending till sale whose gift-card funding
// is clawed back — is resolved to failed. Returns true when the parent was
// resolved.
func resolveB1RefundCompleted(ctx context.Context, pr *b1PendingRefund) bool {
if _, err := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1 AND status = 'pending'
`, pr.RefundID); err != nil {
log.Printf("Failed to mark B1 refund %s completed after Square settle: %v", pr.RefundID, err)
}
if pr.Reason == sweepDuplicateRefundReason {
// payments-table parent: the refund's payment_id IS the pending row.
if failStaleRow(ctx, "payments", pr.PaymentID) {
log.Printf("B1 refund %s COMPLETED at Square — marked the pending payment %s failed (the duplicate charge was reversed)", pr.RefundID, pr.PaymentID)
return true
}
log.Printf("B1 refund %s COMPLETED at Square but payment %s was already resolved", pr.RefundID, pr.PaymentID)
return false
}
// till_sale parent: the sale id is encoded in the reason.
if idx := strings.Index(pr.Reason, "(till_sale "); idx >= 0 {
tillSaleID := strings.TrimSuffix(pr.Reason[idx+len("(till_sale "):], ")")
ts, ok := loadTillSaleStaleRow(ctx, tillSaleID)
if !ok {
log.Printf("CRITICAL: B1 refund %s COMPLETED at Square but parent till sale %s could not be loaded — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
return false
}
if ts.HasGiftCard {
if clawbackTillSaleFunding(ctx, ts) {
log.Printf("B1 refund %s COMPLETED at Square — clawed back till sale %s's funded gift card and marked it failed", pr.RefundID, tillSaleID)
return true
}
log.Printf("CRITICAL: B1 refund %s COMPLETED at Square but clawing back till sale %s's funding failed — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
return false
}
if failStaleRow(ctx, "till_sales", tillSaleID) {
log.Printf("B1 refund %s COMPLETED at Square — marked till sale %s failed", pr.RefundID, tillSaleID)
return true
}
log.Printf("B1 refund %s COMPLETED at Square but till sale %s was already resolved", pr.RefundID, tillSaleID)
return false
}
log.Printf("B1 refund %s COMPLETED at Square but the parent row could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", pr.RefundID, pr.Reason)
return false
}
// loadTillSaleStaleRow reads a till_sale's gift-card context for the B1
// re-poll pass's clawback — the same fields fetchStaleRows/scanStaleRow
// populate for the stale-pending sweep.
func loadTillSaleStaleRow(ctx context.Context, id string) (staleRow, bool) {
var r staleRow
var itemID, redeemedBy sql.NullString
var isCreate *bool
var hasGiftCard bool
var total float64
err := db.Conn.QueryRow(ctx, `
SELECT ts.id, ts.item_id, gc.redeemed_by, (ts.created_at = gc.created_at) AS is_create,
(gc.id IS NOT NULL) AS has_gift_card, ts.total_amount
FROM till_sales ts
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
WHERE ts.id = $1
`, id).Scan(&r.ID, &itemID, &redeemedBy, &isCreate, &hasGiftCard, &total)
if err != nil {
log.Printf("Failed to load till sale %s for B1 refund clawback: %v", id, err)
return r, false
}
r.ItemID = itemID.String
if redeemedBy.Valid && redeemedBy.String != "" {
r.RedeemToUserID = &redeemedBy.String
}
r.IsCreate = isCreate != nil && *isCreate
r.HasGiftCard = hasGiftCard
r.TotalAmount = total
r.AmountPence = int64(math.Round(total * 100))
return r, true
}
// ensureRefundKey returns the idempotency key to use when re-issuing a manual // ensureRefundKey returns the idempotency key to use when re-issuing a manual
// refund, persisting a generated fallback to the refunds row BEFORE Square is // refund, persisting a generated fallback to the refunds row BEFORE Square is
// called so every retry reuses the SAME key — Square dedups same-key retries, // called so every retry reuses the SAME key — Square dedups same-key retries,
+247 -30
View File
@@ -220,6 +220,14 @@ func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolv
return 0, 0, err return 0, 0, err
} }
for _, r := range stale { for _, r := range stale {
// B1: a row whose auto-refund of a replay-induced duplicate charge is
// still PENDING at Square must never be blind-failed (or have its gift
// card clawed back) — the refund is non-terminal and may still settle.
// The B1 re-poll pass resolves it on settlement.
if hasInFlightSweepDuplicateRefund(ctx, table, r.ID) {
log.Printf("Stale pending %s row %s has a sweep auto-refund of a duplicate charge still pending at Square — leaving pending until the refund settles", table, r.ID)
continue
}
if r.SquarePaymentID != "" { if r.SquarePaymentID != "" {
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) { switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
case staleReconcileCompleted: case staleReconcileCompleted:
@@ -323,6 +331,15 @@ func sweepKeyedStaleRows(ctx context.Context, table string, cutoff time.Time) (r
// a row older than that when swept can no longer be replayed trustworthily. // a row older than that when swept can no longer be replayed trustworthily.
replayExpired := clock.Now().Add(-stalePendingPaymentAge) replayExpired := clock.Now().Add(-stalePendingPaymentAge)
for _, r := range stale { for _, r := range stale {
// B1: a row whose auto-refund of a replay-induced duplicate charge is
// still PENDING at Square must be left alone — re-playing its expired
// idempotency key can land ANOTHER charge, and blind-failing or clawing
// back while the refund is non-terminal would reverse money that may
// still come back. The B1 re-poll pass resolves it on settlement.
if hasInFlightSweepDuplicateRefund(ctx, table, r.ID) {
log.Printf("Stale pending %s row %s has a sweep auto-refund of a duplicate charge still pending at Square — leaving pending until the refund settles", table, r.ID)
continue
}
if r.CreatedAt.Before(replayExpired) { if r.CreatedAt.Before(replayExpired) {
// Key retention window already closed — replaying would misread an // Key retention window already closed — replaying would misread an
// expired key as "never charged". Blind-fail + WARN exactly as the // expired key as "never charged". Blind-fail + WARN exactly as the
@@ -948,11 +965,16 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
// failed (the original charge was never found — the caller's // failed (the original charge was never found — the caller's
// definitively-failed branch does that; a till sale's gift-card // definitively-failed branch does that; a till sale's gift-card
// funding is clawed back there too, since the duplicate has been // funding is clawed back there too, since the duplicate has been
// refunded). Only a refund FAILURE keeps the CRITICAL // refunded). A refund Square leaves PENDING is NON-terminal: the row
// manual-reconciliation path. // stays pending (no fail, no clawback) and the B1 re-poll pass
// resolves it when Square settles. Only a refund FAILURE keeps the
// CRITICAL manual-reconciliation path.
if refundErr := refundSweepDuplicateCharge(ctx, table, r, pr); refundErr == nil { if refundErr := refundSweepDuplicateCharge(ctx, table, r, pr); refundErr == nil {
log.Printf("stale pending %s row %s: the replayed COMPLETED payment %s was a NEW charge under an expired idempotency key (lag %s) — auto-refunded the duplicate at Square and marking the row failed (the original charge was never found)", table, r.ID, pr.ID, lag) log.Printf("stale pending %s row %s: the replayed COMPLETED payment %s was a NEW charge under an expired idempotency key (lag %s) — auto-refunded the duplicate at Square and marking the row failed (the original charge was never found)", table, r.ID, pr.ID, lag)
return staleReconcileDefinitivelyFailed, "" return staleReconcileDefinitivelyFailed, ""
} else if errors.Is(refundErr, errSweepRefundPending) {
log.Printf("stale pending %s row %s: the auto-refund of the replay-induced duplicate charge %s is PENDING at Square (non-terminal) — leaving the row pending; the B1 refund re-poll resolves it when Square settles", table, r.ID, pr.ID)
return staleReconcileLeavePending, ""
} else { } else {
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key; auto-refund FAILED (%v) — leaving the row PENDING — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag, refundErr) return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created after the pending row %s (lag %s) — a NEW charge under an expired idempotency key; auto-refund FAILED (%v) — leaving the row PENDING — MANUAL RECONCILIATION REQUIRED: check Square for both charges and refund the duplicate", table, pr.ID, r.ID, lag, refundErr)
} }
@@ -970,6 +992,38 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
} }
} }
// errSweepRefundPending is the sentinel refundSweepDuplicateCharge returns when
// Square accepted the auto-refund of a replay-induced duplicate charge but left
// it PENDING — a NON-terminal state: the refund may still complete (the
// duplicate is reversed) or fail. The caller must NOT treat it as success: the
// parent row is left PENDING (never marked failed, a till sale's funded gift
// card is never clawed back) so a later sweep run can re-poll the refund. The
// refund's idempotency key ("sweepdup-"+paymentID) is deterministic, so the
// re-poll finds the SAME refund at Square.
var errSweepRefundPending = errors.New("sweep duplicate-charge refund pending at Square")
// sweepDuplicateRefundReason is the audit-trail reason carried by every
// refunds row for a sweep auto-refund of a replay-induced duplicate charge
// (B1). The B1 re-poll pass (sweepPendingB1Refunds, refunds.go) and the
// in-flight guard (hasInFlightSweepDuplicateRefund) match on it, so it must
// stay in lockstep with refunds.go. For a till_sale the reason carries the
// parent till_sale id (see sweepDuplicateRefundReasonFor) — refunds has no
// till_sale column and the refund must link back to the sale the re-poll pass
// claws back when Square settles.
const sweepDuplicateRefundReason = "duplicate charge — sweep replay"
// sweepDuplicateRefundReasonFor returns the refunds-row reason for a sweep
// auto-refund. tillSaleID is "" for payments-table rows (whose parent is the
// refund's own payment_id); a till_sale's parent is encoded in the reason
// because refunds.payment_id is FK'd to payments and the sale has no payments
// row of its own.
func sweepDuplicateRefundReasonFor(tillSaleID string) string {
if tillSaleID == "" {
return sweepDuplicateRefundReason
}
return sweepDuplicateRefundReason + " (till_sale " + tillSaleID + ")"
}
// refundSweepDuplicateCharge auto-refunds a replayed COMPLETED payment that the // refundSweepDuplicateCharge auto-refunds a replayed COMPLETED payment that the
// sweep proved to be a NEW charge under an expired idempotency key (B1) — money // sweep proved to be a NEW charge under an expired idempotency key (B1) — money
// the customer never authorized. The refund reuses the existing Square refund // the customer never authorized. The refund reuses the existing Square refund
@@ -983,13 +1037,23 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
// gets a fresh key and is refunded too; // gets a fresh key and is refunded too;
// - reason "duplicate charge — sweep replay" for the audit trail. // - reason "duplicate charge — sweep replay" for the audit trail.
// //
// On success a refunds row is recorded for payments-table rows (a till_sale // A refunds row is recorded for BOTH payments-table and till_sale rows (a
// row has no payments row to attach the refund to — the Square refund plus the // till_sale's refund attaches to a synthetic payments row created for the
// deduped critical-payment admin notification cover the audit trail there) and // duplicate charge, since refunds.payment_id is FK'd to payments; the insert
// an admin notification is inserted so an operator sees the auto-refund. The // is idempotent on the deterministic refund key, whose UNIQUE constraint
// caller then marks the row definitively failed (the ORIGINAL charge was never // doubles as the dedup guard), and an admin notification is inserted so an
// found). Any failure leaves the money state at Square untouched and returns // operator sees the auto-refund.
// the error so the caller keeps the CRITICAL manual-reconciliation path. //
// Return semantics:
//
// - nil — the refund COMPLETED at Square; the caller marks the row
// definitively failed (the ORIGINAL charge was never found) and, for a till
// sale, claws back the funded gift card — the duplicate has been reversed;
// - errSweepRefundPending — the refund is PENDING (non-terminal); the caller
// leaves the row pending; the B1 re-poll pass (sweepPendingB1Refunds,
// refunds.go) resolves the row when Square settles;
// - any other error — the money state at Square is untouched; the caller
// keeps the CRITICAL manual-reconciliation path.
func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, pr *square.PaymentResult) error { func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, pr *square.PaymentResult) error {
if pr == nil || pr.ID == "" { if pr == nil || pr.ID == "" {
return errors.New("replayed payment has no Square payment id to refund") return errors.New("replayed payment has no Square payment id to refund")
@@ -1004,39 +1068,127 @@ func refundSweepDuplicateCharge(ctx context.Context, table string, r staleRow, p
if len(refundKey) > maxIdempotencyKeyLength { if len(refundKey) > maxIdempotencyKeyLength {
refundKey = truncateIdempotencyKey("sweepdup", pr.ID) refundKey = truncateIdempotencyKey("sweepdup", pr.ID)
} }
reason := "duplicate charge — sweep replay"
res, refundErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ res, refundErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.ID, PaymentID: pr.ID,
Amount: r.AmountPence, Amount: r.AmountPence,
IdempotencyKey: refundKey, IdempotencyKey: refundKey,
Reason: reason, Reason: sweepDuplicateRefundReason,
}) })
if refundErr != nil { if refundErr != nil {
return fmt.Errorf("auto-refund of replay-induced duplicate charge %s failed: %w", pr.ID, refundErr) return fmt.Errorf("auto-refund of replay-induced duplicate charge %s failed: %w", pr.ID, refundErr)
} }
status := "completed" status := "completed"
if res.Status == "PENDING" { pending := false
switch res.Status {
case "PENDING", "APPROVED":
// Square's non-terminal refund states — the money has not moved yet but
// the refund is in flight. NON-terminal: the caller must leave the row
// pending and never claw back a funded gift card (reversing the funding
// before Square settles the refund could leave a customer charged with
// no gift card if the refund later fails).
status = "pending" status = "pending"
} else if res.Status == "FAILED" || res.Status == "REJECTED" { pending = true
case "FAILED", "REJECTED":
// Square definitively rejected the refund — the duplicate charge stands. // Square definitively rejected the refund — the duplicate charge stands.
return fmt.Errorf("auto-refund of replay-induced duplicate charge %s was %s at Square", pr.ID, res.Status) return fmt.Errorf("auto-refund of replay-induced duplicate charge %s was %s at Square", pr.ID, res.Status)
} }
if table == "payments" {
amountPounds := float64(r.AmountPence) / 100.0 recordSweepDuplicateRefundRow(ctx, table, r, pr, res.ID, status, refundKey)
if _, insErr := db.Conn.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, 'manual', $6, $7, $8, NOW())
`, r.ID, r.BookingID, amountPounds, res.ID, status, reason, refundKey, r.CreatedBy); insErr != nil {
log.Printf("CRITICAL: auto-refunded duplicate charge %s at Square (refund %s) but recording the refunds row for pending %s failed: %v — MANUAL RECONCILIATION REQUIRED", pr.ID, res.ID, r.ID, insErr)
}
}
insertCriticalPaymentNotification(ctx, r.BookingID, r.CreatedBy) insertCriticalPaymentNotification(ctx, r.BookingID, r.CreatedBy)
if pending {
log.Printf("Auto-refund of replay-induced duplicate charge %s (%d pence) for pending row %s is PENDING at Square (refund %s) — leaving the row pending for the refund re-poll", pr.ID, r.AmountPence, r.ID, res.ID)
return errSweepRefundPending
}
log.Printf("Auto-refunded replay-induced duplicate charge %s (%d pence) for pending row %s — refund %s", pr.ID, r.AmountPence, r.ID, res.ID) log.Printf("Auto-refunded replay-induced duplicate charge %s (%d pence) for pending row %s — refund %s", pr.ID, r.AmountPence, r.ID, res.ID)
return nil return nil
} }
// recordSweepDuplicateRefundRow writes the refunds row for a sweep auto-refund
// of a replay-induced duplicate charge (B1), idempotently on the deterministic
// refund idempotency key (UNIQUE — a re-run can never mint a second row). For
// a payments-table row the refund attaches to the pending payment row itself.
// A till_sale has no payments row (refunds.payment_id is NOT NULL + FK), so a
// synthetic payments row is created FIRST — 'completed' (the duplicate charge
// genuinely landed at Square) with the duplicate's square_payment_id, so the
// B1 re-poll pass can look the refund up by payment id — and the refund is
// attached to it. The parent till_sale id is carried in the reason so the
// re-poll pass claws it back when Square settles.
func recordSweepDuplicateRefundRow(ctx context.Context, table string, r staleRow, pr *square.PaymentResult, refundID, status, refundKey string) {
amountPounds := float64(r.AmountPence) / 100.0
paymentID := r.ID
var bookingID *string
reason := sweepDuplicateRefundReason
if table == "payments" {
bookingID = r.BookingID
} else {
// till_sale: create the synthetic payments row for the duplicate charge.
payKey := truncateIdempotencyKey("sweepdup-pay", pr.ID)
err := db.Conn.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at)
VALUES ('full', 'in_person_card', 'completed', $1, $2, $3, $4, NOW())
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id
`, amountPounds, pr.ID, payKey, r.CreatedBy).Scan(&paymentID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("CRITICAL: auto-refunded duplicate charge %s at Square (refund %s) but creating the payments row for till sale %s failed: %v — MANUAL RECONCILIATION REQUIRED", pr.ID, refundID, r.ID, err)
return
}
// Re-run: the synthetic row already exists — reuse it.
if rErr := db.Conn.QueryRow(ctx, `SELECT id FROM payments WHERE idempotency_key = $1`, payKey).Scan(&paymentID); rErr != nil {
log.Printf("CRITICAL: auto-refunded duplicate charge %s at Square (refund %s) but re-reading the payments row for till sale %s failed: %v — MANUAL RECONCILIATION REQUIRED", pr.ID, refundID, r.ID, rErr)
return
}
}
reason = sweepDuplicateRefundReasonFor(r.ID)
}
if _, insErr := db.Conn.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, 'manual', $6, $7, $8, NOW())
ON CONFLICT (idempotency_key) DO UPDATE SET status = EXCLUDED.status, square_refund_id = EXCLUDED.square_refund_id
`, paymentID, bookingID, amountPounds, refundID, status, reason, refundKey, r.CreatedBy); insErr != nil {
log.Printf("CRITICAL: auto-refunded duplicate charge %s at Square (refund %s) but recording the refunds row failed: %v — MANUAL RECONCILIATION REQUIRED", pr.ID, refundID, insErr)
}
}
// hasInFlightSweepDuplicateRefund reports whether a stale pending row carries a
// sweep auto-refund of a replay-induced duplicate charge (B1) that Square left
// PENDING. While the refund is in flight the row must NOT be replayed (a replay
// of the expired-key row could land ANOTHER charge), blind-failed or clawed
// back (the refund is non-terminal — money may still reverse). The B1 re-poll
// pass (sweepPendingB1Refunds, refunds.go) resolves the parent row when Square
// settles. For a till_sale the refund row's reason carries the sale id (see
// sweepDuplicateRefundReasonFor); a payments-table refund's payment_id IS the
// parent row.
func hasInFlightSweepDuplicateRefund(ctx context.Context, table, id string) bool {
var exists bool
var err error
if table == "till_sales" {
err = db.Conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM refunds
WHERE status = 'pending' AND square_refund_id IS NOT NULL
AND reason = $1
)
`, sweepDuplicateRefundReasonFor(id)).Scan(&exists)
} else {
err = db.Conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM refunds
WHERE payment_id = $1 AND status = 'pending' AND square_refund_id IS NOT NULL
AND reason = $2
)
`, id, sweepDuplicateRefundReason).Scan(&exists)
}
if err != nil {
log.Printf("Failed to check for an in-flight sweep duplicate refund on %s row %s: %v", table, id, err)
return false
}
return exists
}
// leaveGiftCardPurchasePending keeps a gift-card-purchase payment row (payments // leaveGiftCardPurchasePending keeps a gift-card-purchase payment row (payments
// table, booking_id NULL) pending after Square confirms the charge COMPLETED, // table, booking_id NULL) pending after Square confirms the charge COMPLETED,
// instead of rescuing it to 'completed'. Completing the row would permanently // instead of rescuing it to 'completed'. Completing the row would permanently
@@ -1745,6 +1897,14 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
UpdatedAt: clock.Now(), UpdatedAt: clock.Now(),
} }
// The booking user is read here (before the M4 carve) because the carve
// computes the campaign discounts applyEligibleCampaignsAtPayment is about
// to apply below, and that apply needs the payer's user id.
var bookingUserID string
if err := tx.QueryRow(ctx, `SELECT COALESCE(user_id, '') FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil {
slog.Error("Failed to load booking user for terminal campaign apply", "booking_id", bookingID, "err", err)
}
// M4 split: a terminal charge above the remaining booking value is a tip — // M4 split: a terminal charge above the remaining booking value is a tip —
// record it as its own record so only the booking portion is refundable. // record it as its own record so only the booking portion is refundable.
// B3: only when the customer EXPLICITLY requested a tip (checkout // B3: only when the customer EXPLICITLY requested a tip (checkout
@@ -1758,13 +1918,50 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
if bErr == nil && bookingInfo != nil { if bErr == nil && bookingInfo != nil {
charged := float64(pr.Amount) / 100.0 charged := float64(pr.Amount) / 100.0
remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid) remainingBookingValue := math.Max(0, bookingInfo.TotalAmount-bookingInfo.TotalPaid)
// Reserve the headroom of the campaign discounts that
// applyEligibleCampaignsAtPayment is about to mint: a
// terminal charge already priced to the discounted amount (the frontend
// charges totaldiscount plus any explicit tip) must carve the tip
// against the DISCOUNTED obligation, not the full total — otherwise the
// whole charge lands as booking portion and the tip is silently
// absorbed into deposit/balance instead of being recorded as gratuity
// (M4). The no-tip case is unchanged: the booking portion exactly
// covers the discounted obligation and the discount fills the rest.
if pending := pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount); pending > 0.004 {
remainingBookingValue = math.Max(0, remainingBookingValue-pending)
}
bookingPortion := math.Min(charged, remainingBookingValue) bookingPortion := math.Min(charged, remainingBookingValue)
bookingPortion = math.Round(bookingPortion*100) / 100 bookingPortion = math.Round(bookingPortion*100) / 100
tipAmount := math.Round((charged-bookingPortion)*100) / 100 tipAmount := math.Max(0, math.Round((charged-bookingPortion)*100)/100)
if checkoutTipEnabled && tipAmount > 0.004 { if checkoutTipEnabled && tipAmount > 0.004 {
records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount) records = buildTerminalSplitRecords(record, bookingInfo, bookingPortion, tipAmount)
} }
} }
hasTip := false
for _, rec := range records {
if rec.PaymentType == "tip" {
hasTip = true
break
}
}
// Apply eligible campaigns BEFORE the records are inserted when the M4
// carve minted a TIP record and a campaign discount is pending. The apply
// path refuses discounts once a booking has 2+ completed real payments, and
// the split records (deposit + balance + tip) would count as 2+ even though
// they are ONE charge — refusing the discount the frontend already priced
// in would leave the booking under-discounted and never complete. Applying
// before the insert is money-safe: a tip record only exists when the charge
// exceeds the DISCOUNTED obligation, so the booking portion plus the
// pending discount can never exceed the total — the F1 full-amount
// over-credit cannot occur here (a full-amount charge never produces a tip
// record, so it keeps the after-insert apply whose cap refuses it).
appliedCampaignsBeforeInsert := false
if hasTip && bookingInfo != nil && bErr == nil {
if pendingCampaignDiscountAmount(ctx, tx, bookingID, bookingUserID, bookingInfo.TotalAmount) > 0.004 {
applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil)
appliedCampaignsBeforeInsert = true
}
}
if len(records) == 0 { if len(records) == 0 {
records = []PaymentRecord{record} records = []PaymentRecord{record}
} }
@@ -1804,13 +2001,14 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
// skip re-application via their already-recorded guards. The apply runs // skip re-application via their already-recorded guards. The apply runs
// AFTER the payment record insert so the headroom counts the charge as real // AFTER the payment record insert so the headroom counts the charge as real
// money (never over-credits), and after the M4 tip split so tip records // money (never over-credits), and after the M4 tip split so tip records
// never affect the discount computation. // never affect the discount computation. When the M4 carve minted a tip
var bookingUserID string // record the discount was already applied BEFORE the insert (see above), so
if err := tx.QueryRow(ctx, `SELECT COALESCE(user_id, '') FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil { // the apply is skipped here — a second run is idempotent but unnecessary.
slog.Error("Failed to load booking user for terminal campaign apply", "booking_id", bookingID, "err", err) // The booking user was already read above for the M4 carve.
} if !appliedCampaignsBeforeInsert {
if applyErr := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil); applyErr != nil { if applyErr := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, bookingUserID, nil); applyErr != nil {
slog.Error("Failed to apply eligible campaigns for terminal checkout", "booking_id", bookingID, "err", applyErr) slog.Error("Failed to apply eligible campaigns for terminal checkout", "booking_id", bookingID, "err", applyErr)
}
} }
// Release the in-flight guard: this checkout is now recorded. // Release the in-flight guard: this checkout is now recorded.
@@ -1831,6 +2029,25 @@ func recordTerminalPaymentTx(ctx context.Context, tx pgx.Tx, checkoutID, booking
return paymentID, nil return paymentID, nil
} }
// pendingCampaignDiscountAmount sums the campaign discounts that
// applyEligibleCampaignsAtPayment is about to apply for the booking (its
// 'expected' argument is nil in the terminal flow, so it recomputes the
// eligible set from scratch). The discount rows do not exist yet when the M4
// tip carve runs — they are minted later in the same transaction — so the
// carve must reserve their headroom NOW or a terminal charge already priced to
// the discounted amount absorbs the customer's explicit tip into the booking
// portion (M4). Referral discounts are excluded: the terminal apply path never
// mints them.
func pendingCampaignDiscountAmount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64) float64 {
var total float64
for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) {
if d.Source == "campaign" {
total += d.Amount
}
}
return math.Round(total*100) / 100
}
// recordUntrackedTillSalePayment records a stale card-machine till sale whose // recordUntrackedTillSalePayment records a stale card-machine till sale whose
// checkout COMPLETED at Square but was never polled/recorded: the sale is // checkout COMPLETED at Square but was never polled/recorded: the sale is
// marked 'completed' with the returned square_payment_id written back. A // marked 'completed' with the returned square_payment_id written back. A
+240
View File
@@ -837,6 +837,246 @@ func TestSweepStalePendingPayments_KeyedReplayNewCharge_AutoRefunded(t *testing.
} }
} }
// pendingRefundClient forces RefundPayment to return a PENDING result so the
// B1 auto-refund branch is exercised: Square accepted the refund but left it
// non-terminal.
type pendingRefundClient struct {
square.SquareClient
}
func (c *pendingRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) {
res, err := c.SquareClient.RefundPayment(ctx, req)
if err != nil {
return nil, err
}
res.Status = "PENDING"
return res, nil
}
// settledRefundClient reports every refund Square holds as COMPLETED — as if a
// PENDING refund settled — so the B1 re-poll pass resolves the parent row.
type settledRefundClient struct {
square.SquareClient
}
func (c *settledRefundClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
refunds, err := c.SquareClient.ListPaymentRefunds(ctx, paymentID, beginTime)
if err != nil {
return nil, err
}
for i := range refunds {
refunds[i].Status = "COMPLETED"
}
return refunds, nil
}
// TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending
// locks the B1 PENDING fix: when Square accepts the auto-refund of a
// replay-induced duplicate charge but leaves it PENDING (non-terminal), the
// sweep must NOT mark the parent payment failed and must record the refunds
// row with status 'pending' + square_refund_id. The re-poll pass
// (SweepPendingSquareRefunds) then resolves the parent row ONLY once Square
// reports the refund COMPLETED. Sequential (flips SQUARE_ENVIRONMENT), like the
// sibling B1 tests.
func TestSweepStalePendingPayments_KeyedReplayNewCharge_RefundPending_LeavesRowPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
const key = "key-expired-replay-pending-refund"
const dupPayID = "pay_expired_key_pending_refund"
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'ccof:test-saved-card', created_by = $2 WHERE id = $3", key, userID, staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
Status: "COMPLETED",
ID: dupPayID,
SquarePayID: dupPayID,
CreatedAt: clock.Now().Format(time.RFC3339),
}}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
// PENDING refund → NON-terminal: the parent row must NOT be marked failed.
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "pending" {
t.Errorf("expected a PENDING auto-refund to leave the row pending (never failed), got %q", status)
}
// The in-flight refund must be recorded with status 'pending' + square_refund_id.
var refundStatus string
var sqRefundID *string
var reason string
if err := db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id, reason FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus, &sqRefundID, &reason); err != nil {
t.Fatalf("failed to query refunds row: %v", err)
}
if refundStatus != "pending" {
t.Errorf("expected the refunds row to be pending, got %q", refundStatus)
}
if sqRefundID == nil || *sqRefundID == "" {
t.Error("expected the refunds row to carry the Square refund id")
}
if reason != sweepDuplicateRefundReason {
t.Errorf("expected reason %q, got %q", sweepDuplicateRefundReason, reason)
}
// Now the refund settles at Square: the re-poll pass resolves the parent.
SquareClient = &settledRefundClient{SquareClient: mock}
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("refund re-poll sweep failed: %v", err)
}
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to re-query payment: %v", err)
}
if status != "failed" {
t.Errorf("expected a COMPLETED refund to resolve the parent row to failed, got %q", status)
}
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundStatus); err != nil {
t.Fatalf("failed to re-query refunds row: %v", err)
}
if refundStatus != "completed" {
t.Errorf("expected the refunds row to complete once Square settles, got %q", refundStatus)
}
}
// TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClawback
// locks the B1 PENDING fix for till sales: a PENDING auto-refund of a
// replay-induced duplicate charge must leave the till sale pending with its
// funded gift card intact (NO clawback, NO fail), and the refunds row must be
// recorded (attached to a synthetic payments row for the duplicate charge)
// with the parent sale encoded in the reason. Once Square settles the refund
// COMPLETED, the re-poll pass claws back the funding and marks the sale failed.
// Sequential (flips SQUARE_ENVIRONMENT), like the sibling B1 tests.
func TestSweepStalePendingPayments_KeyedTillReplayNewCharge_RefundPending_NoClawback(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
pool := context.Background()
saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true)
// Age the sale AND its created gift card inside the key window (23h,
// created_at equality preserved → is_create stays true) and add the key.
const dupPayID = "pay_expired_key_till_pending_refund"
if _, err := tx.Exec(ctx, "UPDATE till_sales SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = 'key-till-expired-pending-refund', square_source_id = 'ccof:test-saved-card' WHERE id = $1", saleID); err != nil {
t.Fatalf("failed to age the till sale: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE gift_cards SET created_at = NOW() - INTERVAL '23 hours' WHERE id = $1", giftCardID); err != nil {
t.Fatalf("failed to age the gift card: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient()
t.Setenv("SQUARE_ENVIRONMENT", "production")
SquareClient = &pendingRefundClient{SquareClient: &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
Status: "COMPLETED",
ID: dupPayID,
SquarePayID: dupPayID,
CreatedAt: clock.Now().Format(time.RFC3339),
}}}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
if _, err := SweepStalePendingPayments(pool); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "pending" {
t.Errorf("expected a PENDING auto-refund to leave the till sale pending (never failed, no clawback), got %q", status)
}
var cardCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 1 {
t.Errorf("expected the funded gift card NOT clawed back while the refund is pending, got %d cards", cardCount)
}
// The refund row exists with the parent till sale encoded in the reason.
var reason, refundStatus string
if err := db.Conn.QueryRow(pool, `SELECT reason, status FROM refunds WHERE reason = $1`, sweepDuplicateRefundReasonFor(saleID)).Scan(&reason, &refundStatus); err != nil {
t.Fatalf("failed to query B1 refund row: %v", err)
}
if refundStatus != "pending" {
t.Errorf("expected the B1 refunds row to be pending, got %q", refundStatus)
}
// Refund settles COMPLETED → re-poll claws back the funding and fails the sale.
SquareClient = &settledRefundClient{SquareClient: mock}
if _, err := SweepPendingSquareRefunds(pool); err != nil {
t.Fatalf("refund re-poll sweep failed: %v", err)
}
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to re-query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected a COMPLETED refund to resolve the till sale to failed, got %q", status)
}
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if cardCount != 0 {
t.Errorf("expected the created gift card clawed back after the refund settled COMPLETED, got %d cards", cardCount)
}
}
// TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending // TestSweepStalePendingPayments_KeyedReplayUnparseableCreatedAt_LeavesPending
// locks the B1 caveat: a replayed COMPLETED payment whose created_at CANNOT be // locks the B1 caveat: a replayed COMPLETED payment whose created_at CANNOT be
// parsed must NOT be auto-refunded. An unparseable created_at does not prove // parsed must NOT be auto-refunded. An unparseable created_at does not prove
+7 -4
View File
@@ -75,11 +75,14 @@ func (s *PaymentService) UserTwoFactorEnabled(ctx context.Context, userID string
// stored pending 2FA code. It is a thin delegation shim over // stored pending 2FA code. It is a thin delegation shim over
// twofa.VerifyForUser — the single source of truth for the verification core // twofa.VerifyForUser — the single source of truth for the verification core
// (per-user brute-force lockout, constant-time compare, legacy pre-pepper // (per-user brute-force lockout, constant-time compare, legacy pre-pepper
// hash fallback, code lifetime). It returns nil on a valid code, or a // hash fallback, code lifetime). consume=true is passed so a verified code is
// classified twofa.ErrIncorrect / twofa.ErrLockedOut / twofa.ErrMissingOrExpired // SINGLE-USE: the gate NULLs the pending code on success, so one code
// (or a wrapped DB error) for the caller to map to the correct HTTP status. // authorizes exactly one saved-card charge (not unlimited charges for its
// 10-minute lifetime). It returns nil on a valid code, or a classified
// twofa.ErrIncorrect / twofa.ErrLockedOut / twofa.ErrMissingOrExpired (or a
// wrapped DB error) for the caller to map to the correct HTTP status.
func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error { func verifyPendingTwoFactorCode(ctx context.Context, userID, code string) error {
return twofa.VerifyForUser(ctx, userID, code) return twofa.VerifyForUser(ctx, userID, code, true)
} }
// requireTwoFactorForCardAccess gates the saved-card online payment paths // requireTwoFactorForCardAccess gates the saved-card online payment paths
+32
View File
@@ -13,6 +13,7 @@ package payments
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -176,6 +177,37 @@ func TestRequireTwoFactorForCardAccess_Enforced(t *testing.T) {
}) })
} }
// TestRequireTwoFactorForCardAccess_CodeIsSingleUse pins the finding-1 fix: a
// code verified through the gate is CONSUMED (the pending code is NULLed), so
// the same code cannot authorize a second saved-card charge within its
// 10-minute lifetime. The second attempt with the same code is denied with the
// documented "expired — request a new one" 400.
func TestRequireTwoFactorForCardAccess_CodeIsSingleUse(t *testing.T) {
helperEnvEnforce2FA(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
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"), "first use of the code must pass the gate")
require.Equal(t, http.StatusOK, w.Code)
// The verified code must now be consumed (NULLed) in the DB.
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "a verified gate code must be consumed (NULLed)")
// A second charge attempt with the same code must be denied as expired.
w = httptest.NewRecorder()
require.False(t, requireTwoFactorForCardAccess(w, req, NewPaymentService(), userID, "424242"), "a consumed code must not pass the gate twice")
require.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.Equal(t, "Verification code expired — request a new one", body["error"])
}
// TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the // TestTwoFactorEnforced_CreateBookingPayment_SaveCard_Blocked verifies the
// end-to-end gate on the save-card path: enforced + user without 2FA → 403 with // end-to-end gate on the save-card path: enforced + user without 2FA → 403 with
// no payment row and no saved card (Square never called). // no payment row and no saved card (Square never called).
+1 -1
View File
@@ -213,7 +213,7 @@ func TestPasswordChange_RevokesTokens(t *testing.T) {
} }
// The consumed refresh token no longer verifies. // The consumed refresh token no longer verifies.
if _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil { if _, _, _, err := auth.VerifyRefreshToken(ctx, refreshToken); err == nil {
t.Error("refresh token must be invalid after a password change (B9)") t.Error("refresh token must be invalid after a password change (B9)")
} }
} }
+103 -14
View File
@@ -322,9 +322,11 @@ const (
// DisableTwoFAHandler and VerifyTwoFACodeForUser. The caller must hold st.Mu // DisableTwoFAHandler and VerifyTwoFACodeForUser. The caller must hold st.Mu
// (from twoFAAttemptStateFor) so concurrent attempts from the same user cannot // (from twoFAAttemptStateFor) so concurrent attempts from the same user cannot
// race the limit check. Delegates to the shared implementation in // race the limit check. Delegates to the shared implementation in
// crussell/internal/twofa. // crussell/internal/twofa with consume=false: the interactive setup/disable
// flows clear the pending code themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through the whole handshake here.
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) { func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
res, err := twofa.Check(r.Context(), userID, st, reqCode) res, err := twofa.Check(r.Context(), userID, st, reqCode, false)
return twoFACodeCheckResult(res), err return twoFACodeCheckResult(res), err
} }
@@ -345,7 +347,7 @@ func VerifyTwoFACodeForUser(ctx context.Context, userID, code string) error {
st.Mu.Lock() st.Mu.Lock()
defer st.Mu.Unlock() defer st.Mu.Unlock()
result, err := twofa.Check(ctx, userID, st, code) result, err := twofa.Check(ctx, userID, st, code, false)
if err != nil { if err != nil {
return err return err
} }
@@ -506,7 +508,7 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
st.Mu.Lock() st.Mu.Lock()
defer st.Mu.Unlock() defer st.Mu.Unlock()
if err := ensurePendingTwoFACode(r, userID, st); err != nil { if _, err := ensurePendingTwoFACode(r, userID, st, "disable 2FA"); err != nil {
if errors.Is(err, errTwoFAMintThrottled) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return return
@@ -525,6 +527,84 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// POST /api/user/2fa/code
// Lets an ENABLED user request a fresh verification code for a saved-card
// charge (the B6/B10 gate). This closes the enforced-deployment dead-end where
// 2FA setup clears the pending code and SetupTwoFAHandler refuses already
// enabled users (409): without it there is no way to mint a code for a
// saved-card charge, so every charge returned 400 "Verification code expired —
// request a new one" with no way to get a new one.
//
// The mint machinery is shared with the disable flow: ensurePendingTwoFACode
// reuses a still-valid pending code when one exists and otherwise mints +
// delivers a fresh one via the same build-dependent channel as setup
// (deliverTwoFACode — [2FA] log in dev/test; pepper- and delivery-channel
// gated in production). Fresh-code mints are throttled per-user
// (twoFAMintCooldown) and never reset the failed-attempt counter (B11b).
//
// Contract: 200 {"message":"Code sent"} (+ a dev-only "code" field when 2FA is
// unenforced, matching setup); 409 when the user has not enabled 2FA; 429 on
// the mint cooldown; 503 when no delivery channel is configured (production
// without TWO_FACTOR_ALLOW_LOG_DELIVERY=true); 500 on DB failure. The route is
// mounted with RequireAuth + RequireNonGuest + the shared per-user 2FA limiter
// (plus the group's per-IP limiter), so an enabled user cannot hammer code
// requests faster than the surface budget.
func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var enabled bool
err := db.Conn.QueryRow(r.Context(), `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil {
log.Printf("failed to check 2FA state for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if !enabled {
http.Error(w, "Two-factor authentication is not enabled", http.StatusConflict)
return
}
// The per-user mutex serializes the mint with the charge gate's verify
// critical section so concurrent requests from the same user cannot race
// the cooldown or lockout counters.
st := twoFAAttemptStateFor(userID)
st.Mu.Lock()
defer st.Mu.Unlock()
code, err := ensurePendingTwoFACode(r, userID, st, "saved-card charge")
if err != nil {
if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return
}
if errors.Is(err, errTwoFADeliveryUnavailable) {
// Production with no delivery channel: no fresh code can be minted,
// so the saved-card charge cannot be re-challenged. Surface the
// actionable setup error instead of a silent 500.
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
log.Printf("failed to prepare 2FA code for saved-card charge for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
resp := map[string]any{"message": "Code sent"}
if !twoFARequired() && code != "" {
// Dev convenience (matches setup): return the freshly minted code so
// the request path is testable without grepping the backend log. The
// code is never included when 2FA is enforced.
resp["code"] = code
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("failed to encode 2FA code response: %v", err)
}
}
// POST /api/user/2fa/disable // POST /api/user/2fa/disable
// Turns 2FA off and clears method + pending fields for the authenticated user. // Turns 2FA off and clears method + pending fields for the authenticated user.
// //
@@ -578,7 +658,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// The per-user mint cooldown still bounds how often a fresh code can be // The per-user mint cooldown still bounds how often a fresh code can be
// minted — at most one per twoFAMintCooldown — but it cannot grant a fresh // minted — at most one per twoFAMintCooldown — but it cannot grant a fresh
// guessing budget. // guessing budget.
if err := ensurePendingTwoFACode(r, userID, st); err != nil { if _, err := ensurePendingTwoFACode(r, userID, st, "disable 2FA"); err != nil {
if errors.Is(err, errTwoFAMintThrottled) { if errors.Is(err, errTwoFAMintThrottled) {
http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests) http.Error(w, "Too many attempts. Wait before requesting a new code.", http.StatusTooManyRequests)
return return
@@ -625,8 +705,16 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending // ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// code to verify against, generating + delivering a fresh one via the same // code to verify against, generating + delivering a fresh one via the same
// build-dependent delivery channel as setup (see deliverTwoFACode) when the // build-dependent delivery channel as setup (see deliverTwoFACode) when the
// stored code is missing or expired. The caller must hold the user's // stored code is missing or expired. purpose labels the delivery for the [2FA]
// attempt-state mutex. // log line (e.g. "disable 2FA", "saved-card charge"). The caller must hold the
// user's attempt-state mutex.
//
// It returns the plaintext code only when a FRESH code was minted and
// delivered (dev/test builds always deliver it; production builds only when
// the operator opted into log delivery — see twofa_prod.go). When a valid
// pending code was reused, the return is empty: only the digest is stored, so
// the plaintext is unavailable. Callers must only expose the returned code in
// unenforced environments (matching SetupTwoFAHandler's dev convenience).
// //
// Minting a fresh code does NOT reset the failed-attempt counter (B11b): the // Minting a fresh code does NOT reset the failed-attempt counter (B11b): the
// counter resets only on a successful verify or when the 10-minute attempt // counter resets only on a successful verify or when the 10-minute attempt
@@ -638,7 +726,7 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// who exhausts the budget must wait out the window, not the mint cooldown. A // who exhausts the budget must wait out the window, not the mint cooldown. A
// failed delivery does not start the cooldown (the stamp is written only after // failed delivery does not start the cooldown (the stamp is written only after
// the UPDATE persisted). // the UPDATE persisted).
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState) error { func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, purpose string) (string, error) {
var pendingHash sql.NullString var pendingHash sql.NullString
var pendingExpires sql.NullTime var pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), ` err := db.Conn.QueryRow(r.Context(), `
@@ -647,20 +735,21 @@ func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptStat
WHERE id = $1 WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires) `, userID).Scan(&pendingHash, &pendingExpires)
if err != nil { if err != nil {
return err return "", err
} }
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) { if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return nil return "", nil
} }
now := clock.Now() now := clock.Now()
if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown { if !st.LastMintAt.IsZero() && now.Sub(st.LastMintAt) < twoFAMintCooldown {
return errTwoFAMintThrottled return "", errTwoFAMintThrottled
} }
if _, err := deliverTwoFACode(r, userID, "", "disable 2FA"); err != nil { code, err := deliverTwoFACode(r, userID, "", purpose)
return err if err != nil {
return "", err
} }
st.LastMintAt = now st.LastMintAt = now
return nil return code, nil
} }
// disableTwoFA clears two_factor_enabled and the method + pending code fields. // disableTwoFA clears two_factor_enabled and the method + pending code fields.
+134
View File
@@ -1251,6 +1251,140 @@ func TestTwoFADisableCode_UnenforcedStillMints(t *testing.T) {
require.True(t, pendingHash.Valid, "unenforced env must still mint a pending code") require.True(t, pendingHash.Valid, "unenforced env must still mint a pending code")
} }
// TestTwoFASendVerificationCode_Enabled_MintsFresh verifies POST
// /api/user/2fa/code: an ENABLED user with no pending code gets a fresh code
// minted + delivered ([2FA] log labelled "saved-card charge"), with only the
// hash + a future expiry persisted and no code in the enforced response.
func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) {
twofaEnvEnforced(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp["message"])
_, hasCode := resp["code"]
require.False(t, hasCode, "enforced env must NOT return the code in the response")
var pendingHash sql.NullString
var expires sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users WHERE id = $1`, userID).Scan(&pendingHash, &expires))
require.True(t, pendingHash.Valid, "endpoint must mint a pending code hash")
require.True(t, expires.Valid && expires.Time.After(clock.Now()), "minted code must have a future expiry")
require.Contains(t, buf.String(), "saved-card charge", "delivery log must label the charge purpose")
require.Regexp(t, regexp.MustCompile(`\[2FA\].*\d{6}`), buf.String(), "endpoint must log the code as the delivery channel")
}
// TestTwoFASendVerificationCode_ReusesValidPendingCode verifies that a valid
// unexpired pending code is reused (the stored hash is unchanged) instead of a
// fresh mint, so a mid-flow charge retry is not throttled.
func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid)
require.Equal(t, hashTwoFACode("123456"), pendingHash.String, "existing valid pending code must be reused, not re-minted")
}
// TestTwoFASendVerificationCode_NotEnabled_409 verifies that a user who has NOT
// enabled 2FA is refused with 409 (the endpoint exists only to re-challenge an
// enabled user's saved-card charge; setup covers the not-enabled path).
func TestTwoFASendVerificationCode_NotEnabled_409(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
}
// TestTwoFASendVerificationCode_MintThrottled verifies the per-user mint
// cooldown applies: a second code request inside twoFAMintCooldown returns 429.
// The pending code is dropped first (as a lockout does) because a still-valid
// code is reused by ensurePendingTwoFACode, which short-circuits the cooldown.
func TestTwoFASendVerificationCode_MintThrottled(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Drop the pending code so the next request cannot reuse it and must hit
// the cooldown check instead.
_, err = tx.Exec(ctx, `UPDATE users
SET two_factor_pending_code_hash = NULL, two_factor_pending_code_expires = NULL
WHERE id = $1`, userID)
require.NoError(t, err)
w = performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "Too many attempts. Wait before requesting a new code.")
}
// TestTwoFASendVerificationCode_Unenforced_ReturnsCode verifies the dev
// convenience: in an unenforced env the endpoint mints and returns the code in
// the response (matching setup), and the DB holds the digest of exactly it.
func TestTwoFASendVerificationCode_Unenforced_ReturnsCode(t *testing.T) {
twofaEnvUnenforced(t)
t.Setenv("TWO_FACTOR_PEPPER", "")
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp struct {
Message string `json:"message"`
Code string `json:"code"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "Code sent", resp.Message)
require.Len(t, resp.Code, 6, "unenforced env must return the dev-convenience code")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID).Scan(&pendingHash))
require.True(t, pendingHash.Valid)
sum := sha256.Sum256([]byte(resp.Code))
require.Equal(t, hex.EncodeToString(sum[:]), pendingHash.String, "stored hash must be the SHA-256 of the returned code")
}
// TestTwoFASendVerificationCode_Unauthorized verifies that an unauthenticated
// request is rejected with 401 before any minting happens.
func TestTwoFASendVerificationCode_Unauthorized(t *testing.T) {
w := performUser2FARequest(t, SendVerificationCodeHandler, context.Background(), http.MethodPost, "/api/user/2fa/code", nil, "")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
// TestTwoFACodeVerifyForUser exercises the exported helper that backs the // TestTwoFACodeVerifyForUser exercises the exported helper that backs the
// B6/B10 payments gate (the saved-card charge must present a real 2FA // B6/B10 payments gate (the saved-card charge must present a real 2FA
// challenge): correct code → nil, wrong code → twofa.ErrIncorrect, exhausting // challenge): correct code → nil, wrong code → twofa.ErrIncorrect, exhausting
+46 -8
View File
@@ -11,7 +11,7 @@
// //
// Contract for the payments gate: // Contract for the payments gate:
// //
// err := twofa.VerifyForUser(ctx, userID, code) // err := twofa.VerifyForUser(ctx, userID, code, true) // consume = true
// if err != nil { // if err != nil {
// switch { // switch {
// case errors.Is(err, twofa.ErrIncorrect): // case errors.Is(err, twofa.ErrIncorrect):
@@ -25,6 +25,14 @@
// } // }
// } // }
// //
// A correct code is SINGLE-USE on the payments gate: the gate passes
// consume=true, so the stored pending-code digest and its expiry are NULLed in
// the same critical section as the successful check. One code therefore
// authorizes exactly one saved-card charge, never unlimited charges for its
// 10-minute lifetime. The interactive setup/disable flows pass consume=false —
// they clear the pending fields themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through their whole handshake.
//
// The failed-attempt counter is keyed per user and resets ONLY on a successful // The failed-attempt counter is keyed per user and resets ONLY on a successful
// verify (or after the 10-minute attempt window elapses) — never on a fresh // verify (or after the 10-minute attempt window elapses) — never on a fresh
// code mint, so minting a new code cannot grant a fresh guessing budget (B11b). // code mint, so minting a new code cannot grant a fresh guessing budget (B11b).
@@ -259,10 +267,14 @@ const (
// check. A correct code resets the attempt counter and returns OK. An incorrect // check. A correct code resets the attempt counter and returns OK. An incorrect
// code increments the counter and, on the 5th consecutive failure, invalidates // code increments the counter and, on the 5th consecutive failure, invalidates
// the pending code (lockout). A missing or expired pending code returns // the pending code (lockout). A missing or expired pending code returns
// MissingOrExpired. The returned error is non-nil only for DB failures // MissingOrExpired. consume makes a correct code single-use: the stored digest
// and its expiry are NULLed immediately, so one code cannot authorize a second
// operation within its lifetime (the payments saved-card gate passes true; the
// interactive setup/disable flows pass false and clear the pending fields
// themselves on success). The returned error is non-nil only for DB failures
// (callers return 500); a lockout's pending-code invalidation failure is logged // (callers return 500); a lockout's pending-code invalidation failure is logged
// here and still reported as a lockout. // here and still reported as a lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string) (Result, error) { func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow { if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0) st.Count.Store(0)
st.SetLastActive(now) st.SetLastActive(now)
@@ -309,8 +321,11 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string)
} }
// Success: a legacy (pre-pepper) hash that verified is re-hashed with the // Success: a legacy (pre-pepper) hash that verified is re-hashed with the
// pepper so the plain digest is retired on the next successful verify. // pepper so the plain digest is retired on the next successful verify. This
if legacy { // matters only for the interactive paths (consume=false), where the pending
// code stays valid for the rest of the handshake — consume mode destroys
// the digest outright, so there is nothing to upgrade.
if legacy && !consume {
if _, err := db.Conn.Exec(ctx, ` if _, err := db.Conn.Exec(ctx, `
UPDATE users UPDATE users
SET two_factor_pending_code_hash = $2 SET two_factor_pending_code_hash = $2
@@ -325,6 +340,25 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string)
st.SetLastActive(clock.Now()) st.SetLastActive(clock.Now())
st.LastMintAt = time.Time{} st.LastMintAt = time.Time{}
ResetAttempts(userID) ResetAttempts(userID)
if consume {
// Consume mode (the payments saved-card gate, B6/B10): a verified code
// is single-use. NULL the stored digest and its expiry so the same code
// cannot authorize a second saved-card charge within its 10-minute
// lifetime. The interactive setup/disable flows pass consume=false:
// they clear the pending fields themselves on success (enableTwoFA /
// disableTwoFA), so the code must stay valid through the whole
// verification handshake here. The write goes through the same
// context-routed connection as the rest of Check, so verification and
// consumption are one unit.
if _, err := db.Conn.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
WHERE id = $1
`, userID); err != nil {
log.Printf("failed to consume 2FA pending code for user %s: %v", userID, err)
}
}
return OK, nil return OK, nil
} }
@@ -345,13 +379,17 @@ var (
// It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut / // It returns nil on a correct code, or one of ErrIncorrect / ErrLockedOut /
// ErrMissingOrExpired (or a DB error, wrapped). This is the entry point for // ErrMissingOrExpired (or a DB error, wrapped). This is the entry point for
// the payments card-access gate (B6/B10): a saved-card charge must present a // the payments card-access gate (B6/B10): a saved-card charge must present a
// real, freshly-verified challenge. // real, freshly-verified challenge. consume makes a correct code single-use:
func VerifyForUser(ctx context.Context, userID, code string) error { // the pending-code digest and its expiry are NULLed in the same critical
// section as the successful check (see Check), so one code authorizes exactly
// one gate pass. The interactive setup/disable flows pass false — they clear
// the pending fields themselves on success (enableTwoFA / disableTwoFA).
func VerifyForUser(ctx context.Context, userID, code string, consume bool) error {
st := StateFor(userID) st := StateFor(userID)
st.Mu.Lock() st.Mu.Lock()
defer st.Mu.Unlock() defer st.Mu.Unlock()
result, err := Check(ctx, userID, st, code) result, err := Check(ctx, userID, st, code, consume)
if err != nil { if err != nil {
return fmt.Errorf("2FA verify: %w", err) return fmt.Errorf("2FA verify: %w", err)
} }
+21 -6
View File
@@ -10,6 +10,7 @@ package twofa
import ( import (
"context" "context"
"database/sql"
"testing" "testing"
"time" "time"
@@ -33,12 +34,26 @@ func seedPending(t *testing.T, ctx context.Context, tx db.Querier, userID, code
func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) { func TestVerifyForUser_CorrectAndWrongCode(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// consume=false (interactive setup/disable path): a success keeps the
// pending code valid, so a wrong follow-up code reports ErrIncorrect.
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
seedPending(t, ctx, tx, userID, "123456") seedPending(t, ctx, tx, userID, "123456")
require.NoError(t, VerifyForUser(ctx, userID, "123456", false), "correct code must verify")
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", false), ErrIncorrect)
require.NoError(t, VerifyForUser(ctx, userID, "123456"), "correct code must verify") // consume=true (payments saved-card gate path): a success DESTROYS the
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrIncorrect) // pending code, so re-verifying the same code reports ErrMissingOrExpired
// — a verified code is single-use and cannot authorize a second charge.
userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
seedPending(t, ctx, tx, userID2, "123456")
require.NoError(t, VerifyForUser(ctx, userID2, "123456", true), "correct code must verify")
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", true), ErrMissingOrExpired, "a consumed code must be single-use")
var pendingHash sql.NullString
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_pending_code_hash FROM users WHERE id = $1", userID2).Scan(&pendingHash))
require.False(t, pendingHash.Valid, "a consumed code must be NULLed in the DB")
} }
func TestVerifyForUser_LockoutAndMissing(t *testing.T) { func TestVerifyForUser_LockoutAndMissing(t *testing.T) {
@@ -48,16 +63,16 @@ func TestVerifyForUser_LockoutAndMissing(t *testing.T) {
seedPending(t, ctx, tx, userID, "123456") seedPending(t, ctx, tx, userID, "123456")
// Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap. // Wrong code #1 → ErrIncorrect; four more reach the 5-attempt cap.
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrIncorrect) require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", true), ErrIncorrect)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
_ = VerifyForUser(ctx, userID, "999999") _ = VerifyForUser(ctx, userID, "999999", true)
} }
require.ErrorIs(t, VerifyForUser(ctx, userID, "999999"), ErrLockedOut) require.ErrorIs(t, VerifyForUser(ctx, userID, "999999", true), ErrLockedOut)
// A fresh user with no pending code → ErrMissingOrExpired. // A fresh user with no pending code → ErrMissingOrExpired.
userID2, err := fixtures.CreateTestUser(tx) userID2, err := fixtures.CreateTestUser(tx)
require.NoError(t, err) require.NoError(t, err)
require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456"), ErrMissingOrExpired) require.ErrorIs(t, VerifyForUser(ctx, userID2, "123456", true), ErrMissingOrExpired)
} }
// TestVerifyForUser_AttemptStateMapPersists exercises the shared per-user // TestVerifyForUser_AttemptStateMapPersists exercises the shared per-user
+13 -6
View File
@@ -624,22 +624,29 @@ func main() {
// able to reach these, not just verified accounts. // able to reach these, not just verified accounts.
r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler) r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler)
// The code-issuing/verifying endpoints get a dedicated per-user // The code-issuing/verifying endpoints get a dedicated per-user
// limiter (10/min) on top of the group's generic 120/min limiter: // limiter (10/min) on top of the group's generic 120/min per-IP
// limiter:
// the 6-digit codes live in a 1M space, so a single user must not be // the 6-digit codes live in a 1M space, so a single user must not be
// able to hammer setup/verify/disable faster than the per-user // able to hammer setup/verify/disable/code-mint faster than the
// 5-attempt lockout can trip. The key is the authenticated userID // per-user 5-attempt lockout can trip. The key is the authenticated
// ALONE (RateLimitByUser) — NOT user+IP (B8): with the IP in the // userID ALONE (RateLimitByUser) — NOT user+IP (B8): with the IP in
// key, a client that can rotate its source IP (or that sits behind // the key, a client that can rotate its source IP (or that sits behind
// a proxy echoing a client-supplied CF-Connecting-IP when // a proxy echoing a client-supplied CF-Connecting-IP when
// TRUST_PROXY_HEADERS=true) mints a fresh bucket per IP for the // TRUST_PROXY_HEADERS=true) mints a fresh bucket per IP for the
// same account, collapsing the per-account budget. One shared // same account, collapsing the per-account budget. One shared
// limiter for all four so the whole 2FA surface counts against a // limiter for all five so the whole 2FA surface counts against a
// single per-user budget. // single per-user budget.
twoFALimiter := mw.RateLimitByUser(10, time.Minute) twoFALimiter := mw.RateLimitByUser(10, time.Minute)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable/code", user.SendDisableCodeHandler) r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable/code", user.SendDisableCodeHandler)
// Fresh-code request for an already-ENABLED user making a saved-card
// charge (the B6/B10 gate). Setup refuses enabled users (409) and
// setup clears the pending code on success, so this is the only mint
// path for an enabled user. Same middleware chain + shared limiter as
// the rest of the 2FA surface.
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/code", user.SendVerificationCodeHandler)
r.Delete("/user/account", user.DeleteAccountHandler) r.Delete("/user/account", user.DeleteAccountHandler)
r.Get("/user/gdpr-export", user.GetGDPRExportHandler) r.Get("/user/gdpr-export", user.GetGDPRExportHandler)
r.Get("/user/loyalty", user.GetLoyaltyHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler)
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { extractErrorMessage } from '$lib/utils/toast-safe'; import { extractErrorMessage } from '$lib/utils/toast-safe';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
@@ -12,6 +12,7 @@
campaignDiscountPence, campaignDiscountPence,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry submitPaymentWithRetry
@@ -67,9 +68,14 @@
// B6/B10: charging a customer's saved card requires the customer's current // B6/B10: charging a customer's saved card requires the customer's current
// 2FA verification code when the backend enforces the gate. The backend keys // 2FA verification code when the backend enforces the gate. The backend keys
// on the CARD OWNER (not the admin), so the input is surfaced whenever the // on the CARD OWNER (the booking's user), so the input is surfaced whenever
// gate is enforced — the operator relays the customer's code. // the customer has 2FA enabled in an enforced environment — the operator
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); // relays the customer's code. `twoFactorRequired` is env-wide enforcement
// (true for every session user when the gate is on); the CUSTOMER's setup
// flag is not carried by the admin booking payload, so it is fetched from
// GET /api/admin/users/{id} on mount (see fetchCustomerTwoFactor).
const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired);
let customerTwoFactorEnabled = $state(false);
const stamps = $derived(booking.user?.loyalty_stamps ?? 0); const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false); let useLoyalty = $state(false);
@@ -81,11 +87,51 @@
// irrelevant to the backend gate. // irrelevant to the backend gate.
let twoFactorCode = $state(''); let twoFactorCode = $state('');
// Set true when a charge 403s for a missing code — reveals the input even // Set true when a charge 403s for a missing code — reveals the input even
// if the session user's flag is unset. // if the customer's 2FA flag is unknown/unset.
let reveal2FACodeInput = $state(false); let reveal2FACodeInput = $state(false);
const show2FACodeInput = $derived(reveal2FACodeInput || savedCardChargeRequires2FACode); const show2FACodeInput = $derived(
reveal2FACodeInput || (twoFactorEnforced && customerTwoFactorEnabled)
);
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === ''); const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
// POST /api/user/2fa/code mint state for the "Request a new code" button on
// the saved-card screen. NOTE: this mints for the SIGNED-IN session (the
// admin), which cannot authorize the customer's charge — the backend agent
// coordinating POST /api/user/2fa/code should also add an admin-scoped mint
// (e.g. /api/admin/users/{id}/2fa/code) for the operator to mint the
// customer's code; until then the button exercises the cooldown/429/503 UX.
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
// Focus the verification-code input whenever the saved-card screen shows it
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
// operator can type the customer's code without an extra click.
$effect(() => {
if (status === 'saved-card-selecting' && show2FACodeInput) {
tick().then(() => document.getElementById('two-factor-code')?.focus());
}
});
// B3: pence already paid against this booking. The AppointmentInfo handed in // B3: pence already paid against this booking. The AppointmentInfo handed in
// by /api/admin/today/current-next carries no amount_paid/amount_due/ // by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint // payments, so this is fetched fresh from the admin booking detail endpoint
@@ -158,11 +204,30 @@
let serviceOverrides = $state<Record<string, ServiceOverride>>({}); let serviceOverrides = $state<Record<string, ServiceOverride>>({});
// B6/B10: the admin booking payload carries no 2FA state for the owner, so
// the customer's flag is fetched from the admin user detail endpoint (the
// same source the customer-flag fix keys on). A failure leaves the flag
// false — the charge 403 self-heal still reveals the input.
async function fetchCustomerTwoFactor() {
const targetUserId = booking.user_id ?? booking.user?.id;
if (!targetUserId) return;
try {
const res = await apiFetch(`/api/admin/users/${targetUserId}`);
if (res.ok) {
const data = await res.json();
customerTwoFactorEnabled = data?.twoFactorEnabled === true;
}
} catch {
customerTwoFactorEnabled = false;
}
}
$effect(() => { $effect(() => {
const uid = booking.user_id ?? booking.user?.id; const uid = booking.user_id ?? booking.user?.id;
if (uid) { if (uid) {
fetchCustomerGiftCardBalance(); fetchCustomerGiftCardBalance();
fetchSavedCards(); fetchSavedCards();
fetchCustomerTwoFactor();
} }
const services = booking.services ?? []; const services = booking.services ?? [];
const overrides: Record<string, ServiceOverride> = {}; const overrides: Record<string, ServiceOverride> = {};
@@ -244,10 +309,7 @@
// remaining value, so the frontend charge and the backend record now agree // remaining value, so the frontend charge and the backend record now agree
// and a prior deposit can no longer land as an unintended tip. // and a prior deposit can no longer land as an unintended tip.
const netTotal = $derived( const netTotal = $derived(
Math.max( Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence)
0,
subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence
)
); );
const tipPercentages = $derived.by(() => { const tipPercentages = $derived.by(() => {
@@ -974,7 +1036,8 @@
class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3" class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
> >
<span class="text-sm font-medium text-green-800">Already paid</span> <span class="text-sm font-medium text-green-800">Already paid</span>
<span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span> <span class="text-base font-bold text-green-800">{formatCurrency(amountPaidPence)}</span
>
</div> </div>
{/if} {/if}
@@ -1160,6 +1223,13 @@
{/if} {/if}
</div> </div>
{#if campaignDiscountPence(discountPreview) > 0}
<p class="rounded-md border border-green-200 bg-green-50 p-2.5 text-xs text-green-800">
Discount {formatCurrency(campaignDiscountPence(discountPreview) / 100)} pending — tip will
be calculated on the discounted amount.
</p>
{/if}
<div class="space-y-3"> <div class="space-y-3">
<span class="text-sm font-medium text-gray-700">Add a Tip</span> <span class="text-sm font-medium text-gray-700">Add a Tip</span>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
@@ -1467,6 +1537,22 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA <!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. --> verification code when the backend enforces the gate. -->
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} /> <TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
{#if show2FACodeInput}
<p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh
code from their account.
</p>
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
>
Request a new code
</Button>
{/if}
<div class="flex gap-3"> <div class="flex gap-3">
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button> <Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
@@ -17,6 +17,7 @@
isNonceStale, isNonceStale,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry submitPaymentWithRetry
@@ -128,6 +129,31 @@
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === '' show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
); );
// POST /api/user/2fa/code mint state for the "Request a new code" button
// (session user = card owner, so a minted code authorizes their charge).
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
const isCardValid = $derived(cardSelectionValid); const isCardValid = $derived(cardSelectionValid);
let selectedTip = $state<number | null>(null); let selectedTip = $state<number | null>(null);
@@ -243,129 +269,129 @@
return; return;
} }
let newCardToken: string | undefined; let newCardToken: string | undefined;
let verificationToken: string | undefined; let verificationToken: string | undefined;
if (selectedCardId) { if (selectedCardId) {
// saved card — nothing to tokenize; B6/B10 requires the customer's // saved card — nothing to tokenize; B6/B10 requires the customer's
// current 2FA verification code (collected in the charge form) when // current 2FA verification code (collected in the charge form) when
// the backend enforces the gate. // the backend enforces the gate.
} else if (cardSelection) { } else if (cardSelection) {
// New-card mode: tokenize once per attempt, reuse the nonce + SCA // New-card mode: tokenize once per attempt, reuse the nonce + SCA
// verification token on retry (tokenization is one-shot; the backend // verification token on retry (tokenization is one-shot; the backend
// idempotency key dedups). The verification token is amount-bound, so // idempotency key dedups). The verification token is amount-bound, so
// a changed tip amount forces a fresh tokenization. // a changed tip amount forces a fresh tokenization.
if ( if (
!tipNonce || !tipNonce ||
tipTokenizedForSaveCard !== saveCard || tipTokenizedForSaveCard !== saveCard ||
isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount) isNonceStale(tipTokenizedAt, tipTokenAmount, tipAmount)
) { ) {
try { try {
const tokenized = await cardSelection.tokenizeWithVerification( const tokenized = await cardSelection.tokenizeWithVerification(
Math.round(tipAmount * 100), Math.round(tipAmount * 100),
{ {
givenName: authStore.currentUser?.firstName, givenName: authStore.currentUser?.firstName,
familyName: authStore.currentUser?.lastName, familyName: authStore.currentUser?.lastName,
email: authStore.currentUser?.email email: authStore.currentUser?.email
}, },
saveCard saveCard
); );
tipNonce = tokenized.nonce; tipNonce = tokenized.nonce;
tipVerificationToken = tokenized.verificationToken ?? ''; tipVerificationToken = tokenized.verificationToken ?? '';
tipTokenAmount = tipAmount; tipTokenAmount = tipAmount;
tipTokenizedAt = Date.now(); tipTokenizedAt = Date.now();
tipTokenizedForSaveCard = saveCard; tipTokenizedForSaveCard = saveCard;
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Card entry failed'); toast.error(err instanceof Error ? err.message : 'Card entry failed');
return; return;
}
} }
} newCardToken = tipNonce;
newCardToken = tipNonce; verificationToken = tipVerificationToken || undefined;
verificationToken = tipVerificationToken || undefined; } else {
} else { toast.error('Please select a payment method');
toast.error('Please select a payment method'); return;
return;
}
paymentState = 'processing';
const usedSavedCard = !!selectedCardId;
let responseStatus = 0;
try {
// New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
// rationale as the booking/account flows). Include the card so a
// same-amount tip on a DIFFERENT card gets a fresh key instead of
// deduping against the previous card's charge.
const cardKey = selectedCardId || 'new-card';
if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
tipIdempotencyKey = generateUUID();
tipKeyedAmount = tipAmount;
tipKeyedCard = cardKey;
}
const amountInPence = Math.round(tipAmount * 100);
const body: Record<string, unknown> = {
amount: amountInPence,
idempotency_key: tipIdempotencyKey,
...(selectedCardId ? { card_id: selectedCardId } : {}),
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
};
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!response.ok) {
responseStatus = response.status;
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
} }
paymentState = 'success'; paymentState = 'processing';
tipIdempotencyKey = '';
tipKeyedAmount = 0; const usedSavedCard = !!selectedCardId;
tipKeyedCard = ''; let responseStatus = 0;
tipNonce = '';
tipVerificationToken = ''; try {
tipTokenAmount = 0; // New-card identity is a STABLE sentinel, NOT the cnon: nonce (same
tipTokenizedAt = 0; // rationale as the booking/account flows). Include the card so a
tipTokenizedForSaveCard = false; // same-amount tip on a DIFFERENT card gets a fresh key instead of
twoFactorCode = ''; // deduping against the previous card's charge.
reveal2FACodeInput = false; const cardKey = selectedCardId || 'new-card';
toast.success('Thank you for your tip!'); if (!tipIdempotencyKey || tipKeyedAmount !== tipAmount || tipKeyedCard !== cardKey) {
onSuccess?.(); tipIdempotencyKey = generateUUID();
} catch (err) { tipKeyedAmount = tipAmount;
paymentState = 'error'; tipKeyedCard = cardKey;
let errorMessage = err instanceof Error ? err.message : 'Payment failed'; }
// Saved-card (ccof) charges skip the client-side SCA step, so a const amountInPence = Math.round(tipAmount * 100);
// definitive 402 on the saved-card path means the issuer still const body: Record<string, unknown> = {
// requires verification — retrying the same saved card can never amount: amountInPence,
// succeed. Surface the fix instead of the generic backend text. idempotency_key: tipIdempotencyKey,
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) { ...(selectedCardId ? { card_id: selectedCardId } : {}),
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE; ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
...(verificationToken ? { verification_token: verificationToken } : {}),
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
};
const response = await submitPaymentWithRetry(() =>
apiFetch(`/api/bookings/${booking.id}/tip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
);
if (!response.ok) {
responseStatus = response.status;
const errorText = await response.text();
throw new Error(extractErrorMessage(errorText) || 'Payment failed');
}
paymentState = 'success';
tipIdempotencyKey = '';
tipKeyedAmount = 0;
tipKeyedCard = '';
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
twoFactorCode = '';
reveal2FACodeInput = false;
toast.success('Thank you for your tip!');
onSuccess?.();
} catch (err) {
paymentState = 'error';
let errorMessage = err instanceof Error ? err.message : 'Payment failed';
// Saved-card (ccof) charges skip the client-side SCA step, so a
// definitive 402 on the saved-card path means the issuer still
// requires verification — retrying the same saved card can never
// succeed. Surface the fix instead of the generic backend text.
if (isSavedCardVerificationRequired(responseStatus, usedSavedCard)) {
errorMessage = SAVED_CARD_VERIFICATION_MESSAGE;
}
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the tip can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
reveal2FACodeInput = true;
}
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency
// key is kept: it's still correct for network-timeout dedup.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
} }
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the tip can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
reveal2FACodeInput = true;
}
toast.error(errorMessage);
// A definitive charge failure (e.g. declined card) consumes the nonce
// and SCA verification token — they can never succeed again. Clear the
// cached pair so the next retry re-tokenizes fresh. The idempotency
// key is kept: it's still correct for network-timeout dedup.
tipNonce = '';
tipVerificationToken = '';
tipTokenAmount = 0;
tipTokenizedAt = 0;
tipTokenizedForSaveCard = false;
}
} finally { } finally {
isSubmittingTipSync = false; isSubmittingTipSync = false;
} }
@@ -517,6 +543,18 @@
showInput={show2FACodeInput} showInput={show2FACodeInput}
enabled={twoFactorEnabled} enabled={twoFactorEnabled}
/> />
{#if show2FACodeInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
>
Request a new code
</Button>
{/if}
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -20,6 +20,7 @@
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry submitPaymentWithRetry
@@ -79,6 +80,31 @@
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === '' show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
); );
// POST /api/user/2fa/code mint state for the "Request a new code" button
// (session user = card owner, so a minted code authorizes their charge).
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
// Cached idempotency key per payment attempt (amount + type + card): reused // Cached idempotency key per payment attempt (amount + type + card): reused
// on retry so a lost-response retry dedups instead of double-charging, // on retry so a lost-response retry dedups instead of double-charging,
// regenerated when any of those change. Matches the tip-flow pattern. // regenerated when any of those change. Matches the tip-flow pattern.
@@ -733,7 +759,7 @@
{#if overflowConfirm} {#if overflowConfirm}
<div class="space-y-4"> <div class="space-y-4">
<!-- Overpayment confirmation: the backend rejected the payment because <!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be (stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Applies both before and after recorded as a tip once confirmed. Applies both before and after
@@ -1001,6 +1027,18 @@
showInput={show2FACodeInput} showInput={show2FACodeInput}
enabled={twoFactorEnabled} enabled={twoFactorEnabled}
/> />
{#if show2FACodeInput && twoFactorEnabled}
<Button
variant="outline"
size="sm"
class="w-full"
loading={requesting2FACode}
disabled={requesting2FACode}
onclick={handleRequestNew2FACode}
>
Request a new code
</Button>
{/if}
{#if depositPolicyWarning} {#if depositPolicyWarning}
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800"> <div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
+85 -3
View File
@@ -10,6 +10,7 @@ import {
isOverflowTipConfirmationRequired, isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
requires2FACodeForSavedCard, requires2FACodeForSavedCard,
sanitizeDecimalInput, sanitizeDecimalInput,
submitPaymentWithRetry submitPaymentWithRetry
@@ -154,9 +155,9 @@ describe('campaignDiscountPence', () => {
}); });
it('rounds each discount amount to pence before summing', () => { it('rounds each discount amount to pence before summing', () => {
expect(campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })).toBe( expect(
501 campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] })
); ).toBe(501);
}); });
it('is 0 when no preview', () => { it('is 0 when no preview', () => {
@@ -364,3 +365,84 @@ describe('submitPaymentWithRetry', () => {
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234);
}); });
}); });
describe('requestNewTwoFactorCode', () => {
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns ok with the server message on a 200 mint', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ message: 'Verification code sent' }))
);
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(true);
expect(result.status).toBe(200);
expect(result.message).toBe('Verification code sent');
});
it('falls back to a default message when the 200 body has none', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 200)));
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(true);
expect(result.message).toContain('verification code');
});
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
jsonResponse({ error: 'Too many requests. Wait before requesting a new code.' }, 429)
)
);
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(false);
expect(result.status).toBe(429);
expect(result.message).toContain('Too many requests');
});
it('surfaces the 503 delivery-unavailable error message', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(jsonResponse({ error: 'Verification code delivery unavailable' }, 503))
);
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(false);
expect(result.status).toBe(503);
expect(result.message).toContain('delivery');
});
it('reports a network failure with status 0', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(false);
expect(result.status).toBe(0);
expect(result.message).toContain('Network error');
});
it('attaches the Bearer token from localStorage and POSTs', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('localStorage', {
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
});
const result = await requestNewTwoFactorCode();
expect(result.ok).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/user/2fa/code');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
});
});
+62
View File
@@ -149,6 +149,68 @@ export function isTwoFactorVerificationGateFailure(status: number, message: stri
return /invalid verification code|verification code expired/i.test(message); return /invalid verification code|verification code expired/i.test(message);
} }
/** Shape returned by requestNewTwoFactorCode: the HTTP status (0 for a
* network error before any response), whether the mint succeeded, and a
* user-facing message extracted from the server body or a sensible fallback. */
export interface TwoFactorCodeRequestResult {
status: number;
ok: boolean;
message: string;
}
/**
* Mints a fresh 2FA verification code for the signed-in account via
* POST /api/user/2fa/code (RequireAuth + RequireNonGuest). The backend applies
* a per-user mint cooldown, so a too-fast re-request returns 429; production
* with no delivery channel configured fails closed with 503. A successful mint
* delivers the code through the build-dependent channel ([2FA] server log in
* dev/test builds), so this surfaces the server's `message` never the code
* itself. The Authorization header is read from localStorage (authToken)
* exactly where the auth store persists it so this helper stays free of
* `$lib` imports and the pure-logic vitest suite can exercise it without a
* SvelteKit plugin resolving the `$lib` alias.
*/
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch('/api/user/2fa/code', { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
return { status: response.status, ok: true, message };
}
const body = await response.text();
return {
status: response.status,
ok: false,
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
};
} catch {
return { status: 0, ok: false, message: 'Network error requesting a new code' };
}
}
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
* endpoint bodies (429/503), kept inline so square.ts stays import-free for
* the vitest suite. */
function extractServerErrorMessage(body: string): string {
const trimmed = body.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown };
if (typeof parsed.error === 'string') return parsed.error;
if (typeof parsed.message === 'string') return parsed.message;
} catch {
// Not JSON — use the raw body below.
}
return trimmed;
}
/** /**
* Error code the booking-payment endpoint (POST /api/bookings/{id}/payment) * Error code the booking-payment endpoint (POST /api/bookings/{id}/payment)
* returns with a 400 when a payment would exceed the booking's remaining * returns with a 400 when a payment would exceed the booking's remaining
+8 -1
View File
@@ -881,7 +881,11 @@ INSERT INTO business_settings (
-- 'critical_payment_log' surfaces unresolved money events (stale pending -- 'critical_payment_log' surfaces unresolved money events (stale pending
-- payments/till sales, refunds at the retry cap) in the admin notification -- payments/till sales, refunds at the retry cap) in the admin notification
-- centre — the DB-backed stand-in for the un-watched CRITICAL payment logs. -- centre — the DB-backed stand-in for the un-watched CRITICAL payment logs.
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed', 'refund_failed', 'critical_payment_log'); -- 'refresh_token_reuse' surfaces a replayed (already-rotated) refresh token in
-- the admin notification centre — the DB-backed stand-in for the un-watched
-- CRITICAL auth logs, so a stolen refresh token that was rotated by the attacker
-- and replayed by the victim raises an alert.
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline', 'gift_card_purchased_for_friend', 'default_hours_changed', 'refund_failed', 'critical_payment_log', 'refresh_token_reuse');
CREATE TABLE admin_notifications ( CREATE TABLE admin_notifications (
id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(), id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(),
@@ -2672,6 +2676,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL, role TEXT NOT NULL,
family_id UUID NOT NULL DEFAULT gen_random_uuid(),
used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT FALSE, revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL expires_at TIMESTAMPTZ NOT NULL
@@ -2679,4 +2685,5 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id ON refresh_tokens (family_id);