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
+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")