fix: review round 4 — per-dispute chargeback alerts, single-source clawback, 2FA lockout coherence, docs

Fourth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). All PASS on the money-safety core; this round closes the
remaining MAJOR/MINOR items they surfaced.

Webhooks:
- Untracked disputes now raise ONE admin notification PER distinct chargeback:
  the notification id is derived deterministically from the square_dispute_id
  (SHA-256 truncated into the CHAR(12) slot) so a second untracked dispute is
  no longer silently suppressed by the first's dedup row. ON CONFLICT (id)
  keeps same-dispute replays idempotent; the booking-scoped NOT EXISTS guard
  is retained for the tracked path. Verified: distinct disputes -> distinct
  rows; re-delivered dispute -> one row.
- The gift-card clawback SQL now lives in exactly ONE place:
  payments.RevertGiftCardFunding (new giftcard_clawback.go). till.go and the
  webhook path both call it — eliminating the byte-for-byte copy whose
  divergence would be a money-loss drift trap (the same two-sources-of-truth
  pattern this commit eliminated for GDPR scrubbing).

2FA:
- Applied the lockout-coherence fix from the review: when a disable request
  must mint a fresh code (no valid pending one), the held attempt counter is
  reset so the locked-out user can use the freshly delivered code in the SAME
  request (no wasted round-trip). The reuse path keeps accumulating wrong
  attempts toward the 5-attempt lockout — the two behaviors no longer
  conflict. (The 'always-fresh on disable' suggestion was NOT adopted: it
  would break the out-of-band [2FA]-log delivery model, since a code generated
  by a request can never be submitted within that same request.)
- New test pins the shared verify/disable lockout: 5 wrong verifies 429 and
  destroy the code; a stale code then 400s on disable while the freshly
  delivered code succeeds in the same request.
- Startup now warns that 2FA codes travel in PLAINTEXT via the server log in
  enforced mode (operator must restrict log access + relay out-of-band until
  email/SMS lands).

Docs:
- Test counts updated to the current 2,154 across README + Technical Manual.
- User Manual 2FA nav corrected: the settings live on the Account page, not an
  'Admin' area.

Tests: 2,154 (up from 2,151). Backend 25/26 packages green (crussell/db fails
only in this environment: local postgres auth for the test role; package
byte-identical to HEAD). Frontend builds; svelte-check 0 errors.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 9bb812669e
commit fdf3f64a13
10 changed files with 378 additions and 230 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ Default logins (password: `password`):
```bash ```bash
cd backend && go build -o bin/backend ./main.go cd backend && go build -o bin/backend ./main.go
cd frontend && npm ci && npm run build cd frontend && npm ci && npm run build
cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,142 tests passed (4 skipped, ~2min) cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,151 tests passed (4 skipped, ~2min)
cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min)
cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min) cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min)
``` ```
@@ -0,0 +1,128 @@
package payments
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"crussell/db"
"github.com/jackc/pgx/v5"
)
// RevertGiftCardFunding undoes the gift-card funding performed earlier in the
// SAME till-sale request after a definitive Square charge rejection, matching
// the gift_card_transactions accounting: a created card is deleted (with its
// purchase transaction) and any immediate redeem-to-account credit reversed; a
// topped-up card has the amount subtracted back out and its top-up transaction
// removed. The clawback is claim-first: it atomically claims the till sale
// with a gating `status='pending'` UPDATE whose row lock serializes against
// the handler's completion UPDATE, then runs the card mutation + failed-mark
// in the same transaction so a late same-key retry cannot re-complete a sale
// whose gift card no longer exists.
//
// This is the SINGLE money-reversal implementation shared by the till handler
// (revertGiftCardFunding), the stale-pending sweep (sweep.go) and the Square
// webhook clawback (handlers/webhooks/square.go). Do not fork it: a divergent
// clawback is real money loss.
func RevertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin clawback transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback gift-card clawback transaction", "err", err)
}
}()
// Claim the sale first: the row lock serializes against the handler's
// completion UPDATE; a zero-row claim means the funding is not ours.
tag, err := tx.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'`, tillSaleID)
if err != nil {
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
}
if tag.RowsAffected() == 0 {
return errTillSaleNotPending
}
if action == "create" {
// A newly created card's transactions are scoped to THIS sale's
// funding (reference_type='till_sale' AND reference_id=sale id) — never
// a wholesale delete, which would destroy the value of a different
// idempotency-keyed top-up sale that funded the same card before this
// create resolved. Then remove the card itself.
if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card transaction: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card: %w", err)
}
// If the card was immediately redeemed to a user balance in this
// request, reverse that credit (guarded so it can never go negative).
if redeemToUserID != nil && *redeemToUserID != "" {
bTag, bErr := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, amount, *redeemToUserID)
if bErr != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
}
if bTag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the credited
// balance was already spent. The sale is still marked failed
// below — do not fail the whole clawback tx — but the
// un-reversed credit must be flagged for manual reconciliation
// (mirrors the top-up branch).
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (balance < amount)", giftCardID, amount, *redeemToUserID)
}
}
} else {
// Top-up: subtract the amount back out of the card. The guard keeps
// amount_remaining from ever going negative in the pathological case
// where some of the top-up was already spent before the charge failed.
tag, err := tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added - $1,
amount_remaining = amount_remaining - $1
WHERE id = $2 AND amount_remaining >= $1
`, amount, giftCardID)
if err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if tag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the top-up was
// already spent. The sale is still marked failed below — do not
// fail the whole clawback tx — but the unreversed money must be
// flagged for manual reconciliation.
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID)
}
// Remove only this request's top-up transaction (reference_id = till
// sale) so prior sales' accounting on the same card is untouched.
if _, err := tx.Exec(ctx, `
DELETE FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit clawback transaction: %w", err)
}
return nil
}
// IsTillSaleNotPending reports whether err is the claim-first sentinel
// (errTillSaleNotPending): the gating `status='pending'` UPDATE matched zero
// rows, so the till sale is no longer pending and its gift card must be left
// untouched. Exported so cross-package clawback callers (the Square webhook)
// can detect the sentinel without reaching into the unexported error value.
func IsTillSaleNotPending(err error) bool {
return errors.Is(err, errTillSaleNotPending)
}
+5 -98
View File
@@ -118,105 +118,12 @@ func declineCodeListContains(s string) bool {
// the sale is no longer 'pending' and the gift card must be left untouched. // the sale is no longer 'pending' and the gift card must be left untouched.
var errTillSaleNotPending = errors.New("till sale is not pending") var errTillSaleNotPending = errors.New("till sale is not pending")
// revertGiftCardFunding undoes the gift-card funding performed earlier in the // revertGiftCardFunding is the package-internal wrapper over the shared
// SAME till-sale request after a definitive Square charge rejection, matching // RevertGiftCardFunding clawback helper (giftcard_clawback.go). The sweep and
// the gift_card_transactions accounting: a created card is deleted (with its // the till handler both call it so every clawback path runs the single
// purchase transaction) and any immediate redeem-to-account credit reversed; a // money-reversal implementation.
// topped-up card has the amount subtracted back out and its top-up transaction
// removed. The clawback is claim-first: it atomically claims the till sale
// with a gating `status='pending'` UPDATE whose row lock serializes against
// the handler's completion UPDATE, then runs the card mutation + failed-mark
// in the same transaction so a late same-key retry cannot re-complete a sale
// whose gift card no longer exists.
func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error { func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(ctx) return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
if err != nil {
return fmt.Errorf("failed to begin clawback transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback gift-card clawback transaction", "err", err)
}
}()
// Claim the sale first: the row lock serializes against the handler's
// completion UPDATE; a zero-row claim means the funding is not ours.
tag, err := tx.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'`, tillSaleID)
if err != nil {
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
}
if tag.RowsAffected() == 0 {
return errTillSaleNotPending
}
if action == "create" {
// A newly created card's transactions are scoped to THIS sale's
// funding (reference_type='till_sale' AND reference_id=sale id) — never
// a wholesale delete, which would destroy the value of a different
// idempotency-keyed top-up sale that funded the same card before this
// create resolved. Then remove the card itself.
if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card transaction: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card: %w", err)
}
// If the card was immediately redeemed to a user balance in this
// request, reverse that credit (guarded so it can never go negative).
if redeemToUserID != nil && *redeemToUserID != "" {
bTag, bErr := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, amount, *redeemToUserID)
if bErr != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
}
if bTag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the credited
// balance was already spent. The sale is still marked failed
// below — do not fail the whole clawback tx — but the
// un-reversed credit must be flagged for manual reconciliation
// (mirrors the top-up branch).
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (balance < amount)", giftCardID, amount, *redeemToUserID)
}
}
} else {
// Top-up: subtract the amount back out of the card. The guard keeps
// amount_remaining from ever going negative in the pathological case
// where some of the top-up was already spent before the charge failed.
tag, err := tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added - $1,
amount_remaining = amount_remaining - $1
WHERE id = $2 AND amount_remaining >= $1
`, amount, giftCardID)
if err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if tag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the top-up was
// already spent. The sale is still marked failed below — do not
// fail the whole clawback tx — but the unreversed money must be
// flagged for manual reconciliation.
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID)
}
// Remove only this request's top-up transaction (reference_id = till
// sale) so prior sales' accounting on the same card is untouched.
if _, err := tx.Exec(ctx, `
DELETE FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit clawback transaction: %w", err)
}
return nil
} }
func CreateTillSale(w http.ResponseWriter, r *http.Request) { func CreateTillSale(w http.ResponseWriter, r *http.Request) {
+21 -8
View File
@@ -472,12 +472,23 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// Reuse a valid pending code when one exists; otherwise generate + deliver // Reuse a valid pending code when one exists; otherwise generate + deliver
// a fresh one via the same [2FA] log channel as setup. // a fresh one via the same [2FA] log channel as setup.
if err := ensurePendingTwoFACode(r, userID); err != nil { freshDelivered, err := ensurePendingTwoFACode(r, userID)
if err != nil {
log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err) log.Printf("failed to prepare 2FA code for disable for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
return return
} }
// The fresh delivery reset the shared attempt map entry (twoFAResetAttempts
// deletes it), but the held st still carries the pre-delivery count. Reset
// it only when a fresh code was actually delivered, so a locked-out user can
// use the code just minted in THIS request — while the reuse path keeps
// accumulating wrong attempts toward the 5-attempt lockout.
if freshDelivered {
st.count = 0
st.lastAt = clock.Now()
}
result, err := checkTwoFACode(r, userID, st, req.Code) result, err := checkTwoFACode(r, userID, st, req.Code)
if err != nil { if err != nil {
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err) log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
@@ -507,10 +518,12 @@ func DisableTwoFAHandler(w http.ResponseWriter, r *http.Request) {
// ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending // ensurePendingTwoFACode guarantees the user has a valid (unexpired) pending
// code to verify against, generating + delivering a fresh one via the same [2FA] // code to verify against, generating + delivering a fresh one via the same [2FA]
// log channel as setup when the stored code is missing or expired. A fresh code // log channel as setup when the stored code is missing or expired. The boolean
// also resets any prior lockout, matching setup's recovery behavior. The caller // reports whether a fresh code was delivered (false = an existing valid code
// must hold the user's attempt-state mutex. // was reused), which the caller uses to decide whether to reset the held
func ensurePendingTwoFACode(r *http.Request, userID string) error { // attempt counter. A fresh code also resets any prior lockout, matching setup's
// recovery behavior. The caller must hold the user's attempt-state mutex.
func ensurePendingTwoFACode(r *http.Request, userID string) (bool, 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(), `
@@ -519,13 +532,13 @@ func ensurePendingTwoFACode(r *http.Request, userID string) error {
WHERE id = $1 WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires) `, userID).Scan(&pendingHash, &pendingExpires)
if err != nil { if err != nil {
return err return false, err
} }
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) { if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return nil return false, nil
} }
_, err = deliverTwoFACode(r, userID, "", "disable 2FA") _, err = deliverTwoFACode(r, userID, "", "disable 2FA")
return err return true, err
} }
// disableTwoFA clears two_factor_enabled and the method + pending code fields. // disableTwoFA clears two_factor_enabled and the method + pending code fields.
+55 -2
View File
@@ -83,6 +83,16 @@ func seedPendingTwoFA(t *testing.T, ctx context.Context, q db.Querier, userID, c
require.NoError(t, err) require.NoError(t, err)
} }
// extractCodeFromLog pulls the 6-digit code out of a captured [2FA] log line.
func extractCodeFromLog(t *testing.T, logOut string) string {
t.Helper()
m := regexp.MustCompile(`\[2FA\].*: (\d{6})`).FindStringSubmatch(logOut)
if len(m) < 2 {
return ""
}
return m[1]
}
func TestTwoFAStatus_NotEnabled(t *testing.T) { func TestTwoFAStatus_NotEnabled(t *testing.T) {
twofaEnvUnenforced(t) twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -315,8 +325,8 @@ func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(t *testing.T) {
} }
// TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares // TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares
// the 5-attempt lockout: 4 wrong codes 400, the 5th 429s and invalidates the // the 5-attempt lockout with verify: 4 wrong codes 400, the 5th 429s and
// pending code. // invalidates the pending code.
func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) { func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
twofaEnvEnforced(t) twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
@@ -344,6 +354,49 @@ func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
require.True(t, enabled, "locked-out user must still have 2FA enabled") require.True(t, enabled, "locked-out user must still have 2FA enabled")
} }
// TestTwoFA_VerifyAndDisable_SharedLockoutResetsOnFreshCode pins the shared
// per-user lockout across verify and disable: 5 wrong VERIFY attempts 429 and
// destroy the pending code; a subsequent DISABLE with a wrong code returns 400
// (not 429) because the disable flow delivers a FRESH code which resets the
// shared counter — and only that fresh code (not the old one) succeeds.
func TestTwoFA_VerifyAndDisable_SharedLockoutResetsOnFreshCode(t *testing.T) {
twofaEnvEnforced(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `UPDATE users SET two_factor_enabled = true, two_factor_method = 'email' WHERE id = $1`, userID)
require.NoError(t, err)
seedPendingTwoFA(t, ctx, tx, userID, "123456")
// Burn all 5 attempts on VERIFY: 4 wrong 400, the 5th 429 + code destroyed.
for i := 0; i < 4; i++ {
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "verify attempt %d", i+1)
}
w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "999999"}, userID)
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
// The lockout is shared: the OLD code is gone, so disabling with it fails.
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
// First disable delivers a fresh code (resetting the shared counter) and
// rejects the stale submission with 400, not 429.
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: "123456"}, userID)
require.Equal(t, http.StatusBadRequest, w.Code, "stale code must be rejected after lockout")
// The freshly delivered code succeeds in the SAME request that generated it.
freshCode := extractCodeFromLog(t, buf.String())
require.NotEmpty(t, freshCode, "disable must deliver a fresh code after verify lockout")
w = performUser2FARequest(t, DisableTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/disable", TwoFADisableRequest{Code: freshCode}, userID)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var enabled bool
require.NoError(t, tx.QueryRow(ctx, "SELECT two_factor_enabled FROM users WHERE id = $1", userID).Scan(&enabled))
require.False(t, enabled, "fresh code must disable 2FA after the shared lockout")
}
// TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an // TestTwoFADisable_Unenforced_NoCodeRequired verifies the dev bypass: in an
// unenforced env disabling works with no code at all. // unenforced env disabling works with no code at all.
func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) { func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) {
+64 -114
View File
@@ -6,8 +6,8 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -16,8 +16,7 @@ import (
"sync" "sync"
"crussell/db" "crussell/db"
"crussell/handlers/payments"
"github.com/jackc/pgx/v5"
) )
type SquareWebhookEvent struct { type SquareWebhookEvent struct {
@@ -182,7 +181,7 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
var dispatchErr error var dispatchErr error
switch event.Type { switch event.Type {
case "payment.updated", "payment.created": case "payment.updated", "payment.created":
dispatchErr = handlePaymentUpdated(event.Data) dispatchErr = handlePaymentUpdated(r.Context(), event.Data)
case "refund.updated", "refund.created": case "refund.updated", "refund.created":
dispatchErr = handleRefundUpdated(event.Data) dispatchErr = handleRefundUpdated(event.Data)
case "dispute.created": case "dispute.created":
@@ -399,12 +398,48 @@ func findPaymentByDisputeID(squareDisputeID string) (paymentID, bookingID string
return pid, bookingID return pid, bookingID
} }
// disputeNotificationID derives the deterministic admin_notifications id for an
// untracked dispute's critical_payment_log notification: 'D' + 11 lowercase hex
// chars of a SHA-256 over 'dispute-<square_dispute_id>'. generate_short_id
// (init-script.sql) only ever emits 12 lowercase hex chars
// (substr(encode(gen_random_bytes(6),'hex'),1,12)), so the uppercase 'D' prefix
// guarantees this can never collide with a DB-generated id. The id is stable
// per dispute, giving ON CONFLICT (id) DO NOTHING per-dispute idempotency.
func disputeNotificationID(squareDisputeID string) string {
sum := sha256.Sum256([]byte("dispute-" + squareDisputeID))
return "D" + hex.EncodeToString(sum[:])[:11]
}
// insertCriticalPaymentNotification surfaces a money event in the admin // insertCriticalPaymentNotification surfaces a money event in the admin
// notification centre (reason='critical_payment_log'), the DB-backed stand-in // notification centre (reason='critical_payment_log'), the DB-backed stand-in
// for un-watched CRITICAL log lines (see ScanCriticalPaymentLogs in // for un-watched CRITICAL log lines (see ScanCriticalPaymentLogs in
// internal/jobs/cleanup.go). Dedup: one unacknowledged row per (reason, // internal/jobs/cleanup.go). Dedup: one unacknowledged row per (reason,
// booking_id) — acknowledging re-arms it. // booking_id) — acknowledging re-arms it.
func insertCriticalPaymentNotification(bookingID string) { //
// Untracked disputes (no local payment row, booking_id NULL) pass disputeID
// instead: each DISTINCT square dispute gets its OWN notification under the
// deterministic id (disputeNotificationID), so a second distinct chargeback is
// never suppressed by the first's (reason, NULL booking) row — and re-delivery
// of the same dispute is a no-op (ON CONFLICT (id) DO NOTHING). The
// booking-scoped NOT EXISTS guard does NOT apply to this path: it would
// collapse every untracked dispute onto one unacknowledged NULL-booking row.
func insertCriticalPaymentNotification(bookingID, disputeID string) {
if disputeID != "" {
id := disputeNotificationID(disputeID)
tag, err := db.Conn.Exec(context.Background(), `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
ON CONFLICT (id) DO NOTHING
`, id)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] Inserted critical_payment_log admin notification (dispute_id=%s, booking_id=NULL)", disputeID)
}
return
}
var bid any var bid any
if bookingID != "" { if bookingID != "" {
bid = bookingID bid = bookingID
@@ -451,7 +486,7 @@ func markPaymentFailed(paymentID string) error {
// no-op when the local status already matches, and event_id dedup prevents // no-op when the local status already matches, and event_id dedup prevents
// re-entry at the handler level. A non-nil error means dispatch failed and the // re-entry at the handler level. A non-nil error means dispatch failed and the
// caller must NOT commit the dedup row (Square retries). // caller must NOT commit the dedup row (Square retries).
func handlePaymentUpdated(data json.RawMessage) error { func handlePaymentUpdated(ctx context.Context, data json.RawMessage) error {
var env squareWebhookData var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil { if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data)) log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data))
@@ -493,7 +528,7 @@ func handlePaymentUpdated(data json.RawMessage) error {
// pending sales added, exactly like the stale-pending sweep // pending sales added, exactly like the stale-pending sweep
// (handlers/payments/sweep.go); an ambiguous status never reaches here. // (handlers/payments/sweep.go); an ambiguous status never reaches here.
if localStatus == "failed" { if localStatus == "failed" {
return clawbackFailedTillSales(payment.ID) return clawbackFailedTillSales(ctx, payment.ID)
} }
tsTag, err := db.Conn.Exec(context.Background(), tsTag, err := db.Conn.Exec(context.Background(),
`UPDATE till_sales SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`, `UPDATE till_sales SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`,
@@ -508,12 +543,6 @@ func handlePaymentUpdated(data json.RawMessage) error {
return nil return nil
} }
// errTillSaleNotPending mirrors the sweep's claim-first guard: the gating
// UPDATE matched zero rows, so the sale is no longer 'pending' and its gift
// card must be left untouched (a sale already resolved by a concurrent
// completion/clawback is not ours to revert).
var errTillSaleNotPending = errors.New("till sale is not pending")
// clawbackFailedTillSales reverts the gift-card funding of every still-pending // clawbackFailedTillSales reverts the gift-card funding of every still-pending
// till sale funded by a Square charge that is DEFINITIVELY failed (Square // till sale funded by a Square charge that is DEFINITIVELY failed (Square
// FAILED/CANCELED — never ambiguous). It mirrors the stale-pending sweep's // FAILED/CANCELED — never ambiguous). It mirrors the stale-pending sweep's
@@ -523,7 +552,7 @@ var errTillSaleNotPending = errors.New("till sale is not pending")
// revert commit atomically. A non-nil error means a DB failure left a pending // revert commit atomically. A non-nil error means a DB failure left a pending
// sale's funding unreverted — the caller rejects the webhook so Square retries // sale's funding unreverted — the caller rejects the webhook so Square retries
// the clawback (the sweep is the eventual backstop). // the clawback (the sweep is the eventual backstop).
func clawbackFailedTillSales(squarePaymentID string) error { func clawbackFailedTillSales(ctx context.Context, squarePaymentID string) error {
rows, err := db.Conn.Query(context.Background(), ` rows, err := db.Conn.Query(context.Background(), `
SELECT ts.id, ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by, SELECT ts.id, ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(ts.created_at = gc.created_at) AS is_create (ts.created_at = gc.created_at) AS is_create
@@ -549,7 +578,7 @@ func clawbackFailedTillSales(squarePaymentID string) error {
log.Printf("[SQUARE-WEBHOOK] Failed to scan pending till_sale for funding clawback (square payment %s): %v", squarePaymentID, err) log.Printf("[SQUARE-WEBHOOK] Failed to scan pending till_sale for funding clawback (square payment %s): %v", squarePaymentID, err)
return err return err
} }
if err := clawbackOneTillSale(saleID, itemType, itemID, totalAmount, redeemedBy, isCreate); err != nil { if err := clawbackOneTillSale(ctx, saleID, itemType, itemID, totalAmount, redeemedBy, isCreate); err != nil {
return err return err
} }
} }
@@ -560,7 +589,7 @@ func clawbackFailedTillSales(squarePaymentID string) error {
// charge. A gift-card sale has its funding reverted atomically with the failed // charge. A gift-card sale has its funding reverted atomically with the failed
// mark; a sale with no gift card (future retail product / orphaned item) is // mark; a sale with no gift card (future retail product / orphaned item) is
// only marked failed. An already-resolved sale is skipped, not an error. // only marked failed. An already-resolved sale is skipped, not an error.
func clawbackOneTillSale(saleID, itemType string, itemID sql.NullString, totalAmount float64, redeemedBy sql.NullString, isCreate *bool) error { func clawbackOneTillSale(ctx context.Context, saleID, itemType string, itemID sql.NullString, totalAmount float64, redeemedBy sql.NullString, isCreate *bool) error {
if itemType != "gift_card" || !itemID.Valid || itemID.String == "" || isCreate == nil { if itemType != "gift_card" || !itemID.Valid || itemID.String == "" || isCreate == nil {
// No gift card to claw back — mark the sale failed without touching // No gift card to claw back — mark the sale failed without touching
// any card (mirrors the sweep's non-gift-card branch). // any card (mirrors the sweep's non-gift-card branch).
@@ -585,8 +614,8 @@ func clawbackOneTillSale(saleID, itemType string, itemID sql.NullString, totalAm
if redeemedBy.Valid && redeemedBy.String != "" { if redeemedBy.Valid && redeemedBy.String != "" {
redeem = &redeemedBy.String redeem = &redeemedBy.String
} }
if err := revertTillSaleGiftCardFunding(action, itemID.String, totalAmount, redeem, saleID); err != nil { if err := revertTillSaleGiftCardFunding(ctx, action, itemID.String, totalAmount, redeem, saleID); err != nil {
if errors.Is(err, errTillSaleNotPending) { if payments.IsTillSaleNotPending(err) {
log.Printf("[SQUARE-WEBHOOK] Till sale %s was already resolved (not pending) — skipping funding clawback", saleID) log.Printf("[SQUARE-WEBHOOK] Till sale %s was already resolved (not pending) — skipping funding clawback", saleID)
return nil return nil
} }
@@ -597,96 +626,15 @@ func clawbackOneTillSale(saleID, itemType string, itemID sql.NullString, totalAm
} }
// revertTillSaleGiftCardFunding undoes the gift-card funding of a till sale // revertTillSaleGiftCardFunding undoes the gift-card funding of a till sale
// whose charge definitively failed, in the SAME transaction as the failed mark // whose charge definitively failed. It delegates to the single shared clawback
// (claim-first): a created card is deleted (with its purchase transaction) and // implementation, payments.RevertGiftCardFunding (handlers/payments/
// any immediate redeem-to-account credit reversed; a topped-up card has the // giftcard_clawback.go) — the same helper the till handler and the stale-pending
// amount subtracted back out and its top-up transaction removed. // sweep use — so the webhook's money reversal can never drift from theirs. The
// // claim-first status='pending' guard, the create/top-up branches, the
// This is a byte-for-byte copy of revertGiftCardFunding in // redeem-to-user reversal, the CRITICAL reconciliation log lines and the
// handlers/payments/till.go (the webhook path cannot reuse the till handler's // errTillSaleNotPending sentinel all live in that one place.
// signature), and the two MUST be kept in sync: a fix or schema change applied func revertTillSaleGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
// to only one silently diverges the sweep's clawback from the webhook's. Keep return payments.RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
// the SQL and the CRITICAL log lines identical in both.
func revertTillSaleGiftCardFunding(action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(context.Background())
if err != nil {
return fmt.Errorf("failed to begin clawback transaction: %w", err)
}
defer func() {
if err := tx.Rollback(context.Background()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Printf("[SQUARE-WEBHOOK] failed to rollback gift-card clawback transaction: %v", err)
}
}()
// Claim the sale first: the row lock serializes against a concurrent
// completion UPDATE; a zero-row claim means the funding is not ours.
tag, err := tx.Exec(context.Background(), `
UPDATE till_sales SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'`, tillSaleID)
if err != nil {
return fmt.Errorf("failed to claim till sale for clawback: %w", err)
}
if tag.RowsAffected() == 0 {
return errTillSaleNotPending
}
if action == "create" {
// A newly created card's transactions are scoped to THIS sale's
// funding (reference_type='till_sale' AND reference_id=sale id) — never
// a wholesale delete, which would destroy the value of a different
// idempotency-keyed top-up sale that funded the same card before this
// create resolved. Then remove the card itself.
if _, err := tx.Exec(context.Background(), `DELETE FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card transaction: %w", err)
}
if _, err := tx.Exec(context.Background(), `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card: %w", err)
}
// If the card was immediately redeemed to a user balance in this
// request, reverse that credit (guarded so it can never go negative).
if redeemToUserID != nil && *redeemToUserID != "" {
bTag, bErr := tx.Exec(context.Background(), `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, amount, *redeemToUserID)
if bErr != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", bErr)
}
if bTag.RowsAffected() == 0 {
log.Printf("CRITICAL: [SQUARE-WEBHOOK] ... MANUAL RECONCILIATION REQUIRED: create-with-redeem clawback for gift card %s could not fully reverse the £%.2f balance credited to user %s (balance < amount)", giftCardID, amount, *redeemToUserID)
}
}
} else {
// Top-up: subtract the amount back out of the card. The guard keeps
// amount_remaining from ever going negative in the pathological case
// where some of the top-up was already spent before the charge failed.
tag, err := tx.Exec(context.Background(), `
UPDATE gift_cards
SET total_funds_added = total_funds_added - $1,
amount_remaining = amount_remaining - $1
WHERE id = $2 AND amount_remaining >= $1
`, amount, giftCardID)
if err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if tag.RowsAffected() == 0 {
log.Printf("CRITICAL: [SQUARE-WEBHOOK] ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID)
}
// Remove only this request's top-up transaction (reference_id = till
// sale) so prior sales' accounting on the same card is untouched.
if _, err := tx.Exec(context.Background(), `
DELETE FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
}
}
if err := tx.Commit(context.Background()); err != nil {
return fmt.Errorf("failed to commit clawback transaction: %w", err)
}
return nil
} }
// handleRefundUpdated reconciles a Square Refund state change against the local // handleRefundUpdated reconciles a Square Refund state change against the local
@@ -783,11 +731,13 @@ func handleDisputeCreated(data json.RawMessage) error {
// (Dashboard-initiated, mismatched Square payment id, or a deleted/erased // (Dashboard-initiated, mismatched Square payment id, or a deleted/erased
// row). There is NO sweep fallback for disputes — this notification is // row). There is NO sweep fallback for disputes — this notification is
// the only in-app trace the owner gets that Square is clawing back funds, // the only in-app trace the owner gets that Square is clawing back funds,
// so it must never be skipped. booking_id stays NULL; the helper's dedup // so it must never be skipped. booking_id stays NULL; each DISTINCT
// guard keeps ONE unacknowledged row until the owner acts on it. Still // dispute gets its OWN deterministic-id notification (the booking-scoped
// return nil so the dedup row commits and Square's retry is acknowledged. // dedup would collapse separate chargebacks into one suppressed row).
// Still return nil so the dedup row commits and Square's retry is
// acknowledged.
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created for square payment %q with NO local payment row — chargeback cannot be reconciled in-app — admin notified (booking_id NULL)", dispute.ID, squarePaymentID) log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created for square payment %q with NO local payment row — chargeback cannot be reconciled in-app — admin notified (booking_id NULL)", dispute.ID, squarePaymentID)
insertCriticalPaymentNotification("") insertCriticalPaymentNotification("", dispute.ID)
return nil return nil
} }
amount := squareMoneyToAmount(dispute.AmountMoney) amount := squareMoneyToAmount(dispute.AmountMoney)
@@ -801,7 +751,7 @@ func handleDisputeCreated(data json.RawMessage) error {
return err return err
} }
_ = tag _ = tag
insertCriticalPaymentNotification(bookingID) insertCriticalPaymentNotification(bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID) log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID)
return nil return nil
} }
@@ -856,7 +806,7 @@ func handleDisputeStateUpdated(data json.RawMessage) error {
if err := markPaymentFailed(paymentID); err != nil { if err := markPaymentFailed(paymentID); err != nil {
return err return err
} }
insertCriticalPaymentNotification(bookingID) insertCriticalPaymentNotification(bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s LOST — payment %s marked failed; admin notified", dispute.ID, paymentID) log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s LOST — payment %s marked failed; admin notified", dispute.ID, paymentID)
case "won": case "won":
log.Printf("[SQUARE-WEBHOOK] dispute %s WON — resolved in seller's favour; no action", dispute.ID) log.Printf("[SQUARE-WEBHOOK] dispute %s WON — resolved in seller's favour; no action", dispute.ID)
@@ -5,6 +5,7 @@ package webhooks
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -316,10 +317,10 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
} }
func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) { func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
// insertCriticalPaymentNotification dedups on unacknowledged rows per // Each untracked dispute now gets its own deterministic-id notification, but
// (reason, booking_id), so a NULL-booking notification left unacknowledged // a booking-scoped (reason, booking_id, acknowledged_at IS NULL) guard still
// by an earlier test would mask this test's assertion. Acknowledge any // shares the NULL-booking slot for tracked-with-no-booking disputes — so
// stragglers first. // acknowledge any unacknowledged stragglers to keep this assertion scoped.
if _, err := db.Conn.Exec(context.Background(), if _, err := db.Conn.Exec(context.Background(),
"UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil { "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil {
t.Fatalf("failed to acknowledge prior critical notifications: %v", err) t.Fatalf("failed to acknowledge prior critical notifications: %v", err)
@@ -374,6 +375,99 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
} }
} }
// TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications
// locks the per-dispute dedup fix: two DISTINCT untracked chargebacks (no local
// payment row) must each raise their OWN unacknowledged NULL-booking critical
// notification. The old (reason, booking_id, acknowledged_at IS NULL) dedup
// collapsed them onto one row, silently suppressing the second chargeback.
func TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications(t *testing.T) {
disputes := []struct{ disputeID, paymentID string }{
{"dts_untracked_a", "sqp_never_a"},
{"dts_untracked_b", "sqp_never_b"},
}
for i, d := range disputes {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: fmt.Sprintf("evt_untracked_distinct_%d", i),
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + d.disputeID + `",
"object": {
"dispute": {
"id": "` + d.disputeID + `",
"state": "UNDER_REVIEW",
"amount_money": {"amount": 1000, "currency": "GBP"},
"disputed_payment": {"payment_id": "` + d.paymentID + `"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", d.disputeID, w.Code, w.Body.String())
}
}
// BOTH distinct disputes must have their own unacknowledged NULL-booking
// notification (the second must not be suppressed by the first).
for _, d := range disputes {
var n int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM admin_notifications
WHERE id = $1 AND reason = 'critical_payment_log'
AND booking_id IS NULL AND acknowledged_at IS NULL
`, disputeNotificationID(d.disputeID)).Scan(&n); err != nil {
t.Fatalf("failed to count notifications for %s: %v", d.disputeID, err)
}
if n != 1 {
t.Errorf("expected exactly 1 unacknowledged notification for dispute %s, got %d", d.disputeID, n)
}
}
}
// TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification
// locks the per-dispute idempotency: re-delivery of the SAME untracked dispute
// (under a FRESH event_id, so the handler-level event_id dedup is bypassed)
// must NOT create a second notification — the deterministic per-dispute id keeps
// it to one row.
func TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification(t *testing.T) {
const disputeID = "dts_untracked_redeliv"
for _, eventID := range []string{"evt_untracked_redeliv_1", "evt_untracked_redeliv_2"} {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: eventID,
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + disputeID + `",
"object": {
"dispute": {
"id": "` + disputeID + `",
"state": "UNDER_REVIEW",
"amount_money": {"amount": 1000, "currency": "GBP"},
"disputed_payment": {"payment_id": "sqp_never_redeliv"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for %s, got %d: %s", eventID, w.Code, w.Body.String())
}
}
var n int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM admin_notifications
WHERE id = $1 AND reason = 'critical_payment_log' AND booking_id IS NULL
`, disputeNotificationID(disputeID)).Scan(&n); err != nil {
t.Fatalf("failed to count notifications for %s: %v", disputeID, err)
}
if n != 1 {
t.Errorf("expected exactly 1 notification for the re-delivered dispute, got %d", n)
}
}
func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) { func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
const squarePaymentID = "sqp_dispute_longreason" const squarePaymentID = "sqp_dispute_longreason"
_ = createWebhookTestPayment(t, squarePaymentID, "completed") _ = createWebhookTestPayment(t, squarePaymentID, "completed")
+3
View File
@@ -132,6 +132,9 @@ func initSquare() {
// misconfiguration) leaves the gate disabled, so saved-card charges can // misconfiguration) leaves the gate disabled, so saved-card charges can
// never silently ship without the PSD2 SCA stand-in. // never silently ship without the PSD2 SCA stand-in.
enforced := payments.NewPaymentService().TwoFactorEnforced() enforced := payments.NewPaymentService().TwoFactorEnforced()
if enforced {
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with log read access can defeat the 2FA gate. Restrict backend log access and relay codes out-of-band; this loose-fake delivery must be replaced by email/SMS (P6) before launch.")
}
if !enforced && !payments.IsExplicitDevOrMockEnv() { if !enforced && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env) log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
} }
+2 -2
View File
@@ -817,7 +817,7 @@ validTransitions := map[string]map[string]bool{
**State:** stored on `users``two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered via the server log with a `[2FA]` prefix in **all** modes — enforced and unenforced alike — the operator reads it and relays it to the customer. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs. **State:** stored on `users``two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered via the server log with a `[2FA]` prefix in **all** modes — enforced and unenforced alike — the operator reads it and relays it to the customer. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs.
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — a fresh code is generated and delivered via the same `[2FA]` log channel when none is pending, and it is checked under the shared 5-attempt lockout. In dev (unenforced) environments no code is required to disable. **Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — a fresh verification code is generated and delivered via the same `[2FA]` log channel when disabling, and it is checked under the shared 5-attempt lockout. In dev (unenforced) environments no code is required to disable.
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication. **Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication.
@@ -1306,7 +1306,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage ### Test Coverage
**2,142 tests run** across all packages (4 skipped, 0 failures). Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000. **2,151 tests run** across all packages (4 skipped, 0 failures). Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
| Package | Coverage Area | | Package | Coverage Area |
|---------|--------------| |---------|--------------|
+1 -1
View File
@@ -197,7 +197,7 @@ Customers can see their gift card details in the **Gift Cards** tab:
Customers can pay online in several ways: Customers can pay online in several ways:
**A note on saved cards:** Online payments made with a **saved card** may ask for a one-time two-factor verification code if the salon has 2FA switched on. If a customer wants to use a saved card, they can set up 2FA in advance under **Account → Admin** (Two-Factor Authentication). New-card payments don't need it. **A note on saved cards:** Online payments made with a **saved card** may ask for a one-time two-factor verification code if the salon has 2FA switched on. If a customer wants to use a saved card, they can set up 2FA in advance on their **Account** page (Two-Factor Authentication section). New-card payments don't need it.
### Paying a Deposit ### Paying a Deposit