Files
Crussell/backend/handlers/payments/refunds.go
T
popertots 67cf5b9a45 fix: review round 6 — P0 deposit charge, idempotency rotation, dev-safety guard, 2FA/webhook hardening
Sixth fresh-eyes review pass (5 agents: goal, QA, code-quality, security,
context-mining). QA FAILED the deposit-required new-card flow; the P0 root
cause was backend + frontend, now fixed. All 20 packages green.

P0 money-safety:
- Deposit-required bookings now actually charge the deposit on new-card
  payment. Two-part fix: (1) CreateBookingHandler re-reads the
  trigger-maintained total_amount/total_duration_minutes from the DB after the
  booking_services insert (the INSERT..RETURNING row predates the recalc
  trigger, so TotalAmount serialized as 0 and DepositPaid computed TRUE on an
  unpaid booking — the frontend gate trusted deposit_paid:true, never charged,
  and confirmed the booking with zero payment rows); (2) BookingFlow.svelte
  gates the confirmation view on depositPaid and guards against re-creating a
  booking on retry. Regression test
  TestBookings_Create_DepositPaidFalseOnUnpaidBooking.

Payments (idempotency + money):
- deriveBookingPaymentIdempotencyKey: no-client-key fallback now advances a
  sequence for repeatable types (partial) and rotates past refunded completed
  rows, so refund-then-repay and equal-amount partials diverge onto distinct
  keys; an un-refunded completed row keeps its key (double-charge protection
  holds). Dedup hits on refunded rows now 409, never stale success.
- chargeFailureStatus default is 503 (ambiguous), never 402; table test.
- Flaky TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance fixed
  (ORDER BY payment_type).
- resolveChargeSource: orphaned card-on-file disabled via DeleteCardOnFile
  when SaveCardForUser fails (best-effort, redacted log); retry path preserved.

Square client:
- Dev builds HARD-FAIL (panic) on SQUARE_ENVIRONMENT=production without
  SQUARE_ALLOW_REAL_API=1; sandbox routes with a loud banner.
- Mock fault-injection FailAfterCommit (commit-then-5xx) exercises the exact
  lost-response same-key retry; SimulateCardTokenUsed; 45-char idempotency-key
  cap parity; SquareEnvironment/SquareLocationID shared env helpers used by
  the sweep (env contract no longer comment-only).
- listRefunds truncation now errors (money-sensitive reconcile retries
  instead of over-refunding); getCardsOnFile truncation loudly logged.

Webhooks + 2FA:
- square-environment header checked fail-closed (403) when configured env is
  production/sandbox; dispatch DB work bounded by 30s timeout contexts.
- 2FA codes HMAC-SHA256 pepper'd (TWO_FACTOR_PEPPER) with legacy-hash
  migration + upgrade-on-verify; disable-flow mint cooldown (1/min, 429) caps
  the brute-force loop; in-lockout records never LRU-evicted.

Repo hygiene:
- env-docs CI gate green again (FRONTEND_ORIGIN + SQUARE_ALLOW_REAL_API +
  TWO_FACTOR_PEPPER documented; Vite DEV built-in allowlisted).
- Dead square_deposits schema dropped; obsidian/README/legal-page drift fixed
  (consumeradvice.scot signposting, CORS allowlist, p11 R3/P13, T1).
- 2FA disable residual documented; P6 email/SMS delivery and P12 sandbox
  smoke test remain the pre-go-live gates.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs
gate OK, live deposit-required flow re-verified end-to-end (deposit £11
charged, square_payment_id recorded).
2026-08-22 00:34:49 +01:00

1531 lines
63 KiB
Go

package payments
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log"
"log/slog"
"math"
"sort"
"strconv"
"strings"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"github.com/jackc/pgx/v5"
)
type RefundCalculationResult struct {
TotalPrePaid float64 `json:"total_pre_paid"`
ProtectedDeposit float64 `json:"protected_deposit"`
RefundableAmount float64 `json:"refundable_amount"`
KeptAmount float64 `json:"kept_amount"`
HoursUntilAppointment float64 `json:"hours_until_appointment"`
Tier string `json:"tier"`
}
// paymentRow is one completed payment on a booking, read into a slice before
// the refund loop runs (pgx.Tx does not support concurrent queries on the same
// connection, so rows must be fully drained before Exec/QueryRow in the loop).
type paymentRow struct {
ID string
Amount float64
PaymentMethod string
SquarePaymentID *string
GiftCardID *string
}
// CalculateRefundForCancellation computes the refund amounts for a cancelled booking.
//
// Track A (universal — same rules for all bookings):
// - >72 hours notice: Full refund of all pre-payments
// - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest
// - <24 hours or no-show: Keep all pre-payments
//
// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50).
// This means up to 50% of the subtotal is always treated as a deposit for
// refund purposes, regardless of whether deposit_required was set on the booking.
func CalculateRefundForCancellation(
subtotal float64,
totalPrePaid float64,
cancellationTime time.Time,
startTime time.Time,
) RefundCalculationResult {
hoursUntilAppointment := startTime.Sub(cancellationTime).Hours()
protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct)
// Round to 2 decimal places
protectedDeposit = math.Round(protectedDeposit*100) / 100
totalPrePaidRounded := math.Round(totalPrePaid*100) / 100
var refundableAmount, keptAmount float64
var tier string
fullHrs := FullRefundThreshold.Hours()
partHrs := PartialRefundThreshold.Hours()
switch {
case hoursUntilAppointment > fullHrs:
refundableAmount = totalPrePaidRounded
keptAmount = 0
tier = FullRefundTier
case hoursUntilAppointment >= partHrs:
keptAmount = protectedDeposit
refundableAmount = totalPrePaidRounded - keptAmount
if refundableAmount < 0 {
refundableAmount = 0
}
tier = PartialRefundTier
default:
keptAmount = totalPrePaidRounded
refundableAmount = 0
tier = NoRefundTier
}
return RefundCalculationResult{
TotalPrePaid: totalPrePaidRounded,
ProtectedDeposit: protectedDeposit,
RefundableAmount: refundableAmount,
KeptAmount: keptAmount,
HoursUntilAppointment: hoursUntilAppointment,
Tier: tier,
}
}
// lockCancellationPayments serializes a cancellation refund against the manual
// RefundPayment handler and the sweep. Both hold
// `pg_advisory_xact_lock(hashtext('crussell:refund:' || payment_id))`
// (transaction-level, acquired via acquireAdvisoryXactLockBlocking) on the
// payment ids they touch; a cancellation that computes residuals without the
// same locks can over-refund against a manual refund in flight (the manual
// guard read precedes the cancellation's commit). Locks are acquired in
// ascending payment_id order (matching processChargeGroup) to avoid deadlocks.
// EVERY payment row the cancellation may refund is locked — giftcard and cash
// rows included, not just card methods — so two concurrent refunds of the same
// giftcard/cash payment serialize on the residual computation and can never
// double-credit (H3).
func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []paymentRow) error {
var ids []string
for _, p := range payments {
// discount/on_the_house rows are already excluded by the caller's
// query, so every remaining row is one this loop may refund.
ids = append(ids, p.ID)
}
if len(ids) == 0 {
return nil
}
sort.Strings(ids)
for _, pid := range ids {
// Blocking xact lock (NOT the bounded try-lock used elsewhere): this is
// the admin-only cancellation path, and the manual RefundPayment handler
// can hold the same key across its up-to-30s Square round-trip. A timed
// out acquire here would abort the cancellation transaction — the caller
// (manage.go) would commit the cancellation with ZERO refund rows and no
// sweep retry could ever recover the money. Blocking guarantees the
// refund runs; the lock auto-releases at the caller's commit/rollback.
// See acquireAdvisoryXactLockBlocking for the full rationale.
if err := acquireAdvisoryXactLockBlocking(ctx, tx, "crussell:refund:"+pid); err != nil {
return fmt.Errorf("failed to acquire cancellation refund lock for payment %s: %w", pid, err)
}
}
return nil
}
// ProcessCancellationRefundTx is like ProcessCancellationRefund but uses an
// externally-provided transaction. The caller owns the transaction lifecycle
// (commit/rollback). Pass a non-nil pgx.Tx to share an existing transaction.
// forceFullRefund overrides the notice-tier calculation so the ENTIRE net
// pre-paid amount is refunded (admin "forgive fees" path) regardless of how
// close to the appointment the cancellation happens.
func ProcessCancellationRefundTx(
ctx context.Context,
tx pgx.Tx,
bookingID string,
subtotal float64,
totalPrePaid float64,
startTime time.Time,
cancellationTime time.Time,
reason string,
actorID *string,
forceFullRefund bool,
) (*RefundCalculationResult, error) {
calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime)
if forceFullRefund {
calc.RefundableAmount = calc.TotalPrePaid
calc.KeptAmount = 0
calc.Tier = "admin_full_refund"
}
if calc.RefundableAmount <= 0 {
return &calc, nil
}
// Get the booking's user info for refund routing.
var bookingUserID string
var isGuest bool
bookingUserLookupFailed := false
if err := tx.QueryRow(ctx, `
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id = $1
`, bookingID).Scan(&bookingUserID, &isGuest); err != nil {
log.Printf("Failed to get booking user info for refund: %v", err)
// Non-fatal for Square refunds (which route by payment, not user), but
// the balance-credit branches below must know the lookup FAILED (vs a
// genuine guest) so they never record a 'completed' refund when no
// money could be credited — see creditFailed.
bookingUserLookupFailed = true
}
rows, err := tx.Query(ctx, `
SELECT id, amount, payment_method, square_payment_id, gift_card_id
FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
AND payment_type <> 'tip'
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for refund: %v", err)
return &calc, nil
}
defer rows.Close()
// Read all payments into a slice, then close rows immediately.
var payments []paymentRow
for rows.Next() {
var p paymentRow
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
payments = append(payments, p)
}
if err := rows.Err(); err != nil {
log.Printf("Payment row iteration error: %v", err)
}
// Serialize against the manual RefundPayment handler and the sweep — the
// residual calculation below must not race a manual refund in flight. If
// any lock fails, abort: continuing without the lock reopens the over-refund
// race. pg_advisory_xact_lock auto-releases at the caller's commit.
if err := lockCancellationPayments(ctx, tx, payments); err != nil {
return nil, err
}
refundRemaining := calc.RefundableAmount
// Prior refunds per payment record (completed + pending) — the loop must
// not re-refund money already returned. Sums by payment_id; pending counts
// because a Square call may already be in flight.
//
// This DB-side over-refund guard (completed + pending) is what prevents
// Square's REFUND_AMOUNT_INVALID in practice: a refund is never issued past
// the residual `amount - already`. Both this cancellation path and the
// manual RefundPayment handler compute residuals while holding the same
// advisory lock (`hashtext('crussell:refund:' || payment_id)` — see
// lockCancellationPayments), so a manual refund cannot slip past the guard
// and a cancellation refund cannot be recorded after the manual guard ran
// without the two serializing. Square no longer documents
// PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is
// REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined
// (definitive) — the charge-group/manual sweep handlers fail those rows and
// surface them via admin_notification rather than silently blocking the
// amount in the guard.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(ctx, `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
WHERE booking_id = $1 AND status IN ('completed', 'pending')
GROUP BY payment_id`, bookingID)
if prErr != nil {
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
} else {
for prRows.Next() {
var pid string
var amt float64
if err := prRows.Scan(&pid, &amt); err == nil {
priorRefunds[pid] = amt
}
}
prRows.Close()
}
for _, p := range payments {
if refundRemaining <= 0 {
break
}
paymentID := p.ID
paymentMethod := p.PaymentMethod
amount := p.Amount
giftCardID := p.GiftCardID
already := priorRefunds[paymentID]
residual := math.Round((amount-already)*100) / 100
if residual <= 0 {
// already fully refunded — don't consume refundRemaining
continue
}
refundThisPayment := math.Min(residual, refundRemaining)
var squareRefundID *string
// creditFailed records that money for this payment did NOT actually
// move (deleted gift card, failed balance credit) so the refund record
// is inserted 'failed' instead of claiming a completed refund (M2).
creditFailed := false
switch paymentMethod {
case "online_square", "in_person_card":
// Square API refund is processed AFTER the transaction commits
// (see ProcessPendingSquareRefunds). Inside the tx we only record
// the refund record as "pending" for post-commit processing.
if isGuest || bookingUserID == "" {
log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment)
}
// squareRefundID stays nil — will be set by ProcessPendingSquareRefunds
case "giftcard":
if giftCardID == nil || *giftCardID == "" {
// A giftcard payment with no gift_card_id was made from the
// user's gift-card ACCOUNT balance (the terminal giftcard path
// stores no gift_card_id for balance payments). Credit the
// booking user's balance back — skipping would lose the money
// while the completed refund row claims it was returned (C3).
// Guests get no balance credit, mirroring the cash branch.
if isGuest || bookingUserID == "" {
if isGuest {
log.Printf("Guest giftcard refund: booking %s, payment %s, amount £%.2f — no balance credit", bookingID, paymentID, refundThisPayment)
} else {
log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", paymentID)
}
if bookingUserLookupFailed {
// The user may well exist — the lookup failed
// transiently, so 'completed' would claim money was
// credited when none could be. Mark the record failed
// for admin reconciliation instead.
creditFailed = true
}
break
}
log.Printf("Crediting £%.2f to user %s gift-card balance for account-balance giftcard payment %s", refundThisPayment, bookingUserID, paymentID)
if _, balErr := tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, bookingUserID, refundThisPayment); balErr != nil {
log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
creditFailed = true
}
break
}
// expiry_date is maintained by EVERY gift-card write that counts as
// a "use" (balance check, top-up, transfer, redeem, payment, refund
// credit), so a non-NULL expiry_date means the rolling timer is
// authoritative. NULL semantics stay fail-open: an unset expiry_date
// cannot prove the card is expired, so the refund proceeds.
var expired bool
if err := tx.QueryRow(ctx, `
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
FROM gift_cards WHERE id = $1
`, *giftCardID).Scan(&expired); err != nil {
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err)
} else if expired {
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
// Money-safety (C4): the UPDATE gift_cards credit above is
// SKIPPED for expired cards (money retained by the salon), so
// creditFailed must be set here — otherwise the shared tail
// below inserts the refund record as 'completed' claiming money
// was returned when it never moved.
creditFailed = true
break
}
// Refunding to the card is a "use" per the rolling-expiry terms —
// reset the timer at the same moment the balance is credited.
gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx)
if expiryErr != nil {
log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr)
gcExpiryMonths = defaultGiftCardExpiryMonths
}
gcTag, gcErr := tx.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month')
WHERE id = $2
`, refundThisPayment, *giftCardID, gcExpiryMonths)
if gcErr != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, gcErr)
creditFailed = true
break
}
if gcTag.RowsAffected() == 0 {
// M2: the gift card no longer exists — the UPDATE matched 0 rows
// and no money moved. Marking the refund 'completed' would claim
// money was returned when it wasn't.
slog.Error("CRITICAL: gift card refund UPDATE affected 0 rows — card deleted?, refund NOT credited", "gift_card_id", *giftCardID, "payment_id", paymentID, "booking_id", bookingID, "amount", refundThisPayment)
creditFailed = true
break
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
`, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil {
log.Printf("Failed to create gift card transaction for refund: %v", err)
}
case "cash":
if isGuest || bookingUserID == "" {
if isGuest {
log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment)
} else {
log.Printf("Cash refund: payment %s has no booking user — skipping balance credit", paymentID)
}
if bookingUserLookupFailed {
// The booking-user lookup errored, so this may be a real
// user whose balance credit was skipped — a 'completed'
// refund would claim money was returned when it wasn't.
creditFailed = true
}
} else {
log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID)
if _, balErr := tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, bookingUserID, refundThisPayment); balErr != nil {
log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
creditFailed = true
}
}
default:
log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod)
}
recordStatus := "completed"
if paymentMethod == "online_square" || paymentMethod == "in_person_card" {
recordStatus = "pending"
}
if creditFailed {
recordStatus = "failed"
}
record := RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: refundThisPayment,
SquareRefundID: squareRefundID,
Status: recordStatus,
Reason: reason,
Origin: "cancellation",
CreatedBy: actorID,
CreatedAt: clock.Now(),
}
// Deterministic idempotency key so a scheduler retry can never issue a
// second Square refund. Format never collides with the handler's
// "-refund-" keys.
refundKey := paymentID + "-square-" + strconv.FormatInt(int64(math.Round(refundThisPayment*100)), 10)
record.IdempotencyKey = &refundKey
tag, dbErr := tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (idempotency_key) DO NOTHING
`, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.IdempotencyKey, record.CreatedBy, record.CreatedAt, record.Origin)
if dbErr != nil {
log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr)
continue
}
// tag.RowsAffected() == 0 means the same idempotency_key already exists
// (a prior refund row for this payment+amount in 'failed' state — money
// never moved, but a row exists). Dedup — skip WITHOUT consuming
// refundRemaining so the loop can allocate to the next payment, exactly
// as the pre-ON-CONFLICT UNIQUE-violation path behaved.
if tag.RowsAffected() == 0 {
log.Printf("Refund for payment %s amount £%.2f already exists (idempotency dedup) — skipping without consuming refundRemaining", paymentID, refundThisPayment)
continue
}
refundRemaining -= refundThisPayment
}
if bookingUserID != "" {
var loyaltyUsed bool
if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil {
log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err)
} else if loyaltyUsed {
loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if loyaltyErr != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
} else if loyaltyTag.RowsAffected() == 0 {
slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID)
} else {
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
}
}
}
return &calc, nil
}
// ProcessCancellationRefund calculates and records refunds for a cancelled
// booking, processing refunds against the booking's completed payments up to
// the calculated refundable amount. The wrapper owns its own transaction and
// delegates the refund loop to ProcessCancellationRefundTx
// (forceFullRefund=false) so the standalone and in-transaction callers share
// one implementation; after a successful commit it runs the post-commit
// Square pass (ProcessPendingSquareRefunds). Returns the refund calculation
// and whether any refunds were processed.
func ProcessCancellationRefund(
ctx context.Context,
bookingID string,
subtotal float64,
totalPrePaid float64,
startTime time.Time,
cancellationTime time.Time,
reason string,
actorID *string,
) (*RefundCalculationResult, error) {
calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime)
if calc.RefundableAmount <= 0 {
return &calc, nil
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction for cancellation refund: %v", err)
return &calc, nil
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Delegate the whole refund loop to the transactional variant — the
// non-tx wrapper exists only to own the transaction lifecycle and fire the
// post-commit Square pass (ProcessPendingSquareRefunds) after a successful
// commit. The Tx variant returns an error ONLY on lock failure; every other
// failure logs internally and returns (calc, nil).
res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false)
if txErr != nil {
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr)
return &calc, txErr
}
if cErr := tx.Commit(ctx); cErr != nil {
slog.Error("CRITICAL: failed to commit cancellation refund", "booking_id", bookingID, "err", cErr)
return nil, fmt.Errorf("failed to commit cancellation refund: %w", cErr)
}
// Process pending Square refunds after the transaction commits successfully.
// This ensures Square API calls only happen if the DB records persist.
ProcessPendingSquareRefunds(ctx, bookingID, reason)
return res, nil
}
// ProcessPendingSquareRefunds resolves a booking's pending cancellation card
// refunds AFTER the enclosing transaction has committed — Square API calls
// only happen if the DB records persist.
//
// Pending refunds are aggregated into ONE Square refund per charge
// (square_payment_id): split payment records sharing a single Square charge
// are refunded together, never per-row. See processChargeGroup.
func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason string) {
// (a) Terminal pre-pass scoped to this booking: card refunds with no
// Square reference can never be refunded via Square. Mark them failed
// so they stop retrying, and surface the affected booking in the admin
// notification centre for in-person arrangement. Must run OUTSIDE any
// GROUP BY — Postgres lumps NULLs together, so these rows can't be
// handled in the charge grouping below.
rows, err := db.Conn.Query(ctx, `
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.booking_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`, bookingID)
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed for booking %s: %v", bookingID, err)
} else {
var failedIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
failedIDs = append(failedIDs, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate Square-less card refunds for booking %s: %v", bookingID, err)
}
rows.Close()
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less card refund(s) failed for booking %s (in-person arrangement needed)", len(failedIDs), bookingID)
}
insertRefundFailedNotifications(ctx, failedIDs)
}
// (b) This booking's card charges with pending refunds — one aggregated
// Square refund per charge.
for _, chargeID := range queryChargesWithPendingRefunds(ctx, "r.booking_id = $1", bookingID) {
if _, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), reason); err != nil {
log.Printf("Failed to process charge %s for booking %s: %v", chargeID, bookingID, err)
}
}
}
// SweepPendingSquareRefunds is the scheduler job that retries every booking's
// pending cancellation card refunds. Registered in internal/jobs/cleanup.go as
// "sweep-pending-square-refunds".
func SweepPendingSquareRefunds(ctx context.Context) (int, error) {
// (a) Terminal pre-pass across all bookings: card refunds with no Square
// reference can never be refunded via Square → mark failed and surface
// for in-person arrangement. Must run OUTSIDE any GROUP BY — Postgres
// lumps NULLs together, so these rows can't be handled in the charge
// grouping below.
rows, err := db.Conn.Query(ctx, `
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < 3
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`)
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed: %v", err)
} else {
var failedIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
failedIDs = append(failedIDs, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate Square-less card refunds during sweep: %v", err)
}
rows.Close()
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less card refund(s) failed (in-person arrangement needed)", len(failedIDs))
}
insertRefundFailedNotifications(ctx, failedIDs)
}
// (b) Charges with pending refunds — one aggregated Square refund each.
processed := 0
for _, chargeID := range queryChargesWithPendingRefunds(ctx, "") {
n, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), "scheduled_retry")
if err != nil {
log.Printf("Sweep: failed to process charge %s: %v", chargeID, err)
continue
}
processed += n
}
// (c) Stale MANUAL pending refunds (the handler's ambiguous-error path) are
// invisible to the cancellation passes above (they filter origin='manual'
// out) — without this pass they were never retried, permanently blocking
// the over-refund guard and depressing booking TotalPaid.
n, err := sweepManualPendingSquareRefunds(ctx)
if err != nil {
log.Printf("Sweep: failed to process manual pending refunds: %v", err)
} else {
processed += n
}
return processed, nil
}
// pendingChargeRow is one eligible pending cancellation refund row tied to a
// single Square charge.
type pendingChargeRow struct {
ID string // refunds.id
PaymentID string // payments.id — advisory-lock ordering
Amount float64
CreatedAt time.Time
}
// queryChargesWithPendingRefunds returns the distinct square_payment_ids that
// have at least one eligible pending cancellation refund row. extraWhere is an
// optional extra SQL predicate bound by args (e.g. "r.booking_id = $1").
func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args ...any) []string {
q := `
SELECT DISTINCT p.square_payment_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.refund_attempts < 3
AND p.square_payment_id IS NOT NULL
AND p.payment_method IN ('online_square', 'in_person_card')
AND r.origin = 'cancellation'`
if extraWhere != "" {
q += " AND " + extraWhere
}
rows, err := db.Conn.Query(ctx, q, args...)
if err != nil {
log.Printf("Failed to query charges with pending refunds: %v", err)
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
log.Printf("Failed to scan charge id: %v", err)
continue
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
log.Printf("Charge id iteration error: %v", err)
}
return out
}
// fetchPendingChargeRows returns all eligible pending cancellation refund rows
// for a single Square charge, ordered by refund id.
func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingChargeRow {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, p.id, r.amount, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE p.square_payment_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.origin = 'cancellation'
ORDER BY r.id
`, chargeID)
if err != nil {
log.Printf("Failed to query pending refunds for charge %s: %v", chargeID, err)
return nil
}
defer rows.Close()
var out []pendingChargeRow
for rows.Next() {
var pr pendingChargeRow
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan pending refund row for charge %s: %v", chargeID, err)
continue
}
out = append(out, pr)
}
if err := rows.Err(); err != nil {
log.Printf("Pending refund row iteration error for charge %s: %v", chargeID, err)
}
return out
}
// reconcileRefundAtSquare checks Square for a COMPLETED refund matching the
// exact charge-level amount before the age guard / attempt cap marks rows
// 'failed'. Tri-state return:
//
// (id, nil) — exact COMPLETED refund found → caller marks rows completed
// (nil, nil) — genuinely no match → caller may mark rows failed
// (nil, err) — reconcile FAILED (network/API error) → caller MUST leave
// rows pending and skip the terminal transition. Marking
// failed on an unknown state would let the over-refund guard
// exclude money that actually left the business.
//
// Exact equality on amount AND status COMPLETED AND payment_id: a larger
// COMPLETED refund on the same charge is a manual per-record refund,
// attributing it would mark our rows completed when the aggregate money never
// moved.
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents int64, oldestCreatedAt time.Time) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, oldestCreatedAt)
if err != nil {
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
return nil, err
}
for i := range refunds {
r := &refunds[i]
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalCents {
return &r.ID, nil
}
}
return nil, nil
}
// insertRefundFailedNotifications surfaces failed refunds in the admin
// notification centre — one row per affected booking. The sweep only processes
// 'pending' rows, so this fires once per row transition (no spam); the
// NOT EXISTS guard prevents duplicates on re-runs.
func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
if len(refundIDs) == 0 {
return
}
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, created_at)
SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW()
FROM refunds
WHERE id = ANY($1) AND status = 'failed'
AND NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id
)
`, refundIDs)
if err != nil {
log.Printf("Failed to insert admin_notifications for failed refunds: %v", err)
return
}
if n := int(tag.RowsAffected()); n > 0 {
log.Printf("Inserted %d admin_notification(s) for failed refunds", n)
}
}
// pendingRowsAtAttemptCap returns the refund ids still pending at the 3-attempt
// cap — the candidates for terminal 'failed' resolution.
func pendingRowsAtAttemptCap(ctx context.Context, ids []string) []string {
if len(ids) == 0 {
return nil
}
rows, err := db.Conn.Query(ctx, `
SELECT id FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, ids)
if err != nil {
log.Printf("Failed to query refunds at attempt cap: %v", err)
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
log.Printf("Failed to scan refund id at attempt cap: %v", err)
continue
}
out = append(out, id)
}
return out
}
// processChargeGroup issues ONE Square refund for a charge (square_payment_id)
// covering the sum of its pending cancellation refund rows — the owner
// directive that split records sharing a charge produce a single Square
// refund, never one per row.
//
// Callers pass the charge's current pending rows (fetchPendingChargeRows); the
// rows are re-read under the per-payment advisory lock so a concurrent manual
// refund or another sweep can't double-process.
func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChargeRow, reason string) (int, error) {
if len(rows) == 0 {
return 0, nil
}
// Serialize against the manual RefundPayment handler: acquire the SAME
// per-payment advisory lock (`hashtext('crussell:refund:' || payment_id)`)
// the handler uses, in ascending payment_id order to avoid deadlocks.
pinConn, err := db.Conn.Acquire(ctx)
if err != nil {
log.Printf("Failed to acquire connection for refund lock (charge %s): %v", chargeID, err)
return 0, nil
}
defer pinConn.Release()
paymentIDs := make([]string, 0, len(rows))
seen := make(map[string]bool, len(rows))
for _, r := range rows {
if !seen[r.PaymentID] {
seen[r.PaymentID] = true
paymentIDs = append(paymentIDs, r.PaymentID)
}
}
sort.Strings(paymentIDs)
locked := 0
for _, pid := range paymentIDs {
// Bounded try-lock (R6) so the sweep never blocks a pool connection
// while the manual handler holds the same key across its Square call.
ok, lockErr := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+pid)
if lockErr != nil {
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, lockErr)
break
}
if !ok {
log.Printf("Refund lock for payment %s (charge %s) not acquired within bound — a refund is in progress", pid, chargeID)
break
}
locked++
}
if locked < len(paymentIDs) {
// Give up on this charge (the manual handler may hold the lock). The
// sweep continues to the next charge rather than aborting fatally.
for _, pid := range paymentIDs[:locked] {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, pid); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", pid, err)
}
}
return 0, nil
}
defer func() {
for _, pid := range paymentIDs {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, pid); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", pid, err)
}
}
}()
// Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some).
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
pendingRows, err := db.Conn.Query(ctx, `
SELECT id, amount, created_at FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < 3
`, ids)
if err != nil {
log.Printf("Failed to re-read pending refunds under lock (charge %s): %v", chargeID, err)
return 0, nil
}
var pending []pendingChargeRow
for pendingRows.Next() {
var pr pendingChargeRow
if err := pendingRows.Scan(&pr.ID, &pr.Amount, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan pending refund under lock (charge %s): %v", chargeID, err)
continue
}
pending = append(pending, pr)
}
pendingRows.Close()
if len(pending) == 0 {
return 0, nil
}
// Age guard: Square's idempotency-key retention is finite (~24h). If the
// oldest pending row predates 23 hours, re-issuing with the same charge key
// risks Square treating it as a NEW refund → double refund. Reconcile FIRST:
// money may already have moved at Square (response loss), and failing the
// rows without checking would let the over-refund guard exclude money that
// actually left the business. Only when Square shows no exact COMPLETED
// refund do we mark failed and surface for manual review.
oldest := pending[0].CreatedAt
for _, pr := range pending[1:] {
if pr.CreatedAt.Before(oldest) {
oldest = pr.CreatedAt
}
}
var totalCents int64
for _, pr := range pending {
totalCents += int64(math.Round(pr.Amount * 100))
}
if clock.Now().Sub(oldest) > 23*time.Hour {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave rows
// pending for the next sweep; NEVER mark failed on an unknown state
// (that would let the over-refund guard exclude moved money).
log.Printf("Reconcile failed for aged charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(pending))
return 0, nil
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = ANY($2) AND status = 'pending'
`, *sqRefundID, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
}
log.Printf("Aged card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
return len(pending), nil
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds failed (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, idsOf(pending))
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel. Verify the Square dashboard first.
log.Printf("Card refunds for charge %s are older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
return len(pending), nil
}
}
// ONE Square refund per charge with a CHARGE-STABLE idempotency key. The
// key is derived from the charge ID (square_payment_id) ONLY — NEVER from
// the set of pending row IDs — so it is identical across every sweep run
// no matter how the pending set evolves. The key is used ONLY for the
// Square call — never stored in refunds.idempotency_key (the per-row keys
// remain the audit trail).
//
// SAME-set retry (a crash/response-loss where the pending set is
// unchanged) → SAME key → Square's idempotency dedup returns the
// ORIGINAL refund, so a retry can never double-refund.
//
// CHANGED set (a new cancellation refund row joined the group while the
// old rows still sit 'pending' — the CRITICAL-log path where the
// post-refund DB UPDATE failed) → STILL the SAME key. Money for the
// original total has ALREADY moved at Square, so a set-derived key would
// have hashed to a NEW key and issued a SECOND Square refund on top —
// double-refunding the customer (C6). Deduping on the charge key returns
// the original refund instead; the new row resolves against it and any
// residual gap is a known, admin-visible shortfall rather than lost
// money. The 23h age-guard reconcile above still protects the cross-sweep
// case where Square's finite (~24h) key retention may have lapsed.
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: chargeID,
Amount: totalCents,
IdempotencyKey: chargeAggKey(chargeID),
Reason: reason,
})
switch {
case sqErr == nil:
// Resolve by Square's status: COMPLETED resolves the group; PENDING
// leaves the rows pending (a later sweep reconciles them via
// ListPaymentRefunds); FAILED/REJECTED is a definitive failure that
// must not be marked completed (that would block the amount in the
// over-refund guard forever).
sqStatus := "completed"
if sqResult.Status == "PENDING" {
sqStatus = "pending"
log.Printf("Square refund %s for charge %s is PENDING — leaving refunds pending for the sweep", sqResult.ID, chargeID)
} else if sqResult.Status == "FAILED" || sqResult.Status == "REJECTED" {
sqStatus = "failed"
log.Printf("Square refund %s for charge %s FAILED — marking refunds failed", sqResult.ID, chargeID)
}
// ATOMIC — one statement for the whole group, never per-row. Keeps
// crash-retry amounts identical so Square's key-dedup returns the
// original refund.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = $1, square_refund_id = $2
WHERE id = ANY($3) AND status = 'pending'
`, sqStatus, sqResult.ID, idsOf(pending)); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for charge %s failed — manual reconciliation required: %v", sqResult.ID, chargeID, upErr)
}
return len(pending), nil
case errors.Is(sqErr, square.ErrRefundAlreadyProcessed):
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to resolve refunds after PAYMENT_ALREADY_REFUNDED (charge %s): %v", chargeID, upErr)
}
return len(pending), nil
case errors.Is(sqErr, square.ErrRefundDeclined):
// Definitive decline — money will never move. Bump attempts; at >=3
// mark failed and surface for manual arrangement.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr)
}
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel. Arrange in-person cash pickup at the salon (a day's notice for
// cash on hand).
log.Printf("Card refund for charge %s definitively declined by Square — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
}
return 0, nil
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr)
}
// At the cap, money may have moved at Square despite the ambiguous
// responses — reconcile BEFORE marking failed (same bug class as the
// age guard). An exact COMPLETED refund resolves to completed.
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
// rows pending for the next sweep; NEVER mark failed on an
// unknown state (that would let the over-refund guard exclude
// moved money).
log.Printf("Reconcile failed for ambiguous charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(capIDs))
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = ANY($2) AND status = 'pending'
`, *sqRefundID, capIDs); upErr != nil {
log.Printf("Failed to mark ambiguous refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
}
log.Printf("Ambiguous card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Card refund for charge %s is AMBIGUOUS (Square may have processed it) and no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
}
}
return 0, nil
}
}
func idsOf(rows []pendingChargeRow) []string {
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
return ids
}
// aggRefundKeySuffix returns a deterministic 12-hex-char suffix for a set of
// identifiers. The identifiers (CHAR(12) refund ids, or an over-length
// square_payment_id) are sorted, sha256'd and truncated, so the SAME input
// always yields the SAME suffix. Used to compress an over-length chargeID into
// a fixed-width prefix for the charge-level idempotency key (see chargeAggKey).
func aggRefundKeySuffix(ids []string) string {
sorted := append([]string(nil), ids...)
sort.Strings(sorted)
h := sha256.Sum256([]byte(strings.Join(sorted, "")))
return fmt.Sprintf("%x", h)[:12]
}
// chargeAggKey builds the charge-level idempotency key for an aggregated
// refund as <chargeID>-square-agg. The key depends ONLY on the charge ID —
// NEVER on the set of pending row IDs — so it is stable across sweep runs even
// when a new cancellation refund row joins the group (see processChargeGroup).
// Square's idempotency-key limit is 45 chars; the verbatim form needs the
// chargeID ≤34 chars. square_payment_id is an arbitrary TEXT column holding
// Square's real payment ID (typically 20-28 chars), so the verbatim form can
// exceed the limit — and a >45-char key is rejected with a 400
// INVALID_REQUEST_ERROR (classified ambiguous → stuck pending forever). When
// the verbatim form does not fit, the chargeID is sha256'd into a fixed-width
// prefix instead — NEVER truncated verbatim: two charges sharing a truncated
// prefix would collide on Square's global key dedup and silently swallow the
// second charge's refund (lost money).
func chargeAggKey(chargeID string) string {
key := chargeID + "-square-agg"
if len(key) <= 45 {
return key
}
return aggRefundKeySuffix([]string{chargeID}) + "-square-agg"
}
// manualPendingRow is one stale manual refund row eligible for the sweep's
// resolution. It covers BOTH pending shapes the RefundPayment handler can
// leave behind:
// - square_refund_id set: the handler's synchronous-PENDING response (Square
// already holds the refund, status='pending') — reconciled, never re-issued.
// - square_refund_id NULL: the handler's ambiguous-error path — re-issued
// with the row's OWN stored idempotency key.
type manualPendingRow struct {
ID string
PaymentID string
Amount float64
IdempotencyKey string
Reason string
SquarePaymentID string
SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id
CreatedAt time.Time
}
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
// by the RefundPayment handler. The cancellation passes filter
// origin='cancellation', so manual rows were never re-attempted: they
// permanently blocked the over-refund guard and depressed booking TotalPaid.
// Rows are split per-row by their stored square_refund_id: rows WITH one (the
// handler's synchronous-PENDING response) are reconciled at Square — never
// re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with
// their OWN stored idempotency key (Square dedups same-key retries, so the
// retry is idempotent).
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason,
p.square_payment_id, r.square_refund_id, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.origin = 'manual'
AND r.refund_attempts < 3
AND p.square_payment_id IS NOT NULL
ORDER BY r.payment_id, r.id
`)
if err != nil {
log.Printf("Failed to query manual pending refunds for retry: %v", err)
return 0, nil
}
var pending []manualPendingRow
for rows.Next() {
var pr manualPendingRow
var key *string
var sqRefundID *string
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan manual pending refund: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
rows.Close()
if len(pending) == 0 {
return 0, nil
}
// Group by payment_id, ascending — matching the lock ordering the manual
// handler and processChargeGroup use so the sweep never deadlocks them.
groups := make(map[string][]manualPendingRow)
var paymentIDs []string
for _, pr := range pending {
if _, ok := groups[pr.PaymentID]; !ok {
paymentIDs = append(paymentIDs, pr.PaymentID)
}
groups[pr.PaymentID] = append(groups[pr.PaymentID], pr)
}
sort.Strings(paymentIDs)
processed := 0
for _, pid := range paymentIDs {
n, err := processManualPaymentGroup(ctx, pid, groups[pid])
if err != nil {
log.Printf("Sweep: failed to process manual pending refunds for payment %s: %v", pid, err)
continue
}
processed += n
}
return processed, nil
}
// ensureRefundKey returns the idempotency key to use when re-issuing a manual
// refund, persisting a generated fallback to the refunds row BEFORE Square is
// called so every retry reuses the SAME key — Square dedups same-key retries,
// so a lost-response retry can never issue a SECOND refund.
//
// A legacy refund row with a NULL idempotency_key used to get a fresh random
// suffix on every resume; if the Square call succeeded but the follow-up DB
// UPDATE to 'completed' failed (the CRITICAL-log path), the row stayed
// 'pending' with its key STILL NULL and the next resume generated a NEW random
// key → a second Square refund (double refund). Persisting the key first
// closes that hole: a crash-retry re-reads the persisted key and reuses it.
//
// The `AND idempotency_key IS NULL` guard makes the persist race-safe: only
// one concurrent caller wins the UPDATE; a loser (0 rows affected) re-reads
// and returns the winner's key. The generated shape (paymentID + "-refund-" +
// amount + "-" + 12 hex chars) stays ≤45 chars: 12 + 8 + up-to-9 + 1 + 12 ≈ 42.
func ensureRefundKey(ctx context.Context, refundID, paymentID string, amount int64, storedKey string) (string, error) {
if storedKey != "" {
return storedKey, nil
}
key := paymentID + "-refund-" + strconv.FormatInt(amount, 10) + "-" + randomHexSuffix(6)
tag, err := db.Conn.Exec(ctx, `
UPDATE refunds SET idempotency_key = $1
WHERE id = $2 AND idempotency_key IS NULL
`, key, refundID)
if err != nil {
return "", fmt.Errorf("failed to persist refund idempotency key for %s: %w", refundID, err)
}
if tag.RowsAffected() == 0 {
var existing string
if err := db.Conn.QueryRow(ctx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&existing); err != nil {
return "", fmt.Errorf("failed to re-read persisted refund idempotency key for %s: %w", refundID, err)
}
return existing, nil
}
return key, nil
}
// processManualPaymentGroup retries one payment's stale manual pending refunds
// under the SAME per-payment advisory lock the manual RefundPayment handler
// holds across its guard read — so a re-issued refund can never double-spend
// against a concurrent manual refund. Rows are re-read under the lock; each is
// issued to Square with its OWN stored idempotency key and stored reason.
func processManualPaymentGroup(ctx context.Context, paymentID string, rows []manualPendingRow) (int, error) {
pinConn, err := db.Conn.Acquire(ctx)
if err != nil {
return 0, err
}
defer pinConn.Release()
// Bounded try-lock (R6) so the sweep never blocks a pool connection while
// the manual handler holds the same key across its Square call.
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+paymentID)
if err != nil {
return 0, err
}
if !lockOK {
log.Printf("Refund lock for payment %s not acquired within bound — a manual refund is in progress; leaving rows pending for the next sweep", paymentID)
return 0, nil
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, paymentID); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", paymentID, err)
}
}()
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
// Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some).
prRows, err := db.Conn.Query(ctx, `
SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at,
r.payment_id, p.square_payment_id, r.square_refund_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3
ORDER BY r.id
`, ids)
if err != nil {
return 0, err
}
var pending []manualPendingRow
for prRows.Next() {
var pr manualPendingRow
var key *string
var sqRefundID *string
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil {
log.Printf("Failed to scan manual pending refund under lock: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
prRows.Close()
if len(pending) == 0 {
return 0, nil
}
// Age guard (mirrors processChargeGroup): Square's idempotency-key
// retention is finite (~24h). Reconcile FIRST — an exact COMPLETED refund
// at Square resolves to completed even though our rows are stale.
oldest := pending[0].CreatedAt
for _, pr := range pending[1:] {
if pr.CreatedAt.Before(oldest) {
oldest = pr.CreatedAt
}
}
if clock.Now().Sub(oldest) > 23*time.Hour {
processedAged := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
// this row pending for the next sweep; NEVER mark failed on an
// unknown state.
log.Printf("Reconcile failed for aged manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processedAged++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s failed: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s is older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
}
}
return processedAged, nil
}
processed := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
// Row WITH a stored square_refund_id — the RefundPayment handler's
// synchronous-PENDING response. Square already holds the refund, so a
// re-issue would risk a SECOND refund (Square's key dedup does not
// protect a fresh key). Reconcile instead: an exact COMPLETED refund at
// Square resolves the row; a genuine no-match means Square never
// recorded it → failed + admin notification; a reconcile error is an
// UNKNOWN state → leave pending (never mark failed on an unknown state,
// that would let the over-refund guard exclude money that may have
// moved). Mirrors the 23h age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processed++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after Square reconcile showed no refund: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Manual refund %s (square_refund_id %s) has no COMPLETED refund at Square — marked 'failed' and admin notified; TODO email user+admin to VERIFY the Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, pr.SquareRefundID)
}
continue
}
// Route the stored key through ensureRefundKey: a legacy row with a
// NULL idempotency_key must NOT reach Square with "" (a 400
// INVALID_REQUEST_ERROR, classified ambiguous → stuck forever). The
// helper persists a generated fallback to the row first, so every retry
// reuses the SAME key — a lost-response retry can never issue a second
// refund (Square dedups same-key retries).
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountCents, pr.IdempotencyKey)
if keyErr != nil {
// The key could not be persisted — Square must not be called with an
// empty/unknown key. Leave the row pending for the next sweep (never
// mark failed on an unknown state); the 23h age guard above will
// eventually reconcile it.
log.Printf("Failed to ensure refund key for manual refund %s before re-issue: %v", pr.ID, keyErr)
continue
}
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.SquarePaymentID,
Amount: amountCents,
IdempotencyKey: idemKey,
Reason: pr.Reason,
})
switch {
case sqErr == nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2
`, sqResult.ID, pr.ID); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for manual refund %s failed — manual reconciliation required: %v", sqResult.ID, pr.ID, upErr)
}
processed++
case errors.Is(sqErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to resolve manual refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", pr.ID, upErr)
}
processed++
case errors.Is(sqErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
}
}
return processed, nil
}
// resolveManualRefundAtCap reconciles a manual refund row that just hit the
// 3-attempt cap: money may have moved at Square despite decline/ambiguous
// responses, so reconcile FIRST — an exact COMPLETED refund resolves the row to
// completed; otherwise mark failed and notify the admin.
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave the row
// pending for the next sweep; NEVER mark failed on an unknown state
// (that would let the over-refund guard exclude moved money).
log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — leaving pending for the next sweep", pr.ID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after 3 attempts: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s reached 3 attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
}
}
func currentRefundAttempts(ctx context.Context, refundID string) int {
var n int
if err := db.Conn.QueryRow(ctx, `SELECT refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&n); err != nil {
log.Printf("Failed to read refund_attempts for %s: %v", refundID, err)
}
return n
}