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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user