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
cd backend && go build -o bin/backend ./main.go
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=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.
var errTillSaleNotPending = errors.New("till sale is not pending")
// 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.
// revertGiftCardFunding is the package-internal wrapper over the shared
// RevertGiftCardFunding clawback helper (giftcard_clawback.go). The sweep and
// the till handler both call it so every clawback path runs the single
// money-reversal implementation.
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
return RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
}
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
// 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)
http.Error(w, "server error", http.StatusInternalServerError)
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)
if err != nil {
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
// 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
// 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) error {
// log channel as setup when the stored code is missing or expired. The boolean
// reports whether a fresh code was delivered (false = an existing valid code
// was reused), which the caller uses to decide whether to reset the held
// 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 pendingExpires sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
@@ -519,13 +532,13 @@ func ensurePendingTwoFACode(r *http.Request, userID string) error {
WHERE id = $1
`, userID).Scan(&pendingHash, &pendingExpires)
if err != nil {
return err
return false, err
}
if pendingHash.Valid && pendingExpires.Valid && pendingExpires.Time.After(clock.Now()) {
return nil
return false, nil
}
_, err = deliverTwoFACode(r, userID, "", "disable 2FA")
return err
return true, err
}
// 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)
}
// 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) {
twofaEnvUnenforced(t)
ctx, tx := testutils.SetupTestTx(t)
@@ -315,8 +325,8 @@ func TestTwoFADisable_NoPendingCode_GeneratesFreshCode(t *testing.T) {
}
// TestTwoFADisable_LockoutAfterFiveFailedAttempts verifies that disable shares
// the 5-attempt lockout: 4 wrong codes 400, the 5th 429s and invalidates the
// pending code.
// the 5-attempt lockout with verify: 4 wrong codes 400, the 5th 429s and
// invalidates the pending code.
func TestTwoFADisable_LockoutAfterFiveFailedAttempts(t *testing.T) {
twofaEnvEnforced(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")
}
// 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
// unenforced env disabling works with no code at all.
func TestTwoFADisable_Unenforced_NoCodeRequired(t *testing.T) {
+64 -114
View File
@@ -6,8 +6,8 @@ import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -16,8 +16,7 @@ import (
"sync"
"crussell/db"
"github.com/jackc/pgx/v5"
"crussell/handlers/payments"
)
type SquareWebhookEvent struct {
@@ -182,7 +181,7 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
var dispatchErr error
switch event.Type {
case "payment.updated", "payment.created":
dispatchErr = handlePaymentUpdated(event.Data)
dispatchErr = handlePaymentUpdated(r.Context(), event.Data)
case "refund.updated", "refund.created":
dispatchErr = handleRefundUpdated(event.Data)
case "dispute.created":
@@ -399,12 +398,48 @@ func findPaymentByDisputeID(squareDisputeID string) (paymentID, bookingID string
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
// notification centre (reason='critical_payment_log'), the DB-backed stand-in
// for un-watched CRITICAL log lines (see ScanCriticalPaymentLogs in
// internal/jobs/cleanup.go). Dedup: one unacknowledged row per (reason,
// 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
if 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
// re-entry at the handler level. A non-nil error means dispatch failed and the
// 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
if err := json.Unmarshal(data, &env); err != nil {
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
// (handlers/payments/sweep.go); an ambiguous status never reaches here.
if localStatus == "failed" {
return clawbackFailedTillSales(payment.ID)
return clawbackFailedTillSales(ctx, payment.ID)
}
tsTag, err := db.Conn.Exec(context.Background(),
`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
}
// 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
// till sale funded by a Square charge that is DEFINITIVELY failed (Square
// 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
// sale's funding unreverted — the caller rejects the webhook so Square retries
// 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(), `
SELECT ts.id, ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(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)
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
}
}
@@ -560,7 +589,7 @@ func clawbackFailedTillSales(squarePaymentID string) error {
// 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
// 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 {
// No gift card to claw back — mark the sale failed without touching
// 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 != "" {
redeem = &redeemedBy.String
}
if err := revertTillSaleGiftCardFunding(action, itemID.String, totalAmount, redeem, saleID); err != nil {
if errors.Is(err, errTillSaleNotPending) {
if err := revertTillSaleGiftCardFunding(ctx, action, itemID.String, totalAmount, redeem, saleID); err != nil {
if payments.IsTillSaleNotPending(err) {
log.Printf("[SQUARE-WEBHOOK] Till sale %s was already resolved (not pending) — skipping funding clawback", saleID)
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
// whose charge definitively failed, in the SAME transaction as the failed mark
// (claim-first): 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.
//
// This is a byte-for-byte copy of revertGiftCardFunding in
// handlers/payments/till.go (the webhook path cannot reuse the till handler's
// signature), and the two MUST be kept in sync: a fix or schema change applied
// to only one silently diverges the sweep's clawback from the webhook's. Keep
// 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
// whose charge definitively failed. It delegates to the single shared clawback
// implementation, payments.RevertGiftCardFunding (handlers/payments/
// giftcard_clawback.go) — the same helper the till handler and the stale-pending
// 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
// redeem-to-user reversal, the CRITICAL reconciliation log lines and the
// errTillSaleNotPending sentinel all live in that one place.
func revertTillSaleGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
return payments.RevertGiftCardFunding(ctx, action, giftCardID, amount, redeemToUserID, tillSaleID)
}
// 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
// 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,
// so it must never be skipped. booking_id stays NULL; the helper's dedup
// guard keeps ONE unacknowledged row until the owner acts on it. Still
// return nil so the dedup row commits and Square's retry is acknowledged.
// so it must never be skipped. booking_id stays NULL; each DISTINCT
// dispute gets its OWN deterministic-id notification (the booking-scoped
// 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)
insertCriticalPaymentNotification("")
insertCriticalPaymentNotification("", dispute.ID)
return nil
}
amount := squareMoneyToAmount(dispute.AmountMoney)
@@ -801,7 +751,7 @@ func handleDisputeCreated(data json.RawMessage) error {
return err
}
_ = 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)
return nil
}
@@ -856,7 +806,7 @@ func handleDisputeStateUpdated(data json.RawMessage) error {
if err := markPaymentFailed(paymentID); err != nil {
return err
}
insertCriticalPaymentNotification(bookingID)
insertCriticalPaymentNotification(bookingID, "")
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s LOST — payment %s marked failed; admin notified", dispute.ID, paymentID)
case "won":
log.Printf("[SQUARE-WEBHOOK] dispute %s WON — resolved in seller's favour; no action", dispute.ID)
@@ -5,6 +5,7 @@ package webhooks
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -316,10 +317,10 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
}
func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
// insertCriticalPaymentNotification dedups on unacknowledged rows per
// (reason, booking_id), so a NULL-booking notification left unacknowledged
// by an earlier test would mask this test's assertion. Acknowledge any
// stragglers first.
// Each untracked dispute now gets its own deterministic-id notification, but
// a booking-scoped (reason, booking_id, acknowledged_at IS NULL) guard still
// shares the NULL-booking slot for tracked-with-no-booking disputes — so
// acknowledge any unacknowledged stragglers to keep this assertion scoped.
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 {
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) {
const squarePaymentID = "sqp_dispute_longreason"
_ = createWebhookTestPayment(t, squarePaymentID, "completed")
+3
View File
@@ -132,6 +132,9 @@ func initSquare() {
// misconfiguration) leaves the gate disabled, so saved-card charges can
// never silently ship without the PSD2 SCA stand-in.
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() {
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.
**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.
@@ -1306,7 +1306,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### 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 |
|---------|--------------|
+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:
**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