fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
+138
-27
@@ -19,6 +19,37 @@ import (
|
|||||||
|
|
||||||
var TokenAuth *jwtauth.JWTAuth
|
var TokenAuth *jwtauth.JWTAuth
|
||||||
|
|
||||||
|
// RefreshTokenLifetime is how long an issued refresh token stays valid before
|
||||||
|
// it expires (90 days). Minted rows get expires_at = NOW() + RefreshTokenLifetime
|
||||||
|
// (see GenerateRefreshToken / GenerateRefreshTokenInFamily), and the cleanup
|
||||||
|
// jobs that expire refresh-token families and retain revoked tokens
|
||||||
|
// (handlers/scheduling CleanupExpiredRefreshTokens, jobs SweepSquareWebhookEvents)
|
||||||
|
// reference the SAME constant so the SQL window can never drift from the mint.
|
||||||
|
const RefreshTokenLifetime = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
// refreshTokenLifetimeDays is RefreshTokenLifetime expressed in whole days, fed
|
||||||
|
// to the SQL make_interval(days => ...) calls in the mint queries.
|
||||||
|
const refreshTokenLifetimeDays = int64(RefreshTokenLifetime / (24 * time.Hour))
|
||||||
|
|
||||||
|
// refreshTokenReuseGrace is how long after a rotation a used-token replay is
|
||||||
|
// treated as a BENIGN concurrent refresh (two tabs sharing one refresh token in
|
||||||
|
// localStorage both refreshing on load) instead of theft. A replay inside the
|
||||||
|
// grace window gets the generic error but does NOT kill the rotation family and
|
||||||
|
// does NOT raise the refresh_token_reuse alert — only a replay after the window
|
||||||
|
// has elapsed is treated as theft (see VerifyRefreshToken).
|
||||||
|
const refreshTokenReuseGrace = 30 * time.Second
|
||||||
|
|
||||||
|
// refreshTokenReuseGraceSecs is the grace window in whole seconds for the SQL
|
||||||
|
// make_interval(secs => ...) comparison in VerifyRefreshToken's reuse branch.
|
||||||
|
const refreshTokenReuseGraceSecs = int64(refreshTokenReuseGrace / time.Second)
|
||||||
|
|
||||||
|
// accessTokenFamilyClaim is the JWT claim that binds an access token to the
|
||||||
|
// refresh-token rotation family it was minted alongside. VerifyToken rejects an
|
||||||
|
// access token whose family_id no longer exists in refresh_tokens, so when
|
||||||
|
// reuse detection DELETEs a family every access token minted by that lineage
|
||||||
|
// dies immediately instead of remaining valid for its 1-hour TTL (HIGH 1).
|
||||||
|
const accessTokenFamilyClaim = "family_id"
|
||||||
|
|
||||||
// AuthResponse is the response structure for login/refresh endpoints.
|
// AuthResponse is the response structure for login/refresh endpoints.
|
||||||
// RefreshToken is a 90-day opaque, DB-hashed, single-use credential; the
|
// RefreshToken is a 90-day opaque, DB-hashed, single-use credential; the
|
||||||
// client stores it and presents it (Bearer) to POST /api/refresh-token in
|
// client stores it and presents it (Bearer) to POST /api/refresh-token in
|
||||||
@@ -127,20 +158,39 @@ func InitJWT(secret string) {
|
|||||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
|
// GenerateToken creates a JWT with user_id, role, and a unique jti claim.
|
||||||
// Returns the token string, the JTI, and any error
|
// Returns the token string, the JTI, and any error. The token carries NO
|
||||||
|
// family_id claim, so it is exempt from the family-alive check in VerifyToken —
|
||||||
|
// kept for callers minting transient/test tokens. Production login and refresh
|
||||||
|
// paths use GenerateTokenForFamily so a killed rotation family cannot keep an
|
||||||
|
// access token alive (HIGH 1).
|
||||||
func GenerateToken(userID string, role string) (string, string, error) {
|
func GenerateToken(userID string, role string) (string, string, error) {
|
||||||
|
return GenerateTokenForFamily(userID, role, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTokenForFamily mints an access token carrying a family_id claim that
|
||||||
|
// binds it to a refresh-token rotation family. VerifyToken rejects a token whose
|
||||||
|
// family_id no longer exists in refresh_tokens, so when reuse detection DELETEs
|
||||||
|
// the whole family (VerifyRefreshToken), every access token minted by that
|
||||||
|
// lineage dies immediately instead of remaining valid for its 1-hour TTL.
|
||||||
|
// familyID == "" mints an unbound token (same as GenerateToken).
|
||||||
|
func GenerateTokenForFamily(userID string, role string, familyID string) (string, string, error) {
|
||||||
jti, err := generateJTI()
|
jti, err := generateJTI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
claims := map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"role": role,
|
"role": role,
|
||||||
"jti": jti,
|
"jti": jti,
|
||||||
"exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour
|
"exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour
|
||||||
})
|
}
|
||||||
|
if familyID != "" {
|
||||||
|
claims[accessTokenFamilyClaim] = familyID
|
||||||
|
}
|
||||||
|
|
||||||
|
_, tokenString, err := TokenAuth.Encode(claims)
|
||||||
return tokenString, jti, err
|
return tokenString, jti, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,9 +232,58 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
|
|||||||
return "", "", "", fmt.Errorf("token revoked")
|
return "", "", "", fmt.Errorf("token revoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HIGH 1: reject an access token bound (family_id claim) to a rotation
|
||||||
|
// family that reuse detection has killed. A token minted at rotation carries
|
||||||
|
// family_id; when that family no longer exists in refresh_tokens the token is
|
||||||
|
// dead even though its JTI was never revoked — closing the up-to-1-hour
|
||||||
|
// window where a stolen refresh token's freshly-minted access token could
|
||||||
|
// still hit money endpoints (gift-card buy, saved-card booking payment, tip).
|
||||||
|
if err := verifyFamilyAlive(ctx, token, userID); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
return userID, role, jti, nil
|
return userID, role, jti, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyFamilyAlive rejects access tokens bound to a rotation family that no
|
||||||
|
// longer exists in refresh_tokens. A token WITHOUT a family_id claim is unbound
|
||||||
|
// (minted via GenerateToken — tests/legacy callers) and passes. Fails closed on
|
||||||
|
// a live-DB query error: when the family cannot be confirmed alive the safe
|
||||||
|
// default for money endpoints is to refuse. Mirrors IsJTIRevoked's nil-db
|
||||||
|
// availability default (startup/unit tests).
|
||||||
|
func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error {
|
||||||
|
var familyVal any
|
||||||
|
if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
familyID, ok := familyVal.(string)
|
||||||
|
if !ok || familyID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db.Conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
err := db.Conn.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM refresh_tokens WHERE family_id = $1 AND user_id = $2
|
||||||
|
)`, familyID, userID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token revoked")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("token revoked")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtClaimGetter is the minimal subset of jwt.Token needed to read a claim
|
||||||
|
// (the token returned by jwtauth.VerifyToken). Kept as an interface so the
|
||||||
|
// lestrrat-go/jwx dependency stays out of this file's imports.
|
||||||
|
type jwtClaimGetter interface {
|
||||||
|
Get(string, interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
// generateRefreshTokenString creates a cryptographically random opaque refresh token
|
// generateRefreshTokenString creates a cryptographically random opaque refresh token
|
||||||
func generateRefreshTokenString() (string, error) {
|
func generateRefreshTokenString() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
@@ -194,23 +293,25 @@ func generateRefreshTokenString() (string, error) {
|
|||||||
return fmt.Sprintf("%x", b), nil
|
return fmt.Sprintf("%x", b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRefreshToken creates a refresh token stored in the database
|
// GenerateRefreshToken creates a refresh token stored in the database. Returns
|
||||||
// Returns the opaque token string to return to the client
|
// the opaque token string to return to the client plus the id of the rotation
|
||||||
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, error) {
|
// family the new row was created in (so the caller can bind the matching access
|
||||||
|
// token to it via GenerateTokenForFamily — see HIGH 1).
|
||||||
|
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, string, error) {
|
||||||
token, err := generateRefreshTokenString()
|
token, err := generateRefreshTokenString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store hashed version in DB with 90-day expiry
|
// Store hashed version in DB with the shared RefreshTokenLifetime expiry
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
||||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, NOW() + INTERVAL '90 days')
|
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, gen_random_uuid(), NOW() + make_interval(days => $4))
|
||||||
RETURNING id`
|
RETURNING id, family_id`
|
||||||
|
|
||||||
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) {
|
||||||
@@ -219,16 +320,17 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var tokenID int64
|
var tokenID int64
|
||||||
err = tx.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
|
var familyID string
|
||||||
|
err = tx.QueryRow(ctx, query, userID, token, role, refreshTokenLifetimeDays).Scan(&tokenID, &familyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
return "", "", fmt.Errorf("failed to store 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return token, nil
|
return token, familyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation
|
// GenerateRefreshTokenInFamily creates a refresh token in the SAME rotation
|
||||||
@@ -242,10 +344,10 @@ func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role strin
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store hashed version in DB with 90-day expiry, in the given family
|
// Store hashed version in DB with the shared RefreshTokenLifetime expiry, in the given family
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
INSERT INTO refresh_tokens (user_id, token_hash, role, family_id, expires_at)
|
||||||
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + INTERVAL '90 days')
|
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, $4, NOW() + make_interval(days => $5))
|
||||||
RETURNING id`
|
RETURNING id`
|
||||||
|
|
||||||
tx, err := db.Conn.Begin(ctx)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
@@ -259,7 +361,7 @@ func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role strin
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var tokenID int64
|
var tokenID int64
|
||||||
err = tx.QueryRow(ctx, query, userID, token, role, familyID).Scan(&tokenID)
|
err = tx.QueryRow(ctx, query, userID, token, role, familyID, refreshTokenLifetimeDays).Scan(&tokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -274,10 +376,15 @@ func GenerateRefreshTokenInFamily(ctx context.Context, userID string, role strin
|
|||||||
// VerifyRefreshToken checks a refresh token and returns user details if valid.
|
// VerifyRefreshToken checks a refresh token and returns user details if valid.
|
||||||
// The token is consumed (marked used) upon successful verification — rotation —
|
// 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
|
// 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
|
// SAME family. If an ALREADY-ROTATED token is presented again after the
|
||||||
// attacker rotated it, then the victim replayed it), the entire rotation family
|
// refreshTokenReuseGrace window (a replay: the attacker rotated it, then the
|
||||||
// is revoked (the descendant minted at rotation dies too) and a critical admin
|
// victim replayed it), the entire rotation family is revoked (the descendant
|
||||||
// notification (reason 'refresh_token_reuse') is raised. The caller always gets
|
// minted at rotation dies too, and so does every access token bound to the
|
||||||
|
// family — HIGH 1) and a critical admin notification (reason
|
||||||
|
// 'refresh_token_reuse') is raised. A replay WITHIN the grace window is a
|
||||||
|
// benign concurrent refresh (two tabs sharing one localStorage refresh token
|
||||||
|
// refreshing on load): it gets the generic error but kills NOTHING and raises
|
||||||
|
// NO alert, so the legitimately-rotated session survives. The caller always gets
|
||||||
// the generic "invalid or expired refresh token" error so reuse is never leaked.
|
// 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) {
|
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, familyID string, err error) {
|
||||||
query := `
|
query := `
|
||||||
@@ -312,14 +419,18 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rotation UPDATE matched nothing: the token is expired, revoked, or
|
// The rotation UPDATE matched nothing: the token is expired, revoked, or
|
||||||
// never issued — OR it was already used (replayed). A used token is theft:
|
// never issued — OR it was already used (replayed). A used token replayed
|
||||||
// the descendant minted at rotation would otherwise stay valid for 90 days.
|
// AFTER the grace window is theft: the descendant minted at rotation would
|
||||||
|
// otherwise stay valid for 90 days. A used token replayed WITHIN the grace
|
||||||
|
// window is a benign concurrent refresh (two tabs, one shared refresh token)
|
||||||
|
// and falls through to the generic error below — no family kill, no alert.
|
||||||
var reusedUserID, reusedFamilyID string
|
var reusedUserID, reusedFamilyID string
|
||||||
reuseErr := tx.QueryRow(ctx, `
|
reuseErr := tx.QueryRow(ctx, `
|
||||||
SELECT user_id, family_id FROM refresh_tokens
|
SELECT user_id, family_id FROM refresh_tokens
|
||||||
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||||
AND used_at IS NOT NULL
|
AND used_at IS NOT NULL
|
||||||
`, tokenString).Scan(&reusedUserID, &reusedFamilyID)
|
AND used_at < NOW() - make_interval(secs => $2)
|
||||||
|
`, tokenString, refreshTokenReuseGraceSecs).Scan(&reusedUserID, &reusedFamilyID)
|
||||||
|
|
||||||
if reuseErr == nil {
|
if reuseErr == nil {
|
||||||
// (i) Revoke the ENTIRE family — the reused token and every descendant.
|
// (i) Revoke the ENTIRE family — the reused token and every descendant.
|
||||||
|
|||||||
+151
-4
@@ -378,7 +378,7 @@ func TestGenerateRefreshToken_Success(t *testing.T) {
|
|||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -418,7 +418,7 @@ func TestVerifyRefreshToken_Success(t *testing.T) {
|
|||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -455,7 +455,7 @@ func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
|||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -491,7 +491,7 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Generate a refresh token (new family) and rotate it once.
|
// 1. Generate a refresh token (new family) and rotate it once.
|
||||||
original, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -519,6 +519,18 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
|
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MEDIUM 4 grace window: backdate the original's used_at past the 30s
|
||||||
|
// reuse grace so the replay below is genuine theft. WITHOUT this, a replay
|
||||||
|
// moments after rotation is a benign two-tab concurrent refresh and the
|
||||||
|
// family must NOT be killed.
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE refresh_tokens
|
||||||
|
SET used_at = NOW() - make_interval(secs => 60)
|
||||||
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||||
|
`, original); err != nil {
|
||||||
|
t.Fatalf("failed to backdate used_at for reuse test: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Replay the ORIGINAL token — theft.
|
// 3. Replay the ORIGINAL token — theft.
|
||||||
_, _, _, err = VerifyRefreshToken(ctx, original)
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -560,6 +572,141 @@ func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the MEDIUM 4
|
||||||
|
// hardening: a used-token replay WITHIN the 30s grace window (two tabs sharing
|
||||||
|
// one localStorage refresh token both refreshing on load) is a benign
|
||||||
|
// concurrent refresh — the generic error is returned, but the rotation family
|
||||||
|
// survives and no refresh_token_reuse alert is raised.
|
||||||
|
func TestVerifyRefreshToken_ReplayWithinGrace_IsBenign(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the descendant in the same family (as RefreshTokenHandler does).
|
||||||
|
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
||||||
|
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay the original IMMEDIATELY — inside the grace window → benign.
|
||||||
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for within-grace replay, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
||||||
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The family survives: used original + descendant both still present.
|
||||||
|
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.Errorf("expected 2 refresh tokens in family after within-grace replay, got %d", famCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No theft alert.
|
||||||
|
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 != 0 {
|
||||||
|
t.Errorf("expected 0 'refresh_token_reuse' alerts for a within-grace replay, got %d", alertCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccessTokenKilledWithRotationFamily verifies the HIGH 1 fix: an access
|
||||||
|
// token minted at rotation is bound (family_id claim) to the rotation family,
|
||||||
|
// so when reuse detection DELETEs the family the access token — which the
|
||||||
|
// attacker was handed at rotation — stops verifying immediately instead of
|
||||||
|
// staying valid for its 1-hour TTL.
|
||||||
|
func TestAccessTokenKilledWithRotationFamily(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rotation response: a descendant refresh token in the same family and
|
||||||
|
// an access token minted in that SAME family (as RefreshTokenHandler does).
|
||||||
|
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
||||||
|
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
||||||
|
}
|
||||||
|
attackerToken, attackerJTI, err := GenerateTokenForFamily(userID, "verified_email", familyID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateTokenForFamily() failed: %v", err)
|
||||||
|
}
|
||||||
|
if attackerJTI == "" {
|
||||||
|
t.Fatal("expected non-empty JTI")
|
||||||
|
}
|
||||||
|
|
||||||
|
// While its family is alive the access token verifies (JTI not revoked).
|
||||||
|
if _, _, _, err := VerifyToken(attackerToken, ctx); err != nil {
|
||||||
|
t.Fatalf("access token must verify while its rotation family is alive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdate used_at past the grace window, then replay the original — theft
|
||||||
|
// detected, whole family DELETEd.
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE refresh_tokens
|
||||||
|
SET used_at = NOW() - make_interval(secs => 60)
|
||||||
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
||||||
|
`, original); err != nil {
|
||||||
|
t.Fatalf("failed to backdate used_at: %v", err)
|
||||||
|
}
|
||||||
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected theft detection on replayed token, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != 0 {
|
||||||
|
t.Fatalf("expected 0 refresh tokens in family after reuse, got %d", famCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attacker's access token is now dead even though its JTI was never
|
||||||
|
// revoked — the family-alive check rejects it (HIGH 1).
|
||||||
|
if _, _, _, err := VerifyToken(attackerToken, ctx); err == nil {
|
||||||
|
t.Fatal("access token must be rejected once its rotation family is killed")
|
||||||
|
} else if !strings.Contains(err.Error(), "token revoked") {
|
||||||
|
t.Fatalf("expected 'token revoked' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ func TestRefreshToken_Success(t *testing.T) {
|
|||||||
|
|
||||||
// B5: the endpoint REQUIRES the opaque refresh token in the Authorization
|
// B5: the endpoint REQUIRES the opaque refresh token in the Authorization
|
||||||
// header — the access token alone can no longer self-renew.
|
// header — the access token alone can no longer self-renew.
|
||||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate refresh token: %v", err)
|
t.Fatalf("failed to generate refresh token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1432,7 +1432,7 @@ func TestRefreshToken_RotatesRefreshToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fixtures.DeleteUser(tx, userID)
|
defer fixtures.DeleteUser(tx, userID)
|
||||||
|
|
||||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate refresh token: %v", err)
|
t.Fatalf("failed to generate refresh token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1763,7 +1763,7 @@ func TestRefreshToken_Generation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fixtures.DeleteUser(tx, userID)
|
defer fixtures.DeleteUser(tx, userID)
|
||||||
|
|
||||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate refresh token: %v", err)
|
t.Fatalf("failed to generate refresh token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1920,7 +1920,7 @@ func TestRefreshToken_RotatesRefreshToken_DBBacked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer fixtures.DeleteUser(tx, userID)
|
defer fixtures.DeleteUser(tx, userID)
|
||||||
|
|
||||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate refresh token: %v", err)
|
t.Fatalf("failed to generate refresh token: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,25 +451,30 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
|
||||||
tokenString, jti, err := auth.GenerateToken(userID, role)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue an opaque refresh token (B5): the 90-day credential is stored
|
// Issue an opaque refresh token (B5): the 90-day credential is stored
|
||||||
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
|
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
|
||||||
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
|
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
|
||||||
// token can mint a new pair. The refresh token is returned in the body so
|
// token can mint a new pair. The refresh token is returned in the body so
|
||||||
// the SPA can persist it and present it to POST /api/refresh-token.
|
// the SPA can persist it and present it to POST /api/refresh-token. The
|
||||||
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
// access token is bound to the new rotation family (HIGH 1) so that if the
|
||||||
|
// login refresh token is ever replayed the whole family — access token
|
||||||
|
// included — is killed.
|
||||||
|
refreshToken, familyID, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
|
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
|
||||||
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate the access token AFTER the refresh token so it can be bound to
|
||||||
|
// the same rotation family.
|
||||||
|
tokenString, jti, err := auth.GenerateTokenForFamily(userID, role, familyID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to generate access token for user %s: %v", userID, err)
|
||||||
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||||
Token: tokenString,
|
Token: tokenString,
|
||||||
JTI: jti,
|
JTI: jti,
|
||||||
@@ -528,11 +533,9 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Issue a fresh access token + refresh token pair. The rotated refresh
|
// Issue a fresh access token + refresh token pair. The rotated refresh
|
||||||
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
|
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
|
||||||
// a replayed ancestor can revoke the whole lineage, descendants included.
|
// a replayed ancestor can revoke the whole lineage, descendants included.
|
||||||
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
// The access token is bound to that same family (HIGH 1): when reuse
|
||||||
if err != nil {
|
// detection kills the family, the freshly-minted access token handed to the
|
||||||
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
// attacker at rotation dies with it instead of staying valid for 1 hour.
|
||||||
return
|
|
||||||
}
|
|
||||||
newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
|
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)
|
||||||
@@ -540,6 +543,14 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint the access token AFTER the descendant refresh token so it can be
|
||||||
|
// bound to the same rotation family.
|
||||||
|
newToken, jti, err := auth.GenerateTokenForFamily(userID, currentRole, familyID)
|
||||||
|
if err != nil {
|
||||||
|
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||||
Token: newToken,
|
Token: newToken,
|
||||||
JTI: jti,
|
JTI: jti,
|
||||||
|
|||||||
@@ -382,16 +382,16 @@ func bookingIsFullyPaid(ctx context.Context, q db.Querier, bookingID string) boo
|
|||||||
var fullyPaid bool
|
var fullyPaid bool
|
||||||
if err := q.QueryRow(ctx, `
|
if err := q.QueryRow(ctx, `
|
||||||
WITH booking_total AS (
|
WITH booking_total AS (
|
||||||
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
|
SELECT total_amount * 100 AS total_pence FROM bookings WHERE id = $1
|
||||||
),
|
),
|
||||||
paid_total AS (
|
paid_total AS (
|
||||||
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents
|
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_pence
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE booking_id = $1 AND status = 'completed'
|
WHERE booking_id = $1 AND status = 'completed'
|
||||||
AND payment_type != 'tip'
|
AND payment_type != 'tip'
|
||||||
AND payment_method NOT IN ('on_the_house')
|
AND payment_method NOT IN ('on_the_house')
|
||||||
)
|
)
|
||||||
SELECT pt.paid_cents >= bt.total_cents AND bt.total_cents > 0
|
SELECT pt.paid_pence >= bt.total_pence AND bt.total_pence > 0
|
||||||
FROM booking_total bt, paid_total pt
|
FROM booking_total bt, paid_total pt
|
||||||
`, bookingID).Scan(&fullyPaid); err != nil {
|
`, bookingID).Scan(&fullyPaid); err != nil {
|
||||||
log.Printf("Failed to check full-payment threshold for booking %s: %v", bookingID, err)
|
log.Printf("Failed to check full-payment threshold for booking %s: %v", bookingID, err)
|
||||||
|
|||||||
@@ -2301,16 +2301,16 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
var depositMet bool
|
var depositMet bool
|
||||||
if err := tx2.QueryRow(r.Context(), fmt.Sprintf(`
|
if err := tx2.QueryRow(r.Context(), fmt.Sprintf(`
|
||||||
WITH booking_total AS (
|
WITH booking_total AS (
|
||||||
SELECT total_amount * 100 AS total_cents FROM bookings WHERE id = $1
|
SELECT total_amount * 100 AS total_pence FROM bookings WHERE id = $1
|
||||||
),
|
),
|
||||||
paid_total AS (
|
paid_total AS (
|
||||||
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents
|
SELECT COALESCE(SUM(amount), 0) * 100 AS paid_pence
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE booking_id = $1 AND status = 'completed'
|
WHERE booking_id = $1 AND status = 'completed'
|
||||||
AND payment_type != 'tip'
|
AND payment_type != 'tip'
|
||||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||||
)
|
)
|
||||||
SELECT pt.paid_cents >= ROUND(bt.total_cents * %f)
|
SELECT pt.paid_pence >= ROUND(bt.total_pence * %f)
|
||||||
FROM booking_total bt, paid_total pt
|
FROM booking_total bt, paid_total pt
|
||||||
`, depositPromotionMinPct), bookingID).Scan(&depositMet); err != nil {
|
`, depositPromotionMinPct), bookingID).Scan(&depositMet); err != nil {
|
||||||
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
|
log.Printf("Failed to check deposit threshold for booking %s: %v", bookingID, err)
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ func TestTerminalCash_ClampsToRemainingObligation(t *testing.T) {
|
|||||||
// a customer who already paid in full (overpayment is handled manually at the
|
// a customer who already paid in full (overpayment is handled manually at the
|
||||||
// counter, not minted into the ledger).
|
// counter, not minted into the ledger).
|
||||||
func TestTerminalCash_FullyPaid_RejectsOvercharge(t *testing.T) {
|
func TestTerminalCash_FullyPaid_RejectsOvercharge(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
@@ -100,7 +99,6 @@ func TestTerminalCash_FullyPaid_RejectsOvercharge(t *testing.T) {
|
|||||||
// Square charge use the clamped remaining obligation, not the verbatim
|
// Square charge use the clamped remaining obligation, not the verbatim
|
||||||
// PaymentModal amount that ignored prior payments.
|
// PaymentModal amount that ignored prior payments.
|
||||||
func TestTerminalSavedCard_ClampsToRemainingObligation(t *testing.T) {
|
func TestTerminalSavedCard_ClampsToRemainingObligation(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
@@ -138,7 +136,6 @@ func TestTerminalSavedCard_ClampsToRemainingObligation(t *testing.T) {
|
|||||||
// remaining value UNLESS the customer explicitly requested a tip. The
|
// remaining value UNLESS the customer explicitly requested a tip. The
|
||||||
// recorded terminal_checkouts row amount must reflect the clamp.
|
// recorded terminal_checkouts row amount must reflect the clamp.
|
||||||
func TestTerminalCheckout_NoTip_ClampsToRemaining(t *testing.T) {
|
func TestTerminalCheckout_NoTip_ClampsToRemaining(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
@@ -170,7 +167,6 @@ func TestTerminalCheckout_NoTip_ClampsToRemaining(t *testing.T) {
|
|||||||
// the customer explicitly requested a tip, the checkout amount (booking
|
// the customer explicitly requested a tip, the checkout amount (booking
|
||||||
// portion + tip) is NOT clamped — the overflow is gratuity.
|
// portion + tip) is NOT clamped — the overflow is gratuity.
|
||||||
func TestTerminalCheckout_TipEnabled_NotClamped(t *testing.T) {
|
func TestTerminalCheckout_TipEnabled_NotClamped(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
_, bookingID, _ := setupTestData(t, ctx, tx)
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
@@ -207,7 +203,6 @@ func TestTerminalCheckout_TipEnabled_NotClamped(t *testing.T) {
|
|||||||
// exactly like the booking/cash paths — otherwise a VAT-registered business
|
// exactly like the booking/cash paths — otherwise a VAT-registered business
|
||||||
// silently loses the VAT fields on every saved-card terminal charge.
|
// silently loses the VAT fields on every saved-card terminal charge.
|
||||||
func TestTerminalSavedCard_AppliesVAT(t *testing.T) {
|
func TestTerminalSavedCard_AppliesVAT(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
_, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`)
|
||||||
@@ -335,7 +330,6 @@ func TestApplyEligibleCampaignsAtPayment_CampaignAvailable_NoError(t *testing.T)
|
|||||||
// lost. Charging the discounted amount (£45 on a £50 booking) mints the £5
|
// lost. Charging the discounted amount (£45 on a £50 booking) mints the £5
|
||||||
// discount row so real money + discount == total and the booking completes.
|
// discount row so real money + discount == total and the booking completes.
|
||||||
func TestTerminalSavedCard_AppliesCampaignAtChargeTime(t *testing.T) {
|
func TestTerminalSavedCard_AppliesCampaignAtChargeTime(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
@@ -408,7 +402,6 @@ func (c *exhaustCampaignOnChargeClient) CreatePayment(ctx context.Context, req s
|
|||||||
// The charge still completes at Square and the payment is recorded; the
|
// The charge still completes at Square and the payment is recorded; the
|
||||||
// frontend learns the campaign ended so it can prompt for the difference.
|
// frontend learns the campaign ended so it can prompt for the difference.
|
||||||
func TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed(t *testing.T) {
|
func TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ func TestProcessCancellationRefund_ExcludesTips(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBookingPaymentInfo_ExcludesTips(t *testing.T) {
|
func TestGetBookingPaymentInfo_ExcludesTips(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
ctx, tx := testutils.SetupTestTx(t)
|
ctx, tx := testutils.SetupTestTx(t)
|
||||||
|
|
||||||
userID, err := fixtures.CreateTestUser(tx)
|
userID, err := fixtures.CreateTestUser(tx)
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ import (
|
|||||||
// 'failed' and surfaced for manual arrangement instead.
|
// 'failed' and surfaced for manual arrangement instead.
|
||||||
const stalePendingRefundAge = 23 * time.Hour
|
const stalePendingRefundAge = 23 * time.Hour
|
||||||
|
|
||||||
|
// stalePendingB1RefundAge is the age guard for a B1 sweep auto-refund of a
|
||||||
|
// replay-induced duplicate charge (sweepPendingB1Refunds) that Square left
|
||||||
|
// PENDING. FAILED/REJECTED are terminal refund states, but the webhook
|
||||||
|
// FAILED-refund reconciliation that would normally resolve them is OPTIONAL
|
||||||
|
// (README) — with it unconfigured, hasInFlightSweepDuplicateRefund blocks the
|
||||||
|
// stale sweeps from resolving the parent forever, no alert fires and every
|
||||||
|
// sweep run re-polls Square indefinitely. After a B1 refund has been pending
|
||||||
|
// this long the re-poll pass treats FAILED/REJECTED as terminal itself (failing
|
||||||
|
// the refund, resolving the parent and alerting), and a still-pending refund
|
||||||
|
// older than this is escalated to a deduped CRITICAL admin notification and no
|
||||||
|
// longer re-polled. Never marks anything completed without a COMPLETED Square
|
||||||
|
// refund.
|
||||||
|
const stalePendingB1RefundAge = 48 * time.Hour
|
||||||
|
|
||||||
// maxManualRefundAttempts caps retry attempts for a refund stuck on a
|
// maxManualRefundAttempts caps retry attempts for a refund stuck on a
|
||||||
// decline/ambiguous outcome before it is resolved terminally. Both the manual
|
// decline/ambiguous outcome before it is resolved terminally. Both the manual
|
||||||
// sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the
|
// sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the
|
||||||
@@ -91,6 +105,21 @@ var (
|
|||||||
manualReconcileFailures = make(map[string]int)
|
manualReconcileFailures = make(map[string]int)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// b1EscalatedMu guards b1Escalated, the set of B1 sweep auto-refund rows the
|
||||||
|
// re-poll pass has escalated (pending past stalePendingB1RefundAge). Escalated
|
||||||
|
// rows are no longer re-polled: the FAILED/REJECTED-terminal branch already
|
||||||
|
// resolves them (the row leaves the pending query), and a still-pending
|
||||||
|
// escalated row keeps a deduped CRITICAL admin notification alive while the
|
||||||
|
// parent stays pending for manual reconciliation. In-memory (no schema change —
|
||||||
|
// the schema is single-source and pre-launch, no ALTERs), mirroring
|
||||||
|
// manualReconcileFailures: a process restart resets the set, which merely
|
||||||
|
// re-polls the row once and re-escalates it (the notification is deduped), never
|
||||||
|
// suppressing the alert.
|
||||||
|
var (
|
||||||
|
b1EscalatedMu sync.Mutex
|
||||||
|
b1Escalated = make(map[string]bool)
|
||||||
|
)
|
||||||
|
|
||||||
// trackReconcileFailureReArm records one more consecutive cap-time reconcile
|
// trackReconcileFailureReArm records one more consecutive cap-time reconcile
|
||||||
// failure for each refund row and, once a row crosses
|
// failure for each refund row and, once a row crosses
|
||||||
// maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log'
|
// maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log'
|
||||||
@@ -1587,14 +1616,16 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
|
|||||||
// b1PendingRefund is one refunds row for a sweep auto-refund of a
|
// b1PendingRefund is one refunds row for a sweep auto-refund of a
|
||||||
// replay-induced duplicate charge (B1) that Square left PENDING.
|
// replay-induced duplicate charge (B1) that Square left PENDING.
|
||||||
type b1PendingRefund struct {
|
type b1PendingRefund struct {
|
||||||
RefundID string
|
RefundID string
|
||||||
PaymentID string // refunds.payment_id — the parent payment row
|
PaymentID string // refunds.payment_id — the parent payment row
|
||||||
Amount float64
|
Amount float64
|
||||||
SquareRefundID string
|
SquareRefundID string
|
||||||
IdempotencyKey string // deterministic "sweepdup-" + duplicate payment id (payments-table rows)
|
IdempotencyKey string // deterministic "sweepdup-" + duplicate payment id (payments-table rows)
|
||||||
Reason string // carries the parent till_sale id for till_sale 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)
|
SquarePaymentID string // synthetic till-sale payment row's square_payment_id ("" for payments-table rows)
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
|
CreatedAt time.Time // refunds.created_at — when the auto-refund was recorded (age for escalation)
|
||||||
|
BookingID string // parent payment's booking_id ("" when the row has none)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of
|
// sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of
|
||||||
@@ -1612,7 +1643,8 @@ type b1PendingRefund struct {
|
|||||||
func sweepPendingB1Refunds(ctx context.Context) (int, error) {
|
func sweepPendingB1Refunds(ctx context.Context) (int, error) {
|
||||||
rows, err := db.Conn.Query(ctx, `
|
rows, err := db.Conn.Query(ctx, `
|
||||||
SELECT r.id, r.payment_id, r.amount, r.square_refund_id, COALESCE(r.idempotency_key, ''),
|
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, '')
|
r.reason, COALESCE(p.square_payment_id, ''), COALESCE(r.created_by, ''),
|
||||||
|
r.created_at, COALESCE(p.booking_id, '')
|
||||||
FROM refunds r
|
FROM refunds r
|
||||||
LEFT JOIN payments p ON p.id = r.payment_id
|
LEFT JOIN payments p ON p.id = r.payment_id
|
||||||
WHERE r.status = 'pending' AND r.square_refund_id IS NOT NULL
|
WHERE r.status = 'pending' AND r.square_refund_id IS NOT NULL
|
||||||
@@ -1627,7 +1659,7 @@ func sweepPendingB1Refunds(ctx context.Context) (int, error) {
|
|||||||
var pending []b1PendingRefund
|
var pending []b1PendingRefund
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pr b1PendingRefund
|
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 {
|
if err := rows.Scan(&pr.RefundID, &pr.PaymentID, &pr.Amount, &pr.SquareRefundID, &pr.IdempotencyKey, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedBy, &pr.CreatedAt, &pr.BookingID); err != nil {
|
||||||
log.Printf("Failed to scan B1 sweep auto-refund: %v", err)
|
log.Printf("Failed to scan B1 sweep auto-refund: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1644,6 +1676,17 @@ func sweepPendingB1Refunds(ctx context.Context) (int, error) {
|
|||||||
processed := 0
|
processed := 0
|
||||||
for i := range pending {
|
for i := range pending {
|
||||||
pr := &pending[i]
|
pr := &pending[i]
|
||||||
|
// A row already escalated (pending past stalePendingB1RefundAge) is no
|
||||||
|
// longer re-polled — it either resolved terminal (and left the pending
|
||||||
|
// query) or stays pending under the deduped CRITICAL notification for
|
||||||
|
// manual reconciliation.
|
||||||
|
b1EscalatedMu.Lock()
|
||||||
|
escalated := b1Escalated[pr.RefundID]
|
||||||
|
b1EscalatedMu.Unlock()
|
||||||
|
if escalated {
|
||||||
|
log.Printf("B1 refund %s was already escalated (pending over %s) — not re-polling; manual reconciliation holds the parent pending", pr.RefundID, stalePendingB1RefundAge)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// The Square payment id the refund targets. A till_sale's refund is
|
// The Square payment id the refund targets. A till_sale's refund is
|
||||||
// attached to the synthetic payments row (square_payment_id = the
|
// attached to the synthetic payments row (square_payment_id = the
|
||||||
// duplicate charge); a payments-table refund is attached to the
|
// duplicate charge); a payments-table refund is attached to the
|
||||||
@@ -1669,19 +1712,33 @@ func sweepPendingB1Refunds(ctx context.Context) (int, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stale := clock.Now().Sub(pr.CreatedAt) > stalePendingB1RefundAge
|
||||||
switch status {
|
switch status {
|
||||||
case "COMPLETED":
|
case "COMPLETED":
|
||||||
if resolveB1RefundCompleted(ctx, pr) {
|
if resolveB1RefundCompleted(ctx, pr) {
|
||||||
processed++
|
processed++
|
||||||
}
|
}
|
||||||
case "PENDING", "APPROVED":
|
case "PENDING", "APPROVED":
|
||||||
log.Printf("B1 refund %s is still %q at Square — leaving the parent row pending", pr.RefundID, status)
|
if stale {
|
||||||
|
escalateStaleB1Refund(ctx, pr)
|
||||||
|
} else {
|
||||||
|
log.Printf("B1 refund %s is still %q at Square — leaving the parent row pending", pr.RefundID, status)
|
||||||
|
}
|
||||||
case "":
|
case "":
|
||||||
log.Printf("B1 refund %s (%s) was not found at Square via ListPaymentRefunds — leaving pending; manual reconciliation may be required", pr.RefundID, dupPayID)
|
log.Printf("B1 refund %s (%s) was not found at Square via ListPaymentRefunds — leaving pending; manual reconciliation may be required", pr.RefundID, dupPayID)
|
||||||
default:
|
default:
|
||||||
// FAILED / REJECTED — the webhook FAILED-refund reconciliation
|
// FAILED / REJECTED — terminal at Square. While the refund is young
|
||||||
// (coordinated) owns this terminal state; never resolve it here.
|
// the webhook FAILED-refund reconciliation (OPTIONAL per README)
|
||||||
log.Printf("B1 refund %s is %q at Square — leaving pending for the webhook FAILED-refund reconciliation", pr.RefundID, status)
|
// owns it; once it has been pending past stalePendingB1RefundAge the
|
||||||
|
// sweep treats it as terminal itself so the parent can never be
|
||||||
|
// stranded by a missing webhook.
|
||||||
|
if stale {
|
||||||
|
if resolveB1RefundFailedTerminal(ctx, pr, status) {
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("B1 refund %s is %q at Square — leaving pending for the webhook FAILED-refund reconciliation (or the %s age escalation)", pr.RefundID, status, stalePendingB1RefundAge)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return processed, nil
|
return processed, nil
|
||||||
@@ -1699,13 +1756,51 @@ func resolveB1RefundCompleted(ctx context.Context, pr *b1PendingRefund) bool {
|
|||||||
`, pr.RefundID); err != nil {
|
`, pr.RefundID); err != nil {
|
||||||
log.Printf("Failed to mark B1 refund %s completed after Square settle: %v", pr.RefundID, err)
|
log.Printf("Failed to mark B1 refund %s completed after Square settle: %v", pr.RefundID, err)
|
||||||
}
|
}
|
||||||
|
// A COMPLETED refund resolved the parent — clear any escalation flag so a
|
||||||
|
// restarted/re-polled row can be observed again if it ever re-enters.
|
||||||
|
b1EscalatedMu.Lock()
|
||||||
|
delete(b1Escalated, pr.RefundID)
|
||||||
|
b1EscalatedMu.Unlock()
|
||||||
|
return resolveB1ParentFailed(ctx, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveB1RefundFailedTerminal resolves a B1 sweep auto-refund that Square
|
||||||
|
// reports FAILED/REJECTED once it has been pending longer than
|
||||||
|
// stalePendingB1RefundAge — the terminal treatment the webhook FAILED-refund
|
||||||
|
// reconciliation (OPTIONAL per README) would have applied when configured. The
|
||||||
|
// refund is marked failed, the parent resolved to failed (a till sale's funded
|
||||||
|
// gift card clawed back), and a deduped CRITICAL admin notification raised so
|
||||||
|
// the missing webhook can never strand the parent silently forever. Returns
|
||||||
|
// true when the parent was resolved.
|
||||||
|
func resolveB1RefundFailedTerminal(ctx context.Context, pr *b1PendingRefund, squareStatus string) bool {
|
||||||
|
if _, err := db.Conn.Exec(ctx, `
|
||||||
|
UPDATE refunds SET status = 'failed'
|
||||||
|
WHERE id = $1 AND status = 'pending'
|
||||||
|
`, pr.RefundID); err != nil {
|
||||||
|
log.Printf("Failed to mark B1 refund %s failed after %q at Square: %v", pr.RefundID, squareStatus, err)
|
||||||
|
}
|
||||||
|
b1EscalatedMu.Lock()
|
||||||
|
b1Escalated[pr.RefundID] = true
|
||||||
|
b1EscalatedMu.Unlock()
|
||||||
|
insertB1EscalationNotification(ctx, pr)
|
||||||
|
resolved := resolveB1ParentFailed(ctx, pr)
|
||||||
|
log.Printf("B1 refund %s was %q at Square and pending over %s — marked the refund failed and resolved the parent; MANUAL RECONCILIATION REQUIRED: verify at Square whether the duplicate charge was refunded", pr.RefundID, squareStatus, stalePendingB1RefundAge)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveB1ParentFailed resolves a B1 refund's parent row to failed after the
|
||||||
|
// refund settled definitively (COMPLETED — the duplicate was reversed — or
|
||||||
|
// FAILED/REJECTED past the age threshold). A payments-table refund's payment_id
|
||||||
|
// IS the pending parent row; a till_sale's id is encoded in the reason and its
|
||||||
|
// funded gift card is clawed back. Returns true when the parent was resolved.
|
||||||
|
func resolveB1ParentFailed(ctx context.Context, pr *b1PendingRefund) bool {
|
||||||
if pr.Reason == sweepDuplicateRefundReason {
|
if pr.Reason == sweepDuplicateRefundReason {
|
||||||
// payments-table parent: the refund's payment_id IS the pending row.
|
// payments-table parent: the refund's payment_id IS the pending row.
|
||||||
if failStaleRow(ctx, "payments", pr.PaymentID) {
|
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)
|
log.Printf("B1 refund %s — marked the pending payment %s failed", pr.RefundID, pr.PaymentID)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
log.Printf("B1 refund %s COMPLETED at Square but payment %s was already resolved", pr.RefundID, pr.PaymentID)
|
log.Printf("B1 refund %s — payment %s was already resolved", pr.RefundID, pr.PaymentID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// till_sale parent: the sale id is encoded in the reason.
|
// till_sale parent: the sale id is encoded in the reason.
|
||||||
@@ -1713,28 +1808,62 @@ func resolveB1RefundCompleted(ctx context.Context, pr *b1PendingRefund) bool {
|
|||||||
tillSaleID := strings.TrimSuffix(pr.Reason[idx+len("(till_sale "):], ")")
|
tillSaleID := strings.TrimSuffix(pr.Reason[idx+len("(till_sale "):], ")")
|
||||||
ts, ok := loadTillSaleStaleRow(ctx, tillSaleID)
|
ts, ok := loadTillSaleStaleRow(ctx, tillSaleID)
|
||||||
if !ok {
|
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)
|
log.Printf("CRITICAL: B1 refund %s — parent till sale %s could not be loaded — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if ts.HasGiftCard {
|
if ts.HasGiftCard {
|
||||||
if clawbackTillSaleFunding(ctx, ts) {
|
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)
|
log.Printf("B1 refund %s — clawed back till sale %s's funded gift card and marked it failed", pr.RefundID, tillSaleID)
|
||||||
return true
|
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)
|
log.Printf("CRITICAL: B1 refund %s — clawing back till sale %s's funding failed — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if failStaleRow(ctx, "till_sales", tillSaleID) {
|
if failStaleRow(ctx, "till_sales", tillSaleID) {
|
||||||
log.Printf("B1 refund %s COMPLETED at Square — marked till sale %s failed", pr.RefundID, tillSaleID)
|
log.Printf("B1 refund %s — marked till sale %s failed", pr.RefundID, tillSaleID)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
log.Printf("B1 refund %s COMPLETED at Square but till sale %s was already resolved", pr.RefundID, tillSaleID)
|
log.Printf("B1 refund %s — till sale %s was already resolved", pr.RefundID, tillSaleID)
|
||||||
return false
|
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)
|
log.Printf("B1 refund %s — the parent row could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", pr.RefundID, pr.Reason)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escalateStaleB1Refund surfaces a B1 sweep auto-refund that has been PENDING
|
||||||
|
// (non-terminal) at Square for longer than stalePendingB1RefundAge: a deduped
|
||||||
|
// CRITICAL admin notification fires (the webhook FAILED-refund reconciliation
|
||||||
|
// is OPTIONAL — with it unconfigured nothing else alerts) and the row is no
|
||||||
|
// longer re-polled. The refund is left PENDING and the parent stays pending —
|
||||||
|
// marking failed on a non-terminal state could exclude money that may still
|
||||||
|
// move.
|
||||||
|
func escalateStaleB1Refund(ctx context.Context, pr *b1PendingRefund) {
|
||||||
|
b1EscalatedMu.Lock()
|
||||||
|
b1Escalated[pr.RefundID] = true
|
||||||
|
b1EscalatedMu.Unlock()
|
||||||
|
insertB1EscalationNotification(ctx, pr)
|
||||||
|
log.Printf("CRITICAL: B1 refund %s has been PENDING at Square for over %s — leaving the parent pending and STOPPING re-poll — MANUAL RECONCILIATION REQUIRED: verify the refund at Square and resolve the parent", pr.RefundID, stalePendingB1RefundAge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertB1EscalationNotification raises the deduped 'critical_payment_log'
|
||||||
|
// admin notification for an escalated B1 refund (insertCriticalPaymentNotification
|
||||||
|
// keeps ONE per booking/user until acknowledged). A payments-table refund is
|
||||||
|
// attributed to its parent payment's booking and the payer; a till-sale refund
|
||||||
|
// (synthetic payment row, no booking) is attributed to the payer only.
|
||||||
|
func insertB1EscalationNotification(ctx context.Context, pr *b1PendingRefund) {
|
||||||
|
var bookingID *string
|
||||||
|
if pr.BookingID != "" {
|
||||||
|
b := pr.BookingID
|
||||||
|
bookingID = &b
|
||||||
|
}
|
||||||
|
var userID *string
|
||||||
|
if pr.CreatedBy != "" {
|
||||||
|
u := pr.CreatedBy
|
||||||
|
userID = &u
|
||||||
|
}
|
||||||
|
insertCriticalPaymentNotification(ctx, bookingID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// loadTillSaleStaleRow reads a till_sale's gift-card context for the B1
|
// loadTillSaleStaleRow reads a till_sale's gift-card context for the B1
|
||||||
// re-poll pass's clawback — the same fields fetchStaleRows/scanStaleRow
|
// re-poll pass's clawback — the same fields fetchStaleRows/scanStaleRow
|
||||||
// populate for the stale-pending sweep.
|
// populate for the stale-pending sweep.
|
||||||
@@ -2057,10 +2186,25 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case sqErr == nil:
|
case sqErr == nil:
|
||||||
|
// Resolve by Square's status: a synchronous refund response can be
|
||||||
|
// PENDING (money in flight, e.g. an async card network) — marking it
|
||||||
|
// completed while Square later fails it would permanently block that
|
||||||
|
// amount in the over-refund guard. Only a definitive COMPLETED
|
||||||
|
// resolves to completed; PENDING stays pending for the sweep to
|
||||||
|
// reconcile; FAILED/REJECTED is a real failure. Mirrors
|
||||||
|
// processChargeGroup and the RefundPayment handler (handlers.go).
|
||||||
|
sqStatus := "completed"
|
||||||
|
if sqResult.Status == "PENDING" {
|
||||||
|
sqStatus = "pending"
|
||||||
|
log.Printf("Square refund %s for manual refund %s is PENDING (in flight) — leaving the row pending for the sweep to resolve", sqResult.ID, pr.ID)
|
||||||
|
} else if sqResult.Status == "FAILED" || sqResult.Status == "REJECTED" {
|
||||||
|
sqStatus = "failed"
|
||||||
|
log.Printf("Square refund %s for manual refund %s FAILED — marking the row failed", sqResult.ID, pr.ID)
|
||||||
|
}
|
||||||
if _, upErr := db.Conn.Exec(ctx, `
|
if _, upErr := db.Conn.Exec(ctx, `
|
||||||
UPDATE refunds SET status = 'completed', square_refund_id = $1
|
UPDATE refunds SET status = $1, square_refund_id = $2
|
||||||
WHERE id = $2
|
WHERE id = $3
|
||||||
`, sqResult.ID, pr.ID); upErr != nil {
|
`, sqStatus, sqResult.ID, pr.ID); upErr != nil {
|
||||||
log.Printf("CRITICAL: Square refund committed (%s) but DB update for manual refund %s failed — manual reconciliation required: %v", sqResult.ID, pr.ID, upErr)
|
log.Printf("CRITICAL: Square refund committed (%s) but DB update for manual refund %s failed — manual reconciliation required: %v", sqResult.ID, pr.ID, upErr)
|
||||||
}
|
}
|
||||||
processed++
|
processed++
|
||||||
|
|||||||
@@ -4401,3 +4401,385 @@ func TestProcessChargeGroup_ReconcileError_AtCap_ReArmsAndNotifies(t *testing.T)
|
|||||||
maxConsecutiveReconcileFailures, critCount)
|
maxConsecutiveReconcileFailures, critCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MEDIUM 3 — B1 re-poll age escalation (webhook-optional strand fix)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// b1RePollStatusClient reports every refund Square holds for a payment as a
|
||||||
|
// SINGLE synthetic refund with a fixed id and status, so the B1 re-poll
|
||||||
|
// escalation branches are deterministic regardless of what the underlying mock
|
||||||
|
// stored.
|
||||||
|
type b1RePollStatusClient struct {
|
||||||
|
square.SquareClient
|
||||||
|
refundID string
|
||||||
|
status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *b1RePollStatusClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]square.RefundResult, error) {
|
||||||
|
return []square.RefundResult{{
|
||||||
|
ID: c.refundID,
|
||||||
|
Status: c.status,
|
||||||
|
PaymentID: paymentID,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedB1RefundAndPendingParent seeds a payments-table B1 sweep auto-refund row
|
||||||
|
// (status 'pending', square_refund_id set, the deterministic sweepdup key) on a
|
||||||
|
// still-pending parent payment, aged refundAgeHours back from now, and returns
|
||||||
|
// the parent payment id, the refund id and the booking id.
|
||||||
|
func seedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string, refundAgeHours int) (paymentID, refundID string) {
|
||||||
|
t.Helper()
|
||||||
|
pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create pending parent payment: %v", err)
|
||||||
|
}
|
||||||
|
var rid string
|
||||||
|
err = tx.QueryRow(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, 'pending', 'manual', $5, $6, $7, NOW() - ($8 || ' hours')::interval)
|
||||||
|
RETURNING id
|
||||||
|
`, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID, refundAgeHours).Scan(&rid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert B1 refund row: %v", err)
|
||||||
|
}
|
||||||
|
return pid, rid
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepPendingB1Refunds_FailedPastAge_Terminal locks the MEDIUM 3 fix: a B1
|
||||||
|
// sweep auto-refund Square reports FAILED once it has been pending longer than
|
||||||
|
// stalePendingB1RefundAge is terminal — the webhook FAILED-refund reconciliation
|
||||||
|
// is OPTIONAL (README), so with it unconfigured the sweep itself must fail the
|
||||||
|
// refund, resolve the parent payment and raise the CRITICAL admin notification
|
||||||
|
// instead of re-polling forever.
|
||||||
|
func TestSweepPendingB1Refunds_FailedPastAge_Terminal(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareRefundID = "ref_b1_failed_terminal"
|
||||||
|
const refundKey = "sweepdup-pay_dup_terminal"
|
||||||
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 49)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||||
|
_, _ = 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`, paymentID)
|
||||||
|
_, _ = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "FAILED"}
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
freshCtx := context.Background()
|
||||||
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||||
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refundStatus string
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query refund: %v", err)
|
||||||
|
}
|
||||||
|
if refundStatus != "failed" {
|
||||||
|
t.Errorf("expected the stale FAILED B1 refund marked 'failed', got %q", refundStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parent payment must be resolved to failed — no longer stranded.
|
||||||
|
var paymentStatus string
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query parent payment: %v", err)
|
||||||
|
}
|
||||||
|
if paymentStatus != "failed" {
|
||||||
|
t.Errorf("expected the parent payment resolved to 'failed', got %q", paymentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifCount int
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
||||||
|
t.Fatalf("failed to count admin notifications: %v", err)
|
||||||
|
}
|
||||||
|
if notifCount < 1 {
|
||||||
|
t.Errorf("expected a critical-payment admin notification for the terminal FAILED B1 refund, got %d", notifCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepPendingB1Refunds_FailedYoung_StaysPending locks the pre-threshold
|
||||||
|
// behaviour: a B1 auto-refund Square reports FAILED while still younger than
|
||||||
|
// stalePendingB1RefundAge stays PENDING (the webhook FAILED-refund
|
||||||
|
// reconciliation owns it, and the age escalation has not yet applied) — the
|
||||||
|
// parent is NOT failed on a terminal Square status before the threshold.
|
||||||
|
func TestSweepPendingB1Refunds_FailedYoung_StaysPending(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareRefundID = "ref_b1_failed_young"
|
||||||
|
const refundKey = "sweepdup-pay_dup_young"
|
||||||
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 2)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||||
|
_, _ = 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`, paymentID)
|
||||||
|
_, _ = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "FAILED"}
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
freshCtx := context.Background()
|
||||||
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||||
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refundStatus string
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query refund: %v", err)
|
||||||
|
}
|
||||||
|
if refundStatus != "pending" {
|
||||||
|
t.Errorf("expected the young FAILED B1 refund to stay 'pending' (webhook owns it until the age escalation), got %q", refundStatus)
|
||||||
|
}
|
||||||
|
var paymentStatus string
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query parent payment: %v", err)
|
||||||
|
}
|
||||||
|
if paymentStatus != "pending" {
|
||||||
|
t.Errorf("expected the parent payment untouched while the refund is young, got %q", paymentStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSweepPendingB1Refunds_PendingPastAge_EscalatedStopsRepoll locks the MEDIUM 3
|
||||||
|
// age cap: a B1 auto-refund still PENDING past stalePendingB1RefundAge is
|
||||||
|
// escalated (a deduped CRITICAL admin notification fires) and no longer
|
||||||
|
// re-polled — a second run with Square now reporting COMPLETED must NOT resolve
|
||||||
|
// the parent, proving the escalation stopped the re-poll. The refund stays
|
||||||
|
// PENDING (never marked failed on a non-terminal state; never completed without
|
||||||
|
// a COMPLETED Square refund).
|
||||||
|
func TestSweepPendingB1Refunds_PendingPastAge_EscalatedStopsRepoll(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const squareRefundID = "ref_b1_pending_esc"
|
||||||
|
const refundKey = "sweepdup-pay_dup_pending_esc"
|
||||||
|
paymentID, refundID := seedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey, 49)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||||
|
_, _ = 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`, paymentID)
|
||||||
|
_, _ = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
freshCtx := context.Background()
|
||||||
|
// Run 1: Square still reports PENDING past the age threshold → escalation.
|
||||||
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "PENDING"}
|
||||||
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||||
|
t.Fatalf("SweepPendingSquareRefunds run 1 failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refundStatus, paymentStatus string
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query refund: %v", err)
|
||||||
|
}
|
||||||
|
if refundStatus != "pending" {
|
||||||
|
t.Errorf("expected the escalated refund to stay 'pending' (never failed on a non-terminal state), got %q", refundStatus)
|
||||||
|
}
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
||||||
|
t.Fatalf("failed to query parent payment: %v", err)
|
||||||
|
}
|
||||||
|
if paymentStatus != "pending" {
|
||||||
|
t.Errorf("expected the parent payment to stay pending after escalation, got %q", paymentStatus)
|
||||||
|
}
|
||||||
|
var notifCount int
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
||||||
|
t.Fatalf("failed to count admin notifications: %v", err)
|
||||||
|
}
|
||||||
|
if notifCount < 1 {
|
||||||
|
t.Errorf("expected a critical-payment admin notification for the escalated refund, got %d", notifCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 2: Square would now report COMPLETED — but the row was escalated, so
|
||||||
|
// it is no longer re-polled and the parent must stay pending (a re-poll here
|
||||||
|
// would have resolved it). This proves the cap stopped the re-poll.
|
||||||
|
SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"}
|
||||||
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||||
|
t.Fatalf("SweepPendingSquareRefunds run 2 failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus); err != nil {
|
||||||
|
t.Fatalf("failed to re-query refund: %v", err)
|
||||||
|
}
|
||||||
|
if refundStatus != "pending" {
|
||||||
|
t.Errorf("expected the escalated refund to NOT be re-polled (status stays 'pending'), got %q", refundStatus)
|
||||||
|
}
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&paymentStatus); err != nil {
|
||||||
|
t.Fatalf("failed to re-query parent payment: %v", err)
|
||||||
|
}
|
||||||
|
if paymentStatus != "pending" {
|
||||||
|
t.Errorf("expected the parent payment untouched after the escalated row was skipped, got %q", paymentStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DRIFT-REAL — synchronous-PENDING manual refund must stay pending
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestSweepManualRefund_SyncPendingResponse_LeavesPending locks the DRIFT-REAL
|
||||||
|
// fix: when the sweep re-issues a manual refund and Square returns PENDING
|
||||||
|
// synchronously (money in flight, e.g. an async card network), the refund row
|
||||||
|
// must be left 'pending' — never 'completed' (a later Square failure would
|
||||||
|
// permanently block the amount in the over-refund guard). Mirrors the
|
||||||
|
// processChargeGroup / RefundPayment handler status handling.
|
||||||
|
func TestSweepManualRefund_SyncPendingResponse_LeavesPending(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create card payment: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_manual_pending_sync' WHERE id = $1", paymentID); err != nil {
|
||||||
|
t.Fatalf("failed to set square_payment_id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storedKey := paymentID + "-refund-5000"
|
||||||
|
var refundID string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
|
||||||
|
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', NOW())
|
||||||
|
RETURNING id
|
||||||
|
`, paymentID, bookingID, storedKey).Scan(&refundID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert stale manual pending refund: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 admin_notifications WHERE booking_id = $1`, bookingID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
|
||||||
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
|
||||||
|
_, _ = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
// pendingRefundClient forces the synchronous RefundPayment response to
|
||||||
|
// PENDING (defined in sweep_test.go).
|
||||||
|
SquareClient = &pendingRefundClient{SquareClient: square.NewDevClient()}
|
||||||
|
defer func() { SquareClient = origClient }()
|
||||||
|
|
||||||
|
freshCtx := context.Background()
|
||||||
|
// The sweep processes the whole shared test database — clear any pending
|
||||||
|
// rows left by earlier sequential tests so the re-issue below is the only
|
||||||
|
// one acting on this payment.
|
||||||
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
|
||||||
|
t.Fatalf("failed to clean leftover pending refunds: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
|
||||||
|
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
var squareRefundID *string
|
||||||
|
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query refund: %v", err)
|
||||||
|
}
|
||||||
|
if status != "pending" {
|
||||||
|
t.Errorf("expected a synchronous-PENDING Square refund to leave the row 'pending', got %q", status)
|
||||||
|
}
|
||||||
|
if squareRefundID == nil || *squareRefundID == "" {
|
||||||
|
t.Error("expected square_refund_id to be set (Square holds the refund)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -683,6 +683,20 @@ func clawbackTillSaleFunding(ctx context.Context, r staleRow) bool {
|
|||||||
// created no later than the moment the sweep could first have replayed).
|
// created no later than the moment the sweep could first have replayed).
|
||||||
const replayLegitimateRetryWindow = 22 * time.Hour
|
const replayLegitimateRetryWindow = 22 * time.Hour
|
||||||
|
|
||||||
|
// replayRescueLowerBoundSkew is the lower-bound tolerance for a replayed
|
||||||
|
// COMPLETED payment to still be treated as the ORIGINAL charge under a retained
|
||||||
|
// key rather than a provably-new duplicate. Rows are inserted pending-first, so
|
||||||
|
// natural order is row.CreatedAt < Square's created_at by ~0.5-1s — and the DB
|
||||||
|
// clock can run AHEAD of Square's (independent NTP drift, VM pause/resume),
|
||||||
|
// which makes a retained-key dedup return the ORIGINAL charge with created <
|
||||||
|
// r.CreatedAt. Without this tolerance the sweep would declare that original
|
||||||
|
// payment a "new charge" and auto-refund a charge the customer legitimately
|
||||||
|
// authorized. Anything at or before row.CreatedAt is therefore treated as
|
||||||
|
// AMBIGUOUS (never auto-refunded, never rescued) — see
|
||||||
|
// reconcileStalePaymentByKey. A payment created AFTER row.CreatedAt +
|
||||||
|
// replayLegitimateRetryWindow remains the provable expired-key duplicate.
|
||||||
|
const replayRescueLowerBoundSkew = time.Minute
|
||||||
|
|
||||||
// replayMatchesRowAmount reports whether the replayed payment charged the same
|
// replayMatchesRowAmount reports whether the replayed payment charged the same
|
||||||
// amount the pending row records — the amount the sweep's replay body repeats
|
// amount the pending row records — the amount the sweep's replay body repeats
|
||||||
// and the amount any same-key retry MUST reuse (the retry path rejects a
|
// and the amount any same-key retry MUST reuse (the retry path rejects a
|
||||||
@@ -755,7 +769,10 @@ func parseReplayedCreatedAt(pr *square.PaymentResult) (time.Time, bool) {
|
|||||||
// replayed payment that cannot be proven to be the original (or a retry
|
// replayed payment that cannot be proven to be the original (or a retry
|
||||||
// within the legitimate window) is never rescued (the row stays pending, a
|
// within the legitimate window) is never rescued (the row stays pending, a
|
||||||
// CRITICAL log is raised and an admin notification inserted), so a hidden
|
// CRITICAL log is raised and an admin notification inserted), so a hidden
|
||||||
// second charge can never masquerade as the original one.
|
// second charge can never masquerade as the original one. The caller further
|
||||||
|
// refuses to auto-refund a payment created BEFORE the row (replayRescueLowerBoundSkew
|
||||||
|
// tolerance — a DB clock ahead of Square's can make the retained-key original
|
||||||
|
// look slightly older): such a payment is not provably a new charge.
|
||||||
//
|
//
|
||||||
// The check runs ONLY against real Square timestamps: it is gated off in an
|
// The check runs ONLY against real Square timestamps: it is gated off in an
|
||||||
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
|
// explicit dev/mock env because the dev mock returns payments whose CreatedAt
|
||||||
@@ -954,6 +971,23 @@ func reconcileStalePaymentByKey(ctx context.Context, table string, r staleRow) (
|
|||||||
if !createdOK {
|
if !createdOK {
|
||||||
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s has an UNPARSEABLE created_at — cannot prove it is a NEW expired-key replay rather than the ORIGINAL charge under a retained key — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, r.ID)
|
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s has an UNPARSEABLE created_at — cannot prove it is a NEW expired-key replay rather than the ORIGINAL charge under a retained key — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, r.ID)
|
||||||
}
|
}
|
||||||
|
// B1 (MEDIUM 2): a replayed COMPLETED payment created BEFORE the
|
||||||
|
// pending row is NOT provably a new expired-key replay — those land
|
||||||
|
// ~22h AFTER the row. A retained-key dedup returns the ORIGINAL
|
||||||
|
// charge, and a DB clock running AHEAD of Square's (rows are
|
||||||
|
// inserted pending-first; independent NTP drift, VM pause/resume)
|
||||||
|
// can make that original's created_at lag the row's by up to
|
||||||
|
// replayRescueLowerBoundSkew. Auto-refunding it would reverse a
|
||||||
|
// legitimate payment the customer authorized, so any before-row
|
||||||
|
// created_at is ambiguous: the row is left PENDING with a CRITICAL
|
||||||
|
// notification for manual reconciliation.
|
||||||
|
if created.Before(r.CreatedAt) {
|
||||||
|
skew := "well before"
|
||||||
|
if !created.Before(r.CreatedAt.Add(-replayRescueLowerBoundSkew)) {
|
||||||
|
skew = "slightly before (within the clock-skew tolerance)"
|
||||||
|
}
|
||||||
|
return leavePendingCritical(ctx, r, "stale pending %s reconcile by key: the replayed COMPLETED payment %s was created %s the pending row %s — cannot prove it is a NEW expired-key replay (a DB clock ahead of Square's can make a retained-key original look slightly older) — leaving row %s PENDING — MANUAL RECONCILIATION REQUIRED: verify at Square whether this is a second charge before refunding", table, pr.ID, skew, r.ID, r.ID)
|
||||||
|
}
|
||||||
lag := created.Sub(r.CreatedAt).Round(time.Minute).String()
|
lag := created.Sub(r.CreatedAt).Round(time.Minute).String()
|
||||||
// B1: the replayed COMPLETED payment is a REAL charge the customer
|
// B1: the replayed COMPLETED payment is a REAL charge the customer
|
||||||
// never authorized (an expired-key replay landed it on the still
|
// never authorized (an expired-key replay landed it on the still
|
||||||
|
|||||||
@@ -1261,6 +1261,115 @@ func TestSweepStalePendingPayments_KeyedReplayOriginalPayment_Rescues(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending locks
|
||||||
|
// the B1 lower-bound clock-skew tolerance: a replayed COMPLETED payment created
|
||||||
|
// slightly BEFORE the pending row must NOT be auto-refunded as a "new charge".
|
||||||
|
// Rows are inserted pending-first (row.CreatedAt precedes Square's created_at by
|
||||||
|
// ~0.5-1s), and a DB clock running AHEAD of Square's (independent NTP drift, VM
|
||||||
|
// pause/resume) makes a retained-key dedup return the ORIGINAL charge with
|
||||||
|
// created < row.CreatedAt. Such a payment cannot be proven to be a new
|
||||||
|
// expired-key replay (those land ~22h after the row), so auto-refunding it would
|
||||||
|
// reverse a legitimate charge — the row is left PENDING with a CRITICAL
|
||||||
|
// notification instead. Sequential (flips SQUARE_ENVIRONMENT), like the sibling
|
||||||
|
// B1/A1 tests.
|
||||||
|
func TestSweepStalePendingPayments_KeyedReplayCreatedBeforeRow_LeavesPending(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-retained-clock-skew"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The replayed COMPLETED payment is the ORIGINAL charge under a retained
|
||||||
|
// key whose created_at lags the DB row's by 2s — the DB clock running ahead
|
||||||
|
// of Square's. Seeding from the row's own timestamp (minus 2s) keeps the
|
||||||
|
// lag deterministic.
|
||||||
|
var rowCreatedAt time.Time
|
||||||
|
if err := tx.QueryRow(ctx, "SELECT created_at FROM payments WHERE id = $1", staleID).Scan(&rowCreatedAt); err != nil {
|
||||||
|
t.Fatalf("failed to read aged payment created_at: %v", err)
|
||||||
|
}
|
||||||
|
skewedCreated := rowCreatedAt.Add(-2 * time.Second)
|
||||||
|
|
||||||
|
origClient := SquareClient
|
||||||
|
mock := square.NewDevClient()
|
||||||
|
t.Setenv("SQUARE_ENVIRONMENT", "production")
|
||||||
|
SquareClient = &staleReplayClient{SquareClient: mock, result: &square.PaymentResult{
|
||||||
|
Status: "COMPLETED",
|
||||||
|
ID: "pay_original_skewed",
|
||||||
|
SquarePayID: "pay_original_skewed",
|
||||||
|
CreatedAt: skewedCreated.Format(time.RFC3339Nano),
|
||||||
|
}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambiguous (created before the row): the row must stay PENDING — never
|
||||||
|
// auto-refunded, never rescued.
|
||||||
|
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 before-row replay to leave the payment pending, got %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auto-refund may have been issued for the ambiguous payment.
|
||||||
|
var refundCount int
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, staleID).Scan(&refundCount); err != nil {
|
||||||
|
t.Fatalf("failed to count refunds: %v", err)
|
||||||
|
}
|
||||||
|
if refundCount != 0 {
|
||||||
|
t.Errorf("expected NO auto-refund of an ambiguous before-row payment, got %d refunds rows", refundCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A critical-payment admin notification must surface the manual reconciliation.
|
||||||
|
var notifCount int
|
||||||
|
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID).Scan(¬ifCount); err != nil {
|
||||||
|
t.Fatalf("failed to count admin notifications: %v", err)
|
||||||
|
}
|
||||||
|
if notifCount < 1 {
|
||||||
|
t.Errorf("expected a critical-payment admin notification for the ambiguous replay, got %d", notifCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2
|
// TestSweepStalePendingPayments_KeyedReplayRetryAt215h_Rescues locks the B2
|
||||||
// dead-zone fix: a same-key retry whose charge landed 21.5h after the pending
|
// dead-zone fix: a same-key retry whose charge landed 21.5h after the pending
|
||||||
// row (between the old 21h window and the 22h sweep cutoff) is the REAL charge
|
// row (between the old 21h window and the 22h sweep cutoff) is the REAL charge
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"crussell/auth"
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
|
||||||
@@ -229,7 +230,7 @@ func CleanupExpiredVerificationCodes(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExpiredRefreshTokens deletes expired refresh tokens and
|
// CleanupExpiredRefreshTokens deletes expired refresh tokens and
|
||||||
// revoked tokens older than 90 days.
|
// revoked tokens older than the shared RefreshTokenLifetime window (auth).
|
||||||
func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) {
|
func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) {
|
||||||
tx, err := db.Conn.Begin(ctx)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,8 +245,8 @@ func CleanupExpiredRefreshTokens(ctx context.Context) (int, error) {
|
|||||||
result, err := tx.Exec(ctx, `
|
result, err := tx.Exec(ctx, `
|
||||||
DELETE FROM refresh_tokens
|
DELETE FROM refresh_tokens
|
||||||
WHERE expires_at < NOW()
|
WHERE expires_at < NOW()
|
||||||
OR (revoked = TRUE AND created_at < NOW() - INTERVAL '90 days')
|
OR (revoked = TRUE AND created_at < NOW() - make_interval(days => $1))
|
||||||
`)
|
`, int64(auth.RefreshTokenLifetime/(24*time.Hour)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to cleanup refresh tokens: %w", err)
|
return 0, fmt.Errorf("failed to cleanup refresh tokens: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func TestPasswordChange_RevokesTokens(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate refresh token: %v", err)
|
t.Fatalf("failed to generate refresh token: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,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, "disable 2FA"); 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
|
||||||
@@ -542,10 +542,15 @@ func SendDisableCodeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// gated in production). Fresh-code mints are throttled per-user
|
// gated in production). Fresh-code mints are throttled per-user
|
||||||
// (twoFAMintCooldown) and never reset the failed-attempt counter (B11b).
|
// (twoFAMintCooldown) and never reset the failed-attempt counter (B11b).
|
||||||
//
|
//
|
||||||
// Contract: 200 {"message":"Code sent"} (+ a dev-only "code" field when 2FA is
|
// Contract: 200 {"message":"Code sent","remaining_seconds":N} (+ a dev-only
|
||||||
// unenforced, matching setup); 409 when the user has not enabled 2FA; 429 on
|
// "code" field when 2FA is unenforced, matching setup) where N is how many
|
||||||
// the mint cooldown; 503 when no delivery channel is configured (production
|
// seconds the effective pending code stays valid — the FULL twoFAPendingExpiry
|
||||||
// without TWO_FACTOR_ALLOW_LOG_DELIVERY=true); 500 on DB failure. The route is
|
// after a fresh mint, or the decremented lifetime when an existing valid code
|
||||||
|
// was reused (LOW 5). A 200 with a reused code must NOT be read as "a new code
|
||||||
|
// was sent": the frontend should use the already-delivered code and show the
|
||||||
|
// countdown. 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
|
// mounted with RequireAuth + RequireNonGuest + the shared per-user 2FA limiter
|
||||||
// (plus the group's per-IP limiter), so an enabled user cannot hammer code
|
// (plus the group's per-IP limiter), so an enabled user cannot hammer code
|
||||||
// requests faster than the surface budget.
|
// requests faster than the surface budget.
|
||||||
@@ -575,7 +580,7 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
st.Mu.Lock()
|
st.Mu.Lock()
|
||||||
defer st.Mu.Unlock()
|
defer st.Mu.Unlock()
|
||||||
|
|
||||||
code, err := ensurePendingTwoFACode(r, userID, st, "saved-card charge")
|
code, remaining, err := ensurePendingTwoFACode(r, userID, st, "saved-card charge")
|
||||||
if err != nil {
|
if 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)
|
||||||
@@ -593,7 +598,11 @@ func SendVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]any{"message": "Code sent"}
|
// remaining_seconds tells the client how much longer the (possibly reused)
|
||||||
|
// pending code stays valid, so a button that would otherwise toast "Code
|
||||||
|
// sent" while no NEW code was minted can instead show a countdown / keep
|
||||||
|
// the existing code (LOW 5).
|
||||||
|
resp := map[string]any{"message": "Code sent", "remaining_seconds": int(remaining.Seconds())}
|
||||||
if !twoFARequired() && code != "" {
|
if !twoFARequired() && code != "" {
|
||||||
// Dev convenience (matches setup): return the freshly minted code so
|
// Dev convenience (matches setup): return the freshly minted code so
|
||||||
// the request path is testable without grepping the backend log. The
|
// the request path is testable without grepping the backend log. The
|
||||||
@@ -658,7 +667,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, "disable 2FA"); 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
|
||||||
@@ -712,9 +721,13 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// It returns the plaintext code only when a FRESH code was minted and
|
// It returns the plaintext code only when a FRESH code was minted and
|
||||||
// delivered (dev/test builds always deliver it; production builds only when
|
// delivered (dev/test builds always deliver it; production builds only when
|
||||||
// the operator opted into log delivery — see twofa_prod.go). When a valid
|
// 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
|
// pending code was reused, the returned code is empty: only the digest is
|
||||||
// the plaintext is unavailable. Callers must only expose the returned code in
|
// stored, so the plaintext is unavailable. Callers must only expose the
|
||||||
// unenforced environments (matching SetupTwoFAHandler's dev convenience).
|
// returned code in unenforced environments (matching SetupTwoFAHandler's dev
|
||||||
|
// convenience). The remaining lifetime of the effective pending code (the
|
||||||
|
// reused one, or the fresh mint's full twoFAPendingExpiry) is always returned
|
||||||
|
// so a caller can surface "code still valid for N seconds" instead of implying
|
||||||
|
// a fresh code was sent (LOW 5).
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -726,7 +739,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, purpose string) (string, error) {
|
func ensurePendingTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, purpose string) (string, time.Duration, 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(), `
|
||||||
@@ -735,21 +748,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 "", 0, err
|
||||||
}
|
}
|
||||||
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
|
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
|
||||||
return "", nil
|
return "", pendingExpires.Time.Sub(clock.Now()), 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 "", 0, errTwoFAMintThrottled
|
||||||
}
|
}
|
||||||
code, err := deliverTwoFACode(r, userID, "", purpose)
|
code, err := deliverTwoFACode(r, userID, "", purpose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
st.LastMintAt = now
|
st.LastMintAt = now
|
||||||
return code, nil
|
return code, twoFAPendingExpiry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
|
// disableTwoFA clears two_factor_enabled and the method + pending code fields.
|
||||||
|
|||||||
@@ -1275,6 +1275,7 @@ func TestTwoFASendVerificationCode_Enabled_MintsFresh(t *testing.T) {
|
|||||||
require.Equal(t, "Code sent", resp["message"])
|
require.Equal(t, "Code sent", resp["message"])
|
||||||
_, hasCode := resp["code"]
|
_, hasCode := resp["code"]
|
||||||
require.False(t, hasCode, "enforced env must NOT return the code in the response")
|
require.False(t, hasCode, "enforced env must NOT return the code in the response")
|
||||||
|
require.Equal(t, float64(600), resp["remaining_seconds"], "a fresh mint must report the full 10-minute lifetime (LOW 5)")
|
||||||
|
|
||||||
var pendingHash sql.NullString
|
var pendingHash sql.NullString
|
||||||
var expires sql.NullTime
|
var expires sql.NullTime
|
||||||
@@ -1302,6 +1303,16 @@ func TestTwoFASendVerificationCode_ReusesValidPendingCode(t *testing.T) {
|
|||||||
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
|
w := performUser2FARequest(t, SendVerificationCodeHandler, ctx, http.MethodPost, "/api/user/2fa/code", nil, userID)
|
||||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
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"])
|
||||||
|
// LOW 5: the reused pending code's remaining lifetime is reported so the
|
||||||
|
// client knows a NEW code was NOT minted and can count down the existing one.
|
||||||
|
rem, ok := resp["remaining_seconds"].(float64)
|
||||||
|
require.True(t, ok, "response must include remaining_seconds")
|
||||||
|
require.Greater(t, rem, 0.0, "reused code must still have lifetime remaining")
|
||||||
|
require.LessOrEqual(t, rem, 600.0, "reused code lifetime must not exceed the 10-minute window")
|
||||||
|
|
||||||
var pendingHash sql.NullString
|
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.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.True(t, pendingHash.Valid)
|
||||||
|
|||||||
@@ -267,11 +267,14 @@ func RegisterAll(s *Scheduler) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SweepSquareWebhookEvents deletes square_webhook_events rows older than 90
|
// SweepSquareWebhookEvents deletes square_webhook_events rows older than the
|
||||||
// days. Every accepted webhook event_id is stored permanently for restart-safe
|
// shared auth.RefreshTokenLifetime window (90 days). Every accepted webhook
|
||||||
// dedup (payment.updated fires on every payment update), so without retention
|
// event_id is stored permanently for restart-safe dedup (payment.updated fires
|
||||||
// the table would grow without bound. 90 days comfortably exceeds Square's
|
// on every payment update), so without retention the table would grow without
|
||||||
// webhook replay window while keeping the table bounded.
|
// bound. 90 days comfortably exceeds Square's webhook replay window while
|
||||||
|
// keeping the table bounded. The 90-day window deliberately reuses
|
||||||
|
// auth.RefreshTokenLifetime so the two coinciding retention windows cannot
|
||||||
|
// drift apart.
|
||||||
func SweepSquareWebhookEvents(ctx context.Context) (int, error) {
|
func SweepSquareWebhookEvents(ctx context.Context) (int, error) {
|
||||||
tx, err := db.Conn.Begin(ctx)
|
tx, err := db.Conn.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,8 +288,8 @@ func SweepSquareWebhookEvents(ctx context.Context) (int, error) {
|
|||||||
|
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
DELETE FROM square_webhook_events
|
DELETE FROM square_webhook_events
|
||||||
WHERE received_at < NOW() - INTERVAL '90 days'
|
WHERE received_at < NOW() - make_interval(days => $1)
|
||||||
`)
|
`, int64(auth.RefreshTokenLifetime/(24*time.Hour)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to sweep square webhook events: %w", err)
|
return 0, fmt.Errorf("failed to sweep square webhook events: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,20 @@ const MaxAttempts = 5
|
|||||||
const AttemptWindow = 10 * time.Minute
|
const AttemptWindow = 10 * time.Minute
|
||||||
|
|
||||||
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
||||||
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
|
// user IDs cannot grow it without bound.
|
||||||
// schema is locked — there is no attempt column), so they reset on process
|
//
|
||||||
// restart; the 10-minute pending-code expiry bounds the practical impact.
|
// ACCEPTED LIMITATION (LOW 6 — documentation only, no behavior change): every
|
||||||
|
// 2FA counter here — the per-user failed-attempt count, the lockout window, and
|
||||||
|
// the mint-cooldown stamp (AttemptState.LastMintAt, used by twoFAMintCooldown
|
||||||
|
// in handlers/user) — is purely in-memory and resets on process restart. The
|
||||||
|
// DB schema is locked (there is no attempt column), and the practical impact
|
||||||
|
// is bounded by the 10-minute pending-code expiry (AttemptWindow /
|
||||||
|
// twoFAPendingExpiry): at most one fresh 5-guess budget per 10-minute window.
|
||||||
|
// A MULTI-INSTANCE deployment would need a shared store (e.g. a DB column or
|
||||||
|
// Redis) for these counters, because today each instance keeps its own map —
|
||||||
|
// an attacker could distribute guesses across instances. Single-instance
|
||||||
|
// deployments (this app) are unaffected.
|
||||||
|
//
|
||||||
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
||||||
var MaxTrackedAttempts = 10_000
|
var MaxTrackedAttempts = 10_000
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
submitPaymentWithRetry
|
submitPaymentWithRetry
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
|
|
||||||
type CartItem = {
|
type CartItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -112,22 +113,16 @@
|
|||||||
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
|
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
|
||||||
// surfaced whenever the gate is enforced — the operator relays the
|
// surfaced whenever the gate is enforced — the operator relays the
|
||||||
// customer's code. Cash, card machine, and online (new-card nonce) payments
|
// customer's code. Cash, card machine, and online (new-card nonce) payments
|
||||||
// are unaffected.
|
// are unaffected. Shared two-factor-code state (code, reveal, show/missing
|
||||||
|
// derivations, "Request a new code" handler) — see
|
||||||
|
// $lib/stores/twoFactorCode.svelte.ts. The admin always supplies the
|
||||||
|
// CUSTOMER's code — the admin's own 2FA flag is irrelevant to the backend
|
||||||
|
// gate, so `enabled` is always true.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
// B6/B10: verification code for the customer's saved-card till charge,
|
enabled: () => true,
|
||||||
// collected on the saved-card payment screen. Kept populated across retries
|
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
|
||||||
// so an invalid/expired code can be corrected without re-typing it. The
|
});
|
||||||
// admin always supplies the CUSTOMER's code — the admin's own 2FA flag is
|
|
||||||
// irrelevant to the backend gate.
|
|
||||||
let twoFactorCode = $state('');
|
|
||||||
// Set true when a charge 403s for a missing code — reveals the input even
|
|
||||||
// if the session user's flag is unset.
|
|
||||||
let reveal2FACodeInput = $state(false);
|
|
||||||
const show2FACodeInput = $derived(
|
|
||||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
|
|
||||||
);
|
|
||||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
|
||||||
|
|
||||||
// The saved-card option is hidden outright unless a customer is selected
|
// The saved-card option is hidden outright unless a customer is selected
|
||||||
// AND has at least one currently-valid card on file.
|
// AND has at least one currently-valid card on file.
|
||||||
@@ -327,7 +322,7 @@
|
|||||||
body.user_saved_card_id = selectedSavedCardId;
|
body.user_saved_card_id = selectedSavedCardId;
|
||||||
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
// B6/B10: the backend requires the CARD OWNER's current 2FA
|
||||||
// verification code when the gate is enforced.
|
// verification code when the gate is enforced.
|
||||||
if (show2FACodeInput) body.verification_code = twoFactorCode;
|
if (twoFactor.showInput) body.verification_code = twoFactor.code;
|
||||||
} else if (paymentMethod === 'online_square') {
|
} else if (paymentMethod === 'online_square') {
|
||||||
if (!onlineSquareCardInput) {
|
if (!onlineSquareCardInput) {
|
||||||
throw new Error('Card form is not ready — please wait a moment and try again');
|
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||||
@@ -367,14 +362,14 @@
|
|||||||
toast.success('Sale complete');
|
toast.success('Sale complete');
|
||||||
cart = [];
|
cart = [];
|
||||||
idempotencyKeys.clear();
|
idempotencyKeys.clear();
|
||||||
twoFactorCode = '';
|
twoFactor.setCode('');
|
||||||
reveal2FACodeInput = false;
|
twoFactor.reveal = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the sale can be retried with a fresh code.
|
// and reveal the input so the sale can be retried with a fresh code.
|
||||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||||
paymentError = msg;
|
paymentError = msg;
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -775,7 +770,23 @@
|
|||||||
<!-- B6/B10: saved-card till charges require the customer's
|
<!-- B6/B10: saved-card till charges require the customer's
|
||||||
current 2FA verification code when the backend enforces
|
current 2FA verification code when the backend enforces
|
||||||
the gate. -->
|
the gate. -->
|
||||||
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} />
|
<TwoFactorCodeInput
|
||||||
|
bind:code={twoFactor.code}
|
||||||
|
showInput={twoFactor.showInput}
|
||||||
|
enabled={true}
|
||||||
|
/>
|
||||||
|
{#if twoFactor.showInput}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
loading={twoFactor.requesting}
|
||||||
|
disabled={twoFactor.requesting}
|
||||||
|
onclick={twoFactor.requestNewCode}
|
||||||
|
>
|
||||||
|
Request a new code
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -798,7 +809,7 @@
|
|||||||
loading={processing}
|
loading={processing}
|
||||||
disabled={!canCharge ||
|
disabled={!canCharge ||
|
||||||
processing ||
|
processing ||
|
||||||
missing2FACode ||
|
twoFactor.missing ||
|
||||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||||
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
import {
|
import {
|
||||||
formatLocalDateTime,
|
formatLocalDateTime,
|
||||||
getLondonTodayCalendarDate,
|
getLondonTodayCalendarDate,
|
||||||
@@ -149,27 +150,16 @@
|
|||||||
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
|
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
|
||||||
// customer's current 2FA verification code whenever the backend enforces the
|
// customer's current 2FA verification code whenever the backend enforces the
|
||||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||||
// keeps its own SCA via Square tokenizeWithVerification.
|
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||||
|
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||||
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||||
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
// B6/B10: verification code for a saved-card deposit / new-card save. The
|
enabled: () => twoFactorEnabled,
|
||||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
gateActive: () =>
|
||||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
|
||||||
// the operator). Kept populated across retries so an invalid/expired code can
|
});
|
||||||
// be corrected without re-typing it.
|
|
||||||
let depositTwoFactorCode = $state('');
|
|
||||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
|
||||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
|
||||||
// enter the code.
|
|
||||||
let reveal2FACodeInput = $state(false);
|
|
||||||
const show2FACodeInput = $derived(
|
|
||||||
reveal2FACodeInput ||
|
|
||||||
(savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard))
|
|
||||||
);
|
|
||||||
const missing2FACode = $derived(
|
|
||||||
show2FACodeInput && twoFactorEnabled && depositTwoFactorCode.trim() === ''
|
|
||||||
);
|
|
||||||
|
|
||||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||||
|
|
||||||
@@ -431,7 +421,7 @@
|
|||||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
...(show2FACodeInput ? { verification_code: depositTwoFactorCode } : {})
|
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
paymentAttempted = true;
|
paymentAttempted = true;
|
||||||
@@ -502,8 +492,8 @@
|
|||||||
depositTokenizedAt = 0;
|
depositTokenizedAt = 0;
|
||||||
depositTokenizedForSaveCard = false;
|
depositTokenizedForSaveCard = false;
|
||||||
depositSaveCard = false;
|
depositSaveCard = false;
|
||||||
depositTwoFactorCode = '';
|
twoFactor.setCode('');
|
||||||
reveal2FACodeInput = false;
|
twoFactor.reveal = false;
|
||||||
overflowConfirm = null;
|
overflowConfirm = null;
|
||||||
// Immutable update — avoid mutating the existing object so
|
// Immutable update — avoid mutating the existing object so
|
||||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||||
@@ -524,7 +514,7 @@
|
|||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the deposit can be retried with a fresh code.
|
// and reveal the input so the deposit can be retried with a fresh code.
|
||||||
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
|
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
|
||||||
reveal2FACodeInput = true;
|
twoFactor.reveal = true;
|
||||||
}
|
}
|
||||||
// Pre-start overpayment guard on stale booking data: park the rejected
|
// Pre-start overpayment guard on stale booking data: park the rejected
|
||||||
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
||||||
@@ -2661,44 +2651,56 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<CardSelection
|
<CardSelection
|
||||||
bind:this={paymentCardSelection}
|
bind:this={paymentCardSelection}
|
||||||
cards={[]}
|
cards={[]}
|
||||||
{canSaveCards}
|
{canSaveCards}
|
||||||
bind:selectedCardId={selectedPaymentMethod}
|
bind:selectedCardId={selectedPaymentMethod}
|
||||||
bind:saveCard={depositSaveCard}
|
bind:saveCard={depositSaveCard}
|
||||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- B6/B10: saved-card deposits require the customer's
|
<!-- B6/B10: saved-card deposits require the customer's
|
||||||
current 2FA verification code when the backend
|
current 2FA verification code when the backend
|
||||||
enforces the gate. -->
|
enforces the gate. -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<TwoFactorCodeInput
|
<TwoFactorCodeInput
|
||||||
bind:code={depositTwoFactorCode}
|
bind:code={twoFactor.code}
|
||||||
showInput={show2FACodeInput}
|
showInput={twoFactor.showInput}
|
||||||
enabled={twoFactorEnabled}
|
enabled={twoFactorEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
{#if twoFactor.showInput && twoFactorEnabled}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
loading={twoFactor.requesting}
|
||||||
|
disabled={twoFactor.requesting}
|
||||||
|
onclick={twoFactor.requestNewCode}
|
||||||
|
>
|
||||||
|
Request a new code
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t pt-4">
|
<div class="flex items-center justify-between border-t pt-4">
|
||||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={isProcessingPayment || !depositCardFormValid || missing2FACode}
|
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
|
||||||
onclick={() => processPayment(calculateDepositAmount())}
|
onclick={() => processPayment(calculateDepositAmount())}
|
||||||
class="bg-primary text-primary-foreground"
|
class="bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
{isProcessingPayment
|
{isProcessingPayment
|
||||||
? 'Processing...'
|
? 'Processing...'
|
||||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-xs text-gray-500">
|
<p class="mt-4 text-center text-xs text-gray-500">
|
||||||
Secure payment powered by Square
|
Secure payment powered by Square
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||||
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
@@ -17,7 +18,6 @@
|
|||||||
isNonceStale,
|
isNonceStale,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
requestNewTwoFactorCode,
|
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry
|
||||||
@@ -108,51 +108,15 @@
|
|||||||
// B6/B10: saved-card tips (and saving a new card for reuse) require the
|
// B6/B10: saved-card tips (and saving a new card for reuse) require the
|
||||||
// customer's current 2FA verification code whenever the backend enforces the
|
// customer's current 2FA verification code whenever the backend enforces the
|
||||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||||
// keeps its own SCA via Square tokenizeWithVerification.
|
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||||
|
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||||
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||||
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
// B6/B10: verification code for a saved-card tip / new-card save. The backend
|
enabled: () => twoFactorEnabled,
|
||||||
// requires the card owner's CURRENT one-time code when 2FA is enforced
|
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
||||||
// (delivered via the server log / email-SMS channel and relayed by the
|
});
|
||||||
// operator). Kept populated across retries so an invalid/expired code can be
|
|
||||||
// corrected without re-typing it.
|
|
||||||
let twoFactorCode = $state('');
|
|
||||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
|
||||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
|
||||||
// enter the code.
|
|
||||||
let reveal2FACodeInput = $state(false);
|
|
||||||
const show2FACodeInput = $derived(
|
|
||||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
|
||||||
);
|
|
||||||
const missing2FACode = $derived(
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -335,7 +299,7 @@
|
|||||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
|
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await submitPaymentWithRetry(() =>
|
const response = await submitPaymentWithRetry(() =>
|
||||||
@@ -361,8 +325,8 @@
|
|||||||
tipTokenAmount = 0;
|
tipTokenAmount = 0;
|
||||||
tipTokenizedAt = 0;
|
tipTokenizedAt = 0;
|
||||||
tipTokenizedForSaveCard = false;
|
tipTokenizedForSaveCard = false;
|
||||||
twoFactorCode = '';
|
twoFactor.setCode('');
|
||||||
reveal2FACodeInput = false;
|
twoFactor.reveal = false;
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -379,7 +343,7 @@
|
|||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the tip can be retried with a fresh code.
|
// and reveal the input so the tip can be retried with a fresh code.
|
||||||
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
|
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
|
||||||
reveal2FACodeInput = true;
|
twoFactor.reveal = true;
|
||||||
}
|
}
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
// A definitive charge failure (e.g. declined card) consumes the nonce
|
// A definitive charge failure (e.g. declined card) consumes the nonce
|
||||||
@@ -539,18 +503,18 @@
|
|||||||
<!-- B6/B10: saved-card tips require the customer's current 2FA
|
<!-- B6/B10: saved-card tips require the customer's current 2FA
|
||||||
verification code when the backend enforces the gate. -->
|
verification code when the backend enforces the gate. -->
|
||||||
<TwoFactorCodeInput
|
<TwoFactorCodeInput
|
||||||
bind:code={twoFactorCode}
|
bind:code={twoFactor.code}
|
||||||
showInput={show2FACodeInput}
|
showInput={twoFactor.showInput}
|
||||||
enabled={twoFactorEnabled}
|
enabled={twoFactorEnabled}
|
||||||
/>
|
/>
|
||||||
{#if show2FACodeInput && twoFactorEnabled}
|
{#if twoFactor.showInput && twoFactorEnabled}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={requesting2FACode}
|
loading={twoFactor.requesting}
|
||||||
disabled={requesting2FACode}
|
disabled={twoFactor.requesting}
|
||||||
onclick={handleRequestNew2FACode}
|
onclick={twoFactor.requestNewCode}
|
||||||
>
|
>
|
||||||
Request a new code
|
Request a new code
|
||||||
</Button>
|
</Button>
|
||||||
@@ -569,7 +533,7 @@
|
|||||||
<Button
|
<Button
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || missing2FACode}
|
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
|
||||||
loading={paymentState === 'processing'}
|
loading={paymentState === 'processing'}
|
||||||
onclick={submitTip}
|
onclick={submitTip}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||||
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import { generateUUID } from '$lib/utils/uuid';
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,6 @@
|
|||||||
isOverflowTipConfirmationRequired,
|
isOverflowTipConfirmationRequired,
|
||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
requestNewTwoFactorCode,
|
|
||||||
sanitizeDecimalInput,
|
sanitizeDecimalInput,
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry
|
||||||
@@ -51,60 +51,21 @@
|
|||||||
// B6/B10: saved-card charges (and saving a new card for reuse) require the
|
// B6/B10: saved-card charges (and saving a new card for reuse) require the
|
||||||
// customer's current 2FA verification code whenever the backend enforces the
|
// customer's current 2FA verification code whenever the backend enforces the
|
||||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||||
// keeps its own SCA via Square tokenizeWithVerification.
|
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||||
|
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||||
|
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||||
|
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||||
|
enabled: () => twoFactorEnabled,
|
||||||
|
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
||||||
|
});
|
||||||
|
|
||||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||||
|
|
||||||
let status = $state<PaymentStatus>('idle');
|
let status = $state<PaymentStatus>('idle');
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
// B6/B10: the verification code for a saved-card charge / new-card save. The
|
|
||||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
|
||||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
|
||||||
// the operator). Kept populated across retries so an invalid/expired code can
|
|
||||||
// be corrected without re-typing it.
|
|
||||||
let twoFactorCode = $state('');
|
|
||||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
|
||||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
|
||||||
// enter the code. Revealing the input makes the failure recoverable.
|
|
||||||
let reveal2FACodeInput = $state(false);
|
|
||||||
|
|
||||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
|
||||||
// gate: charging a saved card OR saving the new card for reuse.
|
|
||||||
const show2FACodeInput = $derived(
|
|
||||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
|
||||||
);
|
|
||||||
const missing2FACode = $derived(
|
|
||||||
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.
|
||||||
@@ -542,7 +503,7 @@
|
|||||||
...(cardId ? { card_id: cardId } : {}),
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||||
idempotency_key: payIdempotencyKey
|
idempotency_key: payIdempotencyKey
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -593,8 +554,8 @@
|
|||||||
newCardTokenAmount = 0;
|
newCardTokenAmount = 0;
|
||||||
newCardTokenizedAt = 0;
|
newCardTokenizedAt = 0;
|
||||||
newCardTokenizedForSaveCard = false;
|
newCardTokenizedForSaveCard = false;
|
||||||
twoFactorCode = '';
|
twoFactor.setCode('');
|
||||||
reveal2FACodeInput = false;
|
twoFactor.reveal = false;
|
||||||
paymentResult = {
|
paymentResult = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
@@ -619,7 +580,7 @@
|
|||||||
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
|
||||||
// code, brute-force lockout) is recoverable — keep the code populated
|
// code, brute-force lockout) is recoverable — keep the code populated
|
||||||
// and reveal the input so the charge can be retried with a fresh code.
|
// and reveal the input so the charge can be retried with a fresh code.
|
||||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true;
|
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||||
error = msg;
|
error = msg;
|
||||||
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
||||||
// A definitive charge failure consumes the nonce + SCA verification
|
// A definitive charge failure consumes the nonce + SCA verification
|
||||||
@@ -1023,18 +984,18 @@
|
|||||||
<!-- 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
|
<TwoFactorCodeInput
|
||||||
bind:code={twoFactorCode}
|
bind:code={twoFactor.code}
|
||||||
showInput={show2FACodeInput}
|
showInput={twoFactor.showInput}
|
||||||
enabled={twoFactorEnabled}
|
enabled={twoFactorEnabled}
|
||||||
/>
|
/>
|
||||||
{#if show2FACodeInput && twoFactorEnabled}
|
{#if twoFactor.showInput && twoFactorEnabled}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={requesting2FACode}
|
loading={twoFactor.requesting}
|
||||||
disabled={requesting2FACode}
|
disabled={twoFactor.requesting}
|
||||||
onclick={handleRequestNew2FACode}
|
onclick={twoFactor.requestNewCode}
|
||||||
>
|
>
|
||||||
Request a new code
|
Request a new code
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1084,7 +1045,7 @@
|
|||||||
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={status === 'processing'}
|
loading={status === 'processing'}
|
||||||
disabled={payButtonDisabled || missing2FACode}
|
disabled={payButtonDisabled || twoFactor.missing}
|
||||||
>
|
>
|
||||||
{#if paymentType === 'deposit'}
|
{#if paymentType === 'deposit'}
|
||||||
Pay Deposit ({formatCurrency(
|
Pay Deposit ({formatCurrency(
|
||||||
@@ -1165,7 +1126,7 @@
|
|||||||
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={status === 'processing'}
|
loading={status === 'processing'}
|
||||||
disabled={payButtonDisabled || missing2FACode}
|
disabled={payButtonDisabled || twoFactor.missing}
|
||||||
>
|
>
|
||||||
{#if paymentType === 'partial'}
|
{#if paymentType === 'partial'}
|
||||||
Pay {partialAmountValid
|
Pay {partialAmountValid
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// src/lib/stores/twoFactorCode.svelte.ts
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { requestNewTwoFactorCode } from '$lib/square/square';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared 2FA verification-code state for the saved-card charge surfaces.
|
||||||
|
*
|
||||||
|
* B6/B10: the backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||||
|
* OWNER's current one-time verification code on every saved-card charge in an
|
||||||
|
* enforced environment. This composable owns the whole verification-code UX —
|
||||||
|
* the code itself, the reveal flag (a charge that 403s for a missing code
|
||||||
|
* reveals the input even when the session profile's 2FA flag is stale), the
|
||||||
|
* show/missing derivations and the "Request a new code" handler — so the six
|
||||||
|
* payment surfaces (booking modal, tip, account gift-card, booking-flow
|
||||||
|
* deposit, admin till and admin payment modal) can't drift on any of them.
|
||||||
|
*
|
||||||
|
* Each surface supplies its own predicates:
|
||||||
|
* - `enabled()` — whether the session user's own 2FA is active (customer
|
||||||
|
* surfaces: `!!authStore.currentUser?.twoFactorEnabled`).
|
||||||
|
* Admin surfaces (till, admin payment modal) return true:
|
||||||
|
* the operator always supplies the CUSTOMER's code, so the
|
||||||
|
* session user's own flag is irrelevant to the gate.
|
||||||
|
* - `gateActive()` — whether the pending charge hits the 2FA gate: a saved
|
||||||
|
* card is selected, or a new card is being saved for reuse.
|
||||||
|
* The surface passes its exact gate expression so each
|
||||||
|
* surface's gate semantics are preserved verbatim.
|
||||||
|
*/
|
||||||
|
export function useTwoFactorCodeForSavedCard(options: {
|
||||||
|
enabled: () => boolean;
|
||||||
|
gateActive: () => boolean;
|
||||||
|
}) {
|
||||||
|
// Kept populated across retries so an invalid/expired code can be corrected
|
||||||
|
// without re-typing it.
|
||||||
|
let code = $state('');
|
||||||
|
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||||
|
// CARD OWNER, so even a session user whose own flag is unset must be able
|
||||||
|
// to enter the code. Revealing the input makes the failure recoverable.
|
||||||
|
let reveal = $state(false);
|
||||||
|
// 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 requesting = $state(false);
|
||||||
|
|
||||||
|
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||||
|
// gate: charging a saved card OR saving the new card for reuse.
|
||||||
|
const showInput = $derived(reveal || options.gateActive());
|
||||||
|
const missing = $derived(showInput && options.enabled() && code.trim() === '');
|
||||||
|
|
||||||
|
async function requestNewCode() {
|
||||||
|
if (requesting) return;
|
||||||
|
requesting = true;
|
||||||
|
try {
|
||||||
|
const result = await requestNewTwoFactorCode();
|
||||||
|
if (result.ok) {
|
||||||
|
code = '';
|
||||||
|
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 {
|
||||||
|
requesting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCode(value: string) {
|
||||||
|
code = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get code() {
|
||||||
|
return code;
|
||||||
|
},
|
||||||
|
set code(value: string) {
|
||||||
|
code = value;
|
||||||
|
},
|
||||||
|
setCode,
|
||||||
|
get reveal() {
|
||||||
|
return reveal;
|
||||||
|
},
|
||||||
|
set reveal(value: boolean) {
|
||||||
|
reveal = value;
|
||||||
|
},
|
||||||
|
get showInput() {
|
||||||
|
return showInput;
|
||||||
|
},
|
||||||
|
get missing() {
|
||||||
|
return missing;
|
||||||
|
},
|
||||||
|
get requesting() {
|
||||||
|
return requesting;
|
||||||
|
},
|
||||||
|
requestNewCode
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,10 +15,11 @@
|
|||||||
isSavedCardVerificationRequired,
|
isSavedCardVerificationRequired,
|
||||||
isSquareConfigured,
|
isSquareConfigured,
|
||||||
isTwoFactorVerificationGateFailure,
|
isTwoFactorVerificationGateFailure,
|
||||||
requestNewTwoFactorCode,
|
|
||||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||||
submitPaymentWithRetry
|
submitPaymentWithRetry
|
||||||
} from '$lib/square/square';
|
} from '$lib/square/square';
|
||||||
|
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||||
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||||
@@ -251,50 +252,15 @@
|
|||||||
|
|
||||||
// B6/B10: gift-card buys charge a saved card (or save a new card for reuse)
|
// B6/B10: gift-card buys charge a saved card (or save a new card for reuse)
|
||||||
// whenever the backend enforces the 2FA gate — the CARD OWNER's current
|
// whenever the backend enforces the 2FA gate — the CARD OWNER's current
|
||||||
// verification code must be carried on the charge. Mirrors the customer
|
// verification code must be carried on the charge. Shared two-factor-code
|
||||||
// surface (UserPaymentModal). Kept populated across retries so an
|
// state (code, reveal, show/missing derivations, "Request a new code"
|
||||||
// invalid/expired code can be corrected without re-typing it.
|
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||||
let buyTwoFactorCode = $state('');
|
|
||||||
// Set true when a charge 403s for a missing code — reveals the input even
|
|
||||||
// if the session profile's 2FA flag is stale, making the failure
|
|
||||||
// recoverable.
|
|
||||||
let buyReveal2FACodeInput = $state(false);
|
|
||||||
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||||
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
const buyTwoFactor = useTwoFactorCodeForSavedCard({
|
||||||
// gate: charging a saved card OR saving the new card for reuse.
|
enabled: () => buyTwoFactorEnabled,
|
||||||
const buyShow2FACodeInput = $derived(
|
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard)
|
||||||
buyReveal2FACodeInput ||
|
});
|
||||||
(buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard))
|
|
||||||
);
|
|
||||||
const buyMissing2FACode = $derived(
|
|
||||||
buyShow2FACodeInput && buyTwoFactorEnabled && buyTwoFactorCode.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 buyRequesting2FACode = $state(false);
|
|
||||||
async function handleBuyRequestNew2FACode() {
|
|
||||||
if (buyRequesting2FACode) return;
|
|
||||||
buyRequesting2FACode = true;
|
|
||||||
try {
|
|
||||||
const result = await requestNewTwoFactorCode();
|
|
||||||
if (result.ok) {
|
|
||||||
buyTwoFactorCode = '';
|
|
||||||
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 {
|
|
||||||
buyRequesting2FACode = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side mirror of the £500/day online purchase cap. The backend is
|
// Client-side mirror of the £500/day online purchase cap. The backend is
|
||||||
// authoritative — this counter only reflects confirmed purchases made in
|
// authoritative — this counter only reflects confirmed purchases made in
|
||||||
@@ -496,7 +462,7 @@
|
|||||||
// (where the charge actually landed) would double-charge.
|
// (where the charge actually landed) would double-charge.
|
||||||
const cardKey = cardId || 'new-card';
|
const cardKey = cardId || 'new-card';
|
||||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||||
buyIdempotencyKey = generateIdempotencyKey();
|
buyIdempotencyKey = generateUUID();
|
||||||
buyKeyedAmount = buyAmount;
|
buyKeyedAmount = buyAmount;
|
||||||
buyKeyedCard = cardKey;
|
buyKeyedCard = cardKey;
|
||||||
}
|
}
|
||||||
@@ -512,7 +478,7 @@
|
|||||||
...(cardId ? { card_id: cardId } : {}),
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
...(buyShow2FACodeInput ? { verification_code: buyTwoFactorCode } : {}),
|
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||||
idempotency_key: buyIdempotencyKey
|
idempotency_key: buyIdempotencyKey
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -533,8 +499,8 @@
|
|||||||
buyTokenAmount = 0;
|
buyTokenAmount = 0;
|
||||||
buyTokenizedAt = 0;
|
buyTokenizedAt = 0;
|
||||||
buyTokenizedForSaveCard = false;
|
buyTokenizedForSaveCard = false;
|
||||||
buyTwoFactorCode = '';
|
buyTwoFactor.setCode('');
|
||||||
buyReveal2FACodeInput = false;
|
buyTwoFactor.reveal = false;
|
||||||
await fetchGiftCardBalance();
|
await fetchGiftCardBalance();
|
||||||
} else {
|
} else {
|
||||||
// Capture the status BEFORE consuming the body — the saved-card
|
// Capture the status BEFORE consuming the body — the saved-card
|
||||||
@@ -553,7 +519,7 @@
|
|||||||
? SAVED_CARD_VERIFICATION_MESSAGE
|
? SAVED_CARD_VERIFICATION_MESSAGE
|
||||||
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
||||||
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
||||||
buyReveal2FACodeInput = true;
|
buyTwoFactor.reveal = true;
|
||||||
}
|
}
|
||||||
toast.error(buyErrMsg);
|
toast.error(buyErrMsg);
|
||||||
// A definitive charge failure (e.g. declined card) consumes the
|
// A definitive charge failure (e.g. declined card) consumes the
|
||||||
@@ -670,24 +636,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateIdempotencyKey(): string {
|
|
||||||
const array = new Uint8Array(16);
|
|
||||||
if (typeof window !== 'undefined' && window.crypto) {
|
|
||||||
window.crypto.getRandomValues(array);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
|
||||||
}
|
|
||||||
array[6] = (array[6] & 0x0f) | 0x40;
|
|
||||||
array[8] = (array[8] & 0x3f) | 0x80;
|
|
||||||
return [...array]
|
|
||||||
.map((b, i) => {
|
|
||||||
const hex = b.toString(16).padStart(2, '0');
|
|
||||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
|
||||||
return hex;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNotifPrefs() {
|
async function fetchNotifPrefs() {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/user/notification-preferences');
|
const res = await apiFetch('/api/user/notification-preferences');
|
||||||
@@ -2633,18 +2581,18 @@
|
|||||||
<!-- B6/B10: saved-card gift-card charges require the card owner's
|
<!-- B6/B10: saved-card gift-card charges require the card owner's
|
||||||
current 2FA verification code when the backend enforces the gate. -->
|
current 2FA verification code when the backend enforces the gate. -->
|
||||||
<TwoFactorCodeInput
|
<TwoFactorCodeInput
|
||||||
bind:code={buyTwoFactorCode}
|
bind:code={buyTwoFactor.code}
|
||||||
showInput={buyShow2FACodeInput}
|
showInput={buyTwoFactor.showInput}
|
||||||
enabled={buyTwoFactorEnabled}
|
enabled={buyTwoFactorEnabled}
|
||||||
/>
|
/>
|
||||||
{#if buyShow2FACodeInput && buyTwoFactorEnabled}
|
{#if buyTwoFactor.showInput && buyTwoFactorEnabled}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
loading={buyRequesting2FACode}
|
loading={buyTwoFactor.requesting}
|
||||||
disabled={buyRequesting2FACode}
|
disabled={buyTwoFactor.requesting}
|
||||||
onclick={handleBuyRequestNew2FACode}
|
onclick={buyTwoFactor.requestNewCode}
|
||||||
>
|
>
|
||||||
Request a new code
|
Request a new code
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2655,7 +2603,7 @@
|
|||||||
onclick={buyGiftCard}
|
onclick={buyGiftCard}
|
||||||
disabled={buyingGiftCard ||
|
disabled={buyingGiftCard ||
|
||||||
!isBuyCardValid ||
|
!isBuyCardValid ||
|
||||||
buyMissing2FACode ||
|
buyTwoFactor.missing ||
|
||||||
buyDailyTotal + buyAmount > DAILY_GIFT_CARD_BUY_LIMIT}
|
buyDailyTotal + buyAmount > DAILY_GIFT_CARD_BUY_LIMIT}
|
||||||
class="mt-2 w-full"
|
class="mt-2 w-full"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user