Files
Crussell/backend/handlers/payments/refunds.go
T
popertots 9a182db932 fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup
Full-scope Loop A restart review (18 findings across money/security/dup-mod):

MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded

SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling

FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
2026-08-22 00:34:50 +01:00

2380 lines
106 KiB
Go

package payments
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"github.com/jackc/pgx/v5"
)
// Refunds are exempt from buyer verification. Square's RefundPayment endpoint
// does not support 3DS/SCA verification tokens (verification is a charge-time
// concept — PSD2 SCA applies to the original payment capture, never to the
// money flowing back out), so no refund path carries a verification token and
// none ever will. The practical already-refunded signal is Square's
// REFUND_AMOUNT_INVALID error (PAYMENT_ALREADY_REFUNDED is no longer
// documented): on a genuinely-refunded payment that code is NOT a decline —
// the handler reconciles via an EXACT-amount COMPLETED-refund check
// (reconcileRefundAtSquareExact, see the A11 disambiguation in
// processChargeGroup / processManualPaymentGroup) and resolves the refund row
// to 'completed' instead of failing it, so the over-refund guard never
// re-issues money Square already returned.
//
// stalePendingRefundAge is the age guard for pending refunds: Square's
// idempotency-key retention is finite (~24h), so a pending refund older than
// this must be RECONCILED against Square (ListPaymentRefunds) before any
// re-issue — re-issuing with a key Square no longer retains would be treated
// as a NEW refund (double refund). A pending row older than
// stalePendingRefundAge that Square shows no COMPLETED refund for is marked
// 'failed' and surfaced for manual arrangement instead.
const stalePendingRefundAge = 23 * time.Hour
// stalePendingB1RefundAge is the age guard for a B1 sweep auto-refund of a
// replay-induced duplicate charge (sweepPendingB1Refunds) that Square left
// PENDING. FAILED/REJECTED are terminal refund states, but the webhook
// FAILED-refund reconciliation that would normally resolve them is OPTIONAL
// (README) — with it unconfigured, hasInFlightSweepDuplicateRefund blocks the
// stale sweeps from resolving the parent forever, no alert fires and every
// sweep run re-polls Square indefinitely. After a B1 refund has been pending
// this long the re-poll pass treats FAILED/REJECTED as terminal itself (failing
// the refund, resolving the parent and alerting), and a still-pending refund
// older than this is escalated to a deduped CRITICAL admin notification and no
// longer re-polled. Never marks anything completed without a COMPLETED Square
// refund.
const stalePendingB1RefundAge = 48 * time.Hour
// maxManualRefundAttempts caps retry attempts for a refund stuck on a
// decline/ambiguous outcome before it is resolved terminally. Both the manual
// sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the
// cancellation charge-group sweeps share the same cap: rows are retried while
// refund_attempts < maxManualRefundAttempts, and at the cap a refund is
// reconciled at Square FIRST (never marked failed while the money state is
// unknown — that would let the over-refund guard exclude money that actually
// left the business), then resolved to 'completed' or 'failed' + admin
// notification. The SQL literals below are formatted with this constant so
// the DB filter and the Go cap can never drift apart.
const maxManualRefundAttempts = 3
// maxConsecutiveReconcileFailures is the number of consecutive cap-time
// reconcile failures (each causing a re-arm under the attempt cap) before a
// refund row is surfaced in the admin notification centre. A reconcile failure
// is an UNKNOWN money state — the row is never marked 'failed' on it — but
// silently re-arming forever would oscillate the row between the cap and
// cap-1 indefinitely with zero admin visibility (A5b/A5c). After this many
// consecutive failures a deduped 'critical_payment_log' admin notification is
// inserted so the owner learns the reconcile is hard-failing.
const maxConsecutiveReconcileFailures = 5
// maxTrackedReconcileFailures caps the in-memory consecutive-failure counter
// so the map never grows past a sane value AND the ==maxConsecutiveReconcileFailures
// notification check can never be skipped by an odd increment pattern (A5): a
// row whose counter is already at the cap stops growing, but the notification
// fired when it first crossed maxConsecutiveReconcileFailures and is deduped by
// the NOT EXISTS guard, so capping loses no visibility.
const maxTrackedReconcileFailures = 10
// manualReconcileFailures counts consecutive cap-time reconcile failures per
// refund row (keyed by refunds.id). resolveManualRefundAtCap and the
// charge-group cap path re-arm a row under the attempt cap on a reconcile
// error so the next sweep re-picks it; without a counter that re-arm loops
// forever with no notification. The counter is in-memory (no schema change —
// the schema is single-source and pre-launch, no ALTERs): it counts
// CONSECUTIVE failures, is reset whenever a reconcile succeeds (the row is
// resolved completed/failed), and after maxConsecutiveReconcileFailures
// failures triggers a deduped critical_payment_log admin notification. A
// process restart merely resets the counter, deferring the notification by a
// few sweeps — never suppressing it (the row stays pending and keeps being
// re-reconciled, so the notification eventually fires).
var (
manualReconcileFailureMu sync.Mutex
manualReconcileFailures = make(map[string]int)
)
// b1EscalatedMu guards b1Escalated, the set of B1 sweep auto-refund rows the
// re-poll pass has escalated (pending past stalePendingB1RefundAge). Escalated
// rows are no longer re-polled: the FAILED/REJECTED-terminal branch already
// resolves them (the row leaves the pending query), and a still-pending
// escalated row keeps a deduped CRITICAL admin notification alive while the
// parent stays pending for manual reconciliation. In-memory (no schema change —
// the schema is single-source and pre-launch, no ALTERs), mirroring
// manualReconcileFailures: a process restart resets the set, which merely
// re-polls the row once and re-escalates it (the notification is deduped), never
// suppressing the alert.
var (
b1EscalatedMu sync.Mutex
b1Escalated = make(map[string]bool)
)
// trackReconcileFailureReArm records one more consecutive cap-time reconcile
// failure for each refund row and, once a row crosses
// maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log'
// admin notification for the affected booking(s) — the admin MUST learn a
// reconcile is hard-failing instead of the row silently oscillating under the
// attempt cap forever (A5b/A5c).
func trackReconcileFailureReArm(ctx context.Context, ids []string) {
manualReconcileFailureMu.Lock()
notify := false
for _, id := range ids {
// A5: cap the counter so the map never grows unbounded and the
// ==maxConsecutiveReconcileFailures check below is never skipped by an
// odd increment pattern — a counter that grew past 5 without a reset
// (e.g. to 6) would silently stop firing the notification forever.
if manualReconcileFailures[id] < maxTrackedReconcileFailures {
manualReconcileFailures[id]++
}
if manualReconcileFailures[id] == maxConsecutiveReconcileFailures {
notify = true
}
}
manualReconcileFailureMu.Unlock()
if !notify {
return
}
notifyCriticalReconcileFailure(ctx, ids)
}
// resetReconcileFailureCount clears a refund row's consecutive-reconcile-
// failure counter. Called whenever a reconcile SUCCEEDS (the row is resolved
// to 'completed' or definitively 'failed'), so the counter reflects
// consecutive failures only — a success in between breaks the streak.
func resetReconcileFailureCount(ids ...string) {
manualReconcileFailureMu.Lock()
for _, id := range ids {
delete(manualReconcileFailures, id)
}
manualReconcileFailureMu.Unlock()
}
// notifyCriticalReconcileFailure inserts ONE 'critical_payment_log' admin
// notification per affected booking (deduped by insertCriticalPaymentNotification)
// so a hard-failing reconcile surfaces in the admin notification centre.
// Rows without a booking (gift-card purchases) collapse into one booking-less
// notification — the money event is surfaced, not lost.
func notifyCriticalReconcileFailure(ctx context.Context, ids []string) {
rows, err := db.Conn.Query(ctx, `
SELECT DISTINCT booking_id FROM refunds
WHERE id = ANY($1) AND booking_id IS NOT NULL
`, ids)
if err != nil {
log.Printf("Failed to query bookings for critical reconcile-failure notification: %v", err)
insertCriticalPaymentNotification(ctx, nil, nil)
return
}
var bookingIDs []string
for rows.Next() {
var b string
if err := rows.Scan(&b); err == nil {
bookingIDs = append(bookingIDs, b)
}
}
rows.Close()
if len(bookingIDs) == 0 {
insertCriticalPaymentNotification(ctx, nil, nil)
return
}
for _, b := range bookingIDs {
bid := b
insertCriticalPaymentNotification(ctx, &bid, nil)
}
}
// reconcileRefundAtSquareExact checks Square for a COMPLETED refund matching
// the EXACT payment+amount, WITHOUT an age bound. Same exact-match semantics
// as reconcileRefundAtSquare (payment_id AND status COMPLETED AND amount), minus
// the begin_time filter: the REFUND_AMOUNT_INVALID disambiguation (A11) must
// not miss a pre-existing refund that predates our refund row. Only an
// exact-amount COMPLETED refund proves the money for THIS amount already moved
// — a smaller partial refund does NOT, and attributing it would mark the row
// completed when only part of the amount was refunded.
//
// Tri-state return matches reconcileRefundAtSquare:
//
// (id, nil) — exact COMPLETED refund found
// (nil, nil) — genuinely no exact match
// (nil, err) — reconcile failed (network/API error)
func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountPence int64) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{})
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 == amountPence {
return &r.ID, nil
}
}
return nil, nil
}
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 *
// ProtectedDepositMaxPct).
// 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.
//
// These tiers are the DEFAULT retention for a cancellation (customer calls to
// cancel, or the admin cancels on the customer's behalf without forgiving
// fees). The admin "forgive fees" path (forceFullRefund) overrides the tiers
// entirely so the business keeps nothing — see ProcessCancellationRefundTx for
// the two intents (excusable cancellation vs business-initiated cancellation)
// that share that override.
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.
//
// The admin "forgive fees" checkbox (forceFullRefund) serves TWO distinct
// purposes, and both route here identically — it overrides the notice-tier
// calculation so the ENTIRE net pre-paid amount is refunded regardless of how
// close to the appointment the cancellation happens:
//
// - (A) Genuinely excusable cancellation: the customer has a legitimate
// excuse (medical, emergency, technical fault, etc.) and the business
// chooses to waive its notice-period fees as a goodwill gesture.
// - (B) Business-initiated cancellation: the salon had to cancel the
// appointment and chooses NOT to keep the money (the deposit/notice
// retention would be unfair when the cancellation is the business's
// doing).
//
// These two intents are deliberately NOT distinguished in the refund
// calculation — both mean "the business keeps nothing". When the admin cancels
// on a customer's behalf or a customer calls up to cancel without a forgivable
// excuse, the notice tiers below apply (forceFullRefund=false); the checkbox
// is the explicit opt-out from that retention.
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. The square client reconciles that code against
// Square's refund list (PaymentWasRefunded) and maps an already-refunded
// payment to ErrRefundAlreadyProcessed; the charge-group/manual sweep
// handlers additionally reconcile REFUND_AMOUNT_INVALID via an exact-amount
// COMPLETED-refund check (reconcileRefundAtSquareExact, defense-in-depth —
// see the A11 disambiguation) and resolve those rows to 'completed' — never
// 'failed' + admin_notification, which would let the over-refund guard
// re-issue money Square already returned.
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)
}
}
// B13: the full payment refund is issued above; a campaign-loss balance
// credit granted at charge time (refundLostCampaignAsBalanceCredit) must
// be reversed here or the customer gets the money back AND keeps the
// promised-discount credit — a double credit. No-op when the booking
// never received a B13 credit.
clawbackB13CampaignCredit(ctx, tx, bookingID, bookingUserID)
}
return &calc, nil
}
// clawbackB13CampaignCredit reverses a B13 campaign-loss balance credit
// (refundLostCampaignAsBalanceCredit) when the booking is cancelled: the
// customer receives the full payment refund above, so keeping the promised-
// discount credit on their gift-card balance too would be a double credit. The
// credit is located via its gift_card_transactions audit row (reference_type
// 'b13_campaign_loss', reference_id = booking). The balance debit is guarded
// (balance >= amount) so a balance already spent below the credit is never
// driven negative; an insufficient balance is flagged for manual
// reconciliation instead of silently kept. Idempotent: an existing reversal
// row for the booking prevents a re-run from debiting twice.
func clawbackB13CampaignCredit(ctx context.Context, tx pgx.Tx, bookingID, userID string) {
if bookingID == "" || userID == "" {
return
}
var alreadyReversed bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1 AND transaction_type = 'balance_debit'
)
`, bookingID).Scan(&alreadyReversed); err != nil {
log.Printf("B13: failed to check for an existing clawback of booking %s: %v", bookingID, err)
return
}
if alreadyReversed {
return
}
var creditPounds float64
if err := tx.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1
`, bookingID).Scan(&creditPounds); err != nil || creditPounds <= 0 {
return
}
tag, err := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, creditPounds, userID)
if err != nil {
log.Printf("B13: failed to claw back £%.2f campaign-loss credit from user %s (booking %s): %v", creditPounds, userID, bookingID, err)
return
}
if tag.RowsAffected() == 0 {
log.Printf("CRITICAL: B13 campaign-loss credit of £%.2f for booking %s could not be clawed back from user %s (balance < amount — credit partially spent) — MANUAL RECONCILIATION REQUIRED", creditPounds, bookingID, userID)
return
}
// Record the reversal on the same anchor card as the credit for the audit
// trail (also serves as the idempotency marker above).
var anchorCardID string
if err := tx.QueryRow(ctx, `
SELECT gift_card_id FROM gift_card_transactions
WHERE reference_type = 'b13_campaign_loss' AND reference_id = $1
ORDER BY created_at DESC LIMIT 1
`, bookingID).Scan(&anchorCardID); err == nil && anchorCardID != "" {
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, 'balance_debit', $2, 'b13_campaign_loss', $3, $4, $5)
`, anchorCardID, creditPounds, bookingID, userID, "B13 campaign-loss credit clawed back on cancellation"); err != nil {
log.Printf("B13: failed to record clawback transaction for user %s (booking %s): %v", userID, bookingID, err)
}
}
log.Printf("B13: clawed back £%.2f campaign-loss balance credit from user %s after cancellation of booking %s", creditPounds, userID, bookingID)
}
// 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, fmt.Sprintf(`
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 < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`, maxManualRefundAttempts), 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, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`, maxManualRefundAttempts))
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 := fmt.Sprintf(`
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 < %d
AND p.square_payment_id IS NOT NULL
AND p.payment_method IN ('online_square', 'in_person_card')
AND r.origin = 'cancellation'`, maxManualRefundAttempts)
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, fmt.Sprintf(`
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 < %d
AND r.origin = 'cancellation'
ORDER BY r.id
`, maxManualRefundAttempts), 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
}
// isRefundAmountInvalid reports whether err carries Square's
// REFUND_AMOUNT_INVALID code — structurally (the real HTTP client wraps the
// code in a squareAPIError) or by message (the dev mock embeds the code in its
// simulated error).
func isRefundAmountInvalid(err error) bool {
if err == nil {
return false
}
if square.ErrorCode(err) == "REFUND_AMOUNT_INVALID" {
return true
}
return strings.Contains(err.Error(), "REFUND_AMOUNT_INVALID")
}
// 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, totalPence 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 == totalPence {
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.
//
// The webhooks package carries the SINGULAR variant of this same insert —
// insertRefundFailedNotification (handlers/webhooks/square.go) — which demotes
// a single webhook-surfaced FAILED refund to the identical 'refund_failed'
// row. The two share the same NOT EXISTS dedup guard on
// (reason='refund_failed', booking_id), so a refund resolved by either path
// can never be double-notified; keep the reason string and dedup predicate in
// lockstep when either changes.
// InsertRefundFailedNotifications is the EXPORTED single source for surfacing
// failed refunds in the admin notification centre, consumed by both the sweep
// path (sweep.go) and the webhook path (handlers/webhooks/square.go). The
// webhook package calls this instead of maintaining its own copy, so the SQL
// and the (reason='refund_failed', booking_id) dedup predicate live in exactly
// one place. It delegates to the package-internal insertRefundFailedNotifications.
func InsertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
insertRefundFailedNotifications(ctx, refundIDs)
}
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
// maxManualRefundAttempts 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, fmt.Sprintf(`
SELECT id FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), 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] {
releasePaymentLock(pinConn, "crussell:refund:"+pid)
}
return 0, nil
}
defer func() {
for _, pid := range paymentIDs {
releasePaymentLock(pinConn, "crussell:refund:"+pid)
}
}()
// 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, fmt.Sprintf(`
SELECT id, amount, created_at FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < %d
`, maxManualRefundAttempts), 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 stalePendingRefundAge, 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 totalPence int64
for _, pr := range pending {
totalPence += int64(math.Round(pr.Amount * 100))
}
if clock.Now().Sub(oldest) > stalePendingRefundAge {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, 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)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(idsOf(pending)...)
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)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(idsOf(pending)...)
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 stalePendingRefundAge (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 stalePendingRefundAge 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: totalPence,
IdempotencyKey: chargeAggKey(chargeID),
Reason: reason,
})
// REFUND_AMOUNT_INVALID is Square's ambiguous signal for BOTH a genuinely
// invalid refund amount AND an already-refunded payment (Square no longer
// documents PAYMENT_ALREADY_REFUNDED). Disambiguate with the EXACT amount
// BEFORE the classification switch so the reconciliation applies whether
// the client classified the code as a definitive decline or as
// already-processed: only an exact-amount COMPLETED refund at Square proves
// the money for THIS amount already moved (A11). A smaller partial refund
// does NOT — marking the rows completed would claim the full amount was
// refunded when only part of it was.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalPence)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's own
// classification below (the switch on sqErr) rather than making a
// money decision on partial data.
case sqRefundID != nil:
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 completed after REFUND_AMOUNT_INVALID on already-refunded charge %s: %v", chargeID, upErr)
}
log.Printf("Charge %s already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked %d refund row(s) completed, no admin notification", chargeID, *sqRefundID, len(pending))
return len(pending), nil
default:
// No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID
// here is a genuine decline/invalid amount (the attempt would have
// over-refunded a partially-refunded payment), NOT an
// already-refunded signal. Route through the decline branch.
sqErr = square.ErrRefundDeclined
}
}
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
// maxManualRefundAttempts 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, fmt.Sprintf(`
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, 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 maxManualRefundAttempts 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, totalPence, 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). But leaving the rows AT the cap strands them:
// the sweep only re-picks rows with refund_attempts <
// maxManualRefundAttempts, so capped rows are never
// re-reconciled and never admin-notified — silently stuck
// money (A5c). Re-arm the capped rows under the cap (mirroring
// resolveManualRefundAtCap) so the next sweep re-picks them,
// and track consecutive reconcile failures: after
// maxConsecutiveReconcileFailures consecutive failures a
// deduped 'critical_payment_log' admin notification surfaces
// the hard-failing reconcile.
if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(`
UPDATE refunds SET refund_attempts = %d
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts-1, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to re-arm capped refunds under the attempt cap after reconcile error (charge %s): %v", chargeID, upErr)
}
trackReconcileFailureReArm(ctx, capIDs)
log.Printf("Reconcile failed for ambiguous charge %s (%v) — re-armed %d refund row(s) under the attempt cap for the next sweep; never marked failed on an unknown state", chargeID, rcErr, len(capIDs))
case sqRefundID != nil:
resetReconcileFailureCount(capIDs...)
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:
resetReconcileFailureCount(capIDs...)
if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(`
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d
`, maxManualRefundAttempts), capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, 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) <= maxIdempotencyKeyLength {
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
// BookingID is the payment's booking_id ("" when NULL = gift-card purchase;
// such manual refunds are never re-issued, see processManualPaymentGroup).
BookingID 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).
//
// A terminal pre-pass first sweeps legacy manual rows whose payment has NO
// square_payment_id (pre-dating the handler's square-less guard at
// handlers.go:2477-2480). Such rows can never be refunded via Square and would
// otherwise stay 'pending' forever, blocking the over-refund guard. They are
// marked 'failed' and surfaced in the admin notification centre, mirroring the
// Square-less cancellation pre-pass (refunds.go:554-583) with a DISTINCT
// origin='manual' filter so the two passes never double-process a row. Rows
// WITH a square_refund_id are exempt: a sweep auto-refund of a replay-induced
// duplicate charge (B1) attaches its refunds row to the still-pending parent
// payment (which has no square_payment_id) — demoting it here would kill an
// in-flight refund whose parent Square later settles.
//
// A B1 re-poll pass runs FIRST (sweepPendingB1Refunds): it re-polls sweep
// auto-refunds Square left PENDING by square_refund_id and, ONLY when Square
// reports the refund COMPLETED, marks the parent payment/till-sale row failed
// and claws back a funded gift card — the duplicate charge has been reversed
// and only then is the money state settled.
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
// (a0) B1-origin sweep auto-refunds (refunds rows with a square_refund_id
// that Square left PENDING): re-poll each by square_refund_id and
// resolve the parent row ONLY on COMPLETED. Runs before the passes
// below so an in-flight B1 refund is never demoted or re-issued.
b1Count, b1Err := sweepPendingB1Refunds(ctx)
if b1Err != nil {
log.Printf("Failed to re-poll B1 sweep auto-refunds: %v", b1Err)
}
// (a) Terminal pre-pass: legacy MANUAL card refunds whose payment has no
// Square reference can never be refunded via Square → mark them failed
// so they stop blocking the over-refund guard, and surface the affected
// booking in the admin notification centre for in-person arrangement.
// Mirrors the cancellation Square-less pre-pass (origin='cancellation',
// refunds.go:554-583) with origin='manual' so the filters stay distinct
// and no row is swept by both passes. Must run OUTSIDE any GROUP BY —
// Postgres lumps NULLs together, so these rows can't be handled in the
// per-payment grouping below. Rows WITH a square_refund_id are exempt —
// a B1 auto-refund attaches to a parent payment with no square_payment_id
// and its in-flight refund must not be demoted to failed.
rows, err := db.Conn.Query(ctx, fmt.Sprintf(`
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < %d
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'manual'
AND r.square_refund_id IS NULL
RETURNING r.id
`, maxManualRefundAttempts))
if err != nil {
log.Printf("Failed to mark Square-less manual 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 manual refunds during sweep: %v", err)
}
rows.Close()
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less manual refund(s) failed (in-person arrangement needed)", len(failedIDs))
}
insertRefundFailedNotifications(ctx, failedIDs)
}
// (b) Manual refunds WITH a Square reference — the rows below are the only
// ones the retry/reconcile logic can act on.
rows, err = db.Conn.Query(ctx, fmt.Sprintf(`
SELECT r.id, r.payment_id, p.booking_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 < %d
AND p.square_payment_id IS NOT NULL
ORDER BY r.payment_id, r.id
`, maxManualRefundAttempts))
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
var bookingID sql.NullString
if err := rows.Scan(&pr.ID, &pr.PaymentID, &bookingID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan manual pending refund: %v", err)
continue
}
pr.BookingID = bookingID.String
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 + b1Count, nil
}
// b1PendingRefund is one refunds row for a sweep auto-refund of a
// replay-induced duplicate charge (B1) that Square left PENDING.
type b1PendingRefund struct {
RefundID string
PaymentID string // refunds.payment_id — the parent payment row
Amount float64
SquareRefundID string
IdempotencyKey string // deterministic "sweepdup-" + duplicate payment id (payments-table rows)
Reason string // carries the parent till_sale id for till_sale rows
SquarePaymentID string // synthetic till-sale payment row's square_payment_id ("" for payments-table rows)
CreatedBy string
CreatedAt time.Time // refunds.created_at — when the auto-refund was recorded (age for escalation)
BookingID string // parent payment's booking_id ("" when the row has none)
}
// sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of
// replay-induced duplicate charges (B1, refundSweepDuplicateCharge in sweep.go)
// that Square left PENDING. The refund is NON-terminal: the parent row must
// stay pending (never marked failed, a funded gift card never clawed back)
// until Square settles. When Square reports the refund COMPLETED the parent is
// finally resolved — the pending payment marked failed, and a till sale's
// funded gift card clawed back (the duplicate charge has been reversed; only
// then is the money state settled). FAILED/REJECTED refunds are left pending
// for the webhook FAILED-refund reconciliation (coordinated); a reconcile
// error is an UNKNOWN state and is never resolved here. The stale-pending
// sweeps skip rows with an in-flight B1 refund (hasInFlightSweepDuplicateRefund,
// sweep.go), so the parent is only ever resolved from here.
func sweepPendingB1Refunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.square_refund_id, COALESCE(r.idempotency_key, ''),
r.reason, COALESCE(p.square_payment_id, ''), COALESCE(r.created_by, ''),
r.created_at, COALESCE(p.booking_id, '')
FROM refunds r
LEFT JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.square_refund_id IS NOT NULL
AND r.origin = 'manual' AND r.reason LIKE 'duplicate charge — sweep replay%'
ORDER BY r.id
`)
if err != nil {
log.Printf("Failed to query B1 sweep auto-refunds for re-poll: %v", err)
return 0, nil
}
defer rows.Close()
var pending []b1PendingRefund
for rows.Next() {
var pr b1PendingRefund
if err := rows.Scan(&pr.RefundID, &pr.PaymentID, &pr.Amount, &pr.SquareRefundID, &pr.IdempotencyKey, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedBy, &pr.CreatedAt, &pr.BookingID); err != nil {
log.Printf("Failed to scan B1 sweep auto-refund: %v", err)
continue
}
pending = append(pending, pr)
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate B1 sweep auto-refunds: %v", err)
return 0, nil
}
if len(pending) == 0 {
return 0, nil
}
processed := 0
for i := range pending {
pr := &pending[i]
// A row already escalated (pending past stalePendingB1RefundAge) is no
// longer re-polled — it either resolved terminal (and left the pending
// query) or stays pending under the deduped CRITICAL notification for
// manual reconciliation.
b1EscalatedMu.Lock()
escalated := b1Escalated[pr.RefundID]
b1EscalatedMu.Unlock()
if escalated {
log.Printf("B1 refund %s was already escalated (pending over %s) — not re-polling; manual reconciliation holds the parent pending", pr.RefundID, stalePendingB1RefundAge)
continue
}
// The Square payment id the refund targets. A till_sale's refund is
// attached to the synthetic payments row (square_payment_id = the
// duplicate charge); a payments-table refund is attached to the
// still-pending payment row (no square_payment_id), so the duplicate id
// is recovered from the deterministic refund idempotency key.
dupPayID := pr.SquarePaymentID
if dupPayID == "" {
dupPayID = strings.TrimPrefix(pr.IdempotencyKey, "sweepdup-")
if dupPayID == pr.IdempotencyKey {
log.Printf("Cannot re-poll B1 refund %s: no Square payment id and the stored key %q is not the sweepdup form — leaving pending", pr.RefundID, pr.IdempotencyKey)
continue
}
}
refundList, lErr := SquareClient.ListPaymentRefunds(ctx, dupPayID, time.Time{})
if lErr != nil {
log.Printf("Re-poll of B1 refund %s failed (%v) — leaving pending", pr.RefundID, lErr)
continue
}
status := ""
for j := range refundList {
if refundList[j].ID == pr.SquareRefundID {
status = refundList[j].Status
break
}
}
stale := clock.Now().Sub(pr.CreatedAt) > stalePendingB1RefundAge
switch status {
case "COMPLETED":
if resolveB1RefundCompleted(ctx, pr) {
processed++
}
case "PENDING", "APPROVED":
if stale {
escalateStaleB1Refund(ctx, pr)
} else {
log.Printf("B1 refund %s is still %q at Square — leaving the parent row pending", pr.RefundID, status)
}
case "":
log.Printf("B1 refund %s (%s) was not found at Square via ListPaymentRefunds — leaving pending; manual reconciliation may be required", pr.RefundID, dupPayID)
default:
// FAILED / REJECTED — terminal at Square. While the refund is young
// the webhook FAILED-refund reconciliation (OPTIONAL per README)
// owns it; once it has been pending past stalePendingB1RefundAge the
// sweep treats it as terminal itself so the parent can never be
// stranded by a missing webhook.
if stale {
if resolveB1RefundFailedTerminal(ctx, pr, status) {
processed++
}
} else {
log.Printf("B1 refund %s is %q at Square — leaving pending for the webhook FAILED-refund reconciliation (or the %s age escalation)", pr.RefundID, status, stalePendingB1RefundAge)
}
}
}
return processed, nil
}
// resolveB1RefundCompleted resolves a B1 sweep auto-refund that Square reports
// COMPLETED: the refunds row is marked completed (money moved), and the parent
// row — the pending payment, or the pending till sale whose gift-card funding
// is clawed back — is resolved to failed. Returns true when the parent was
// resolved.
func resolveB1RefundCompleted(ctx context.Context, pr *b1PendingRefund) bool {
if _, err := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1 AND status = 'pending'
`, pr.RefundID); err != nil {
log.Printf("Failed to mark B1 refund %s completed after Square settle: %v", pr.RefundID, err)
}
// A COMPLETED refund resolved the parent — clear any escalation flag so a
// restarted/re-polled row can be observed again if it ever re-enters.
b1EscalatedMu.Lock()
delete(b1Escalated, pr.RefundID)
b1EscalatedMu.Unlock()
return resolveB1ParentFailed(ctx, pr)
}
// resolveB1RefundFailedTerminal resolves a B1 sweep auto-refund that Square
// reports FAILED/REJECTED once it has been pending longer than
// stalePendingB1RefundAge — the terminal treatment the webhook FAILED-refund
// reconciliation (OPTIONAL per README) would have applied when configured. The
// refund is marked failed, the parent resolved to failed (a till sale's funded
// gift card clawed back), and a deduped CRITICAL admin notification raised so
// the missing webhook can never strand the parent silently forever. Returns
// true when the parent was resolved.
func resolveB1RefundFailedTerminal(ctx context.Context, pr *b1PendingRefund, squareStatus string) bool {
if _, err := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.RefundID); err != nil {
log.Printf("Failed to mark B1 refund %s failed after %q at Square: %v", pr.RefundID, squareStatus, err)
}
b1EscalatedMu.Lock()
b1Escalated[pr.RefundID] = true
b1EscalatedMu.Unlock()
insertB1EscalationNotification(ctx, pr)
resolved := resolveB1ParentFailed(ctx, pr)
log.Printf("B1 refund %s was %q at Square and pending over %s — marked the refund failed and resolved the parent; MANUAL RECONCILIATION REQUIRED: verify at Square whether the duplicate charge was refunded", pr.RefundID, squareStatus, stalePendingB1RefundAge)
return resolved
}
// resolveB1ParentFailed resolves a B1 refund's parent row to failed after the
// refund settled definitively (COMPLETED — the duplicate was reversed — or
// FAILED/REJECTED past the age threshold). A payments-table refund's payment_id
// IS the pending parent row; a till_sale's id is encoded in the reason and its
// funded gift card is clawed back. Returns true when the parent was resolved.
func resolveB1ParentFailed(ctx context.Context, pr *b1PendingRefund) bool {
if pr.Reason == sweepDuplicateRefundReason {
// payments-table parent: the refund's payment_id IS the pending row.
if failStaleRow(ctx, "payments", pr.PaymentID) {
log.Printf("B1 refund %s — marked the pending payment %s failed", pr.RefundID, pr.PaymentID)
return true
}
log.Printf("B1 refund %s — payment %s was already resolved", pr.RefundID, pr.PaymentID)
return false
}
// till_sale parent: the sale id is encoded in the reason.
if idx := strings.Index(pr.Reason, "(till_sale "); idx >= 0 {
tillSaleID := strings.TrimSuffix(pr.Reason[idx+len("(till_sale "):], ")")
ts, ok := loadTillSaleStaleRow(ctx, tillSaleID)
if !ok {
log.Printf("CRITICAL: B1 refund %s — parent till sale %s could not be loaded — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
return false
}
if ts.HasGiftCard {
if clawbackTillSaleFunding(ctx, ts) {
log.Printf("B1 refund %s — clawed back till sale %s's funded gift card and marked it failed", pr.RefundID, tillSaleID)
return true
}
log.Printf("CRITICAL: B1 refund %s — clawing back till sale %s's funding failed — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID)
return false
}
if failStaleRow(ctx, "till_sales", tillSaleID) {
log.Printf("B1 refund %s — marked till sale %s failed", pr.RefundID, tillSaleID)
return true
}
log.Printf("B1 refund %s — till sale %s was already resolved", pr.RefundID, tillSaleID)
return false
}
log.Printf("B1 refund %s — the parent row could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", pr.RefundID, pr.Reason)
return false
}
// escalateStaleB1Refund surfaces a B1 sweep auto-refund that has been PENDING
// (non-terminal) at Square for longer than stalePendingB1RefundAge: a deduped
// CRITICAL admin notification fires (the webhook FAILED-refund reconciliation
// is OPTIONAL — with it unconfigured nothing else alerts) and the row is no
// longer re-polled. The refund is left PENDING and the parent stays pending —
// marking failed on a non-terminal state could exclude money that may still
// move.
func escalateStaleB1Refund(ctx context.Context, pr *b1PendingRefund) {
b1EscalatedMu.Lock()
b1Escalated[pr.RefundID] = true
b1EscalatedMu.Unlock()
insertB1EscalationNotification(ctx, pr)
log.Printf("CRITICAL: B1 refund %s has been PENDING at Square for over %s — leaving the parent pending and STOPPING re-poll — MANUAL RECONCILIATION REQUIRED: verify the refund at Square and resolve the parent", pr.RefundID, stalePendingB1RefundAge)
}
// insertB1EscalationNotification raises the deduped 'critical_payment_log'
// admin notification for an escalated B1 refund (insertCriticalPaymentNotification
// keeps ONE per booking/user until acknowledged). A payments-table refund is
// attributed to its parent payment's booking and the payer; a till-sale refund
// (synthetic payment row, no booking) is attributed to the payer only.
func insertB1EscalationNotification(ctx context.Context, pr *b1PendingRefund) {
var bookingID *string
if pr.BookingID != "" {
b := pr.BookingID
bookingID = &b
}
var userID *string
if pr.CreatedBy != "" {
u := pr.CreatedBy
userID = &u
}
insertCriticalPaymentNotification(ctx, bookingID, userID)
}
// loadTillSaleStaleRow reads a till_sale's gift-card context for the B1
// re-poll pass's clawback — the same fields fetchStaleRows/scanStaleRow
// populate for the stale-pending sweep.
func loadTillSaleStaleRow(ctx context.Context, id string) (staleRow, bool) {
var r staleRow
var itemID, redeemedBy sql.NullString
var isCreate *bool
var hasGiftCard bool
var total float64
err := db.Conn.QueryRow(ctx, `
SELECT ts.id, ts.item_id, gc.redeemed_by, (ts.created_at = gc.created_at) AS is_create,
(gc.id IS NOT NULL) AS has_gift_card, ts.total_amount
FROM till_sales ts
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
WHERE ts.id = $1
`, id).Scan(&r.ID, &itemID, &redeemedBy, &isCreate, &hasGiftCard, &total)
if err != nil {
log.Printf("Failed to load till sale %s for B1 refund clawback: %v", id, err)
return r, false
}
r.ItemID = itemID.String
if redeemedBy.Valid && redeemedBy.String != "" {
r.RedeemToUserID = &redeemedBy.String
}
r.IsCreate = isCreate != nil && *isCreate
r.HasGiftCard = hasGiftCard
r.TotalAmount = total
r.AmountPence = int64(math.Round(total * 100))
return r, true
}
// 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 releasePaymentLock(pinConn, "crussell:refund:"+paymentID)
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, fmt.Sprintf(`
SELECT r.id, p.booking_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 < %d
ORDER BY r.id
`, maxManualRefundAttempts), ids)
if err != nil {
return 0, err
}
var pending []manualPendingRow
for prRows.Next() {
var pr manualPendingRow
var key *string
var sqRefundID *string
var bookingID sql.NullString
if err := prRows.Scan(&pr.ID, &bookingID, &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
}
pr.BookingID = bookingID.String
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) > stalePendingRefundAge {
processedAged := 0
for i := range pending {
pr := &pending[i]
amountPence := int64(math.Round(pr.Amount * 100))
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, 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. B18: track consecutive reconcile failures and
// surface the hard-failing reconcile in the admin notification
// centre (mirrors resolveManualRefundAtCap) so the row does not
// oscillate here silently forever.
trackReconcileFailureReArm(ctx, []string{pr.ID})
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)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
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)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
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 stalePendingRefundAge (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]
amountPence := int64(math.Round(pr.Amount * 100))
// A payment with NO booking is a gift-card purchase (BuyGiftCard
// inserts without a booking) — the handler rejects these outright, so a
// pending row that predates that guard must never be re-issued here
// (the sweep is a bypass of the handler guard). Reconcile only,
// exactly like the square_refund_id branch: an exact COMPLETED refund
// resolves to completed (money already moved); a no-match means Square
// never refunded — mark failed + admin-notified so the amount unblocks
// the over-refund guard and the customer is handled via the gift-card
// section.
if pr.BookingID == "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for gift-card-purchase 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 gift-card-purchase manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
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 gift-card-purchase manual refund %s failed: %v", pr.ID, upErr)
}
// A5: terminal resolution — clear the consecutive-failure counter.
resetReconcileFailureCount(pr.ID)
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Gift-card-purchase manual refund %s (payment %s) blocked — payment has no booking; Square shows no COMPLETED refund — marked failed, customer must be refunded via the gift-card section", pr.ID, pr.PaymentID)
}
continue
}
// 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 stalePendingRefundAge age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, 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, amountPence, 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 stalePendingRefundAge 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: amountPence,
IdempotencyKey: idemKey,
Reason: pr.Reason,
})
// REFUND_AMOUNT_INVALID is Square's ambiguous already-refunded-or-
// invalid-amount signal. Disambiguate with the EXACT amount (A11): only
// an exact-amount COMPLETED refund at Square proves THIS amount already
// moved — a smaller partial refund does NOT, and completing the row
// would claim the full amount was refunded when only part of it was.
// Mirrors the processChargeGroup disambiguation so the reconciliation
// applies whether the client classified the code as a definitive
// decline or as already-processed.
if isRefundAmountInvalid(sqErr) {
sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountPence)
switch {
case rcErr != nil:
// Reconcile failed — unknown money state. Keep the client's
// own classification below (the switch on sqErr) rather than
// making a money decision on partial data.
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to resolve manual refund %s completed after REFUND_AMOUNT_INVALID on already-refunded payment: %v", pr.ID, upErr)
}
log.Printf("Manual refund %s: payment already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked completed, no admin notification", pr.ID, *sqRefundID)
processed++
continue
default:
// No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID
// is a genuine decline/invalid amount here (the attempt would
// have over-refunded a partially-refunded payment), NOT an
// already-refunded signal. Route through the decline branch.
sqErr = square.ErrRefundDeclined
}
}
switch {
case sqErr == nil:
// Resolve by Square's status: a synchronous refund response can be
// PENDING (money in flight, e.g. an async card network) — marking it
// completed while Square later fails it would permanently block that
// amount in the over-refund guard. Only a definitive COMPLETED
// resolves to completed; PENDING stays pending for the sweep to
// reconcile; FAILED/REJECTED is a real failure. Mirrors
// processChargeGroup and the RefundPayment handler (handlers.go).
sqStatus := "completed"
if sqResult.Status == "PENDING" {
sqStatus = "pending"
log.Printf("Square refund %s for manual refund %s is PENDING (in flight) — leaving the row pending for the sweep to resolve", sqResult.ID, pr.ID)
} else if sqResult.Status == "FAILED" || sqResult.Status == "REJECTED" {
sqStatus = "failed"
log.Printf("Square refund %s for manual refund %s FAILED — marking the row failed", sqResult.ID, pr.ID)
}
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = $1, square_refund_id = $2
WHERE id = $3
`, sqStatus, 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 >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountPence)
}
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at maxManualRefundAttempts.
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 >= maxManualRefundAttempts {
resolveManualRefundAtCap(ctx, pr, amountPence)
}
}
}
return processed, nil
}
// resolveManualRefundAtCap reconciles a manual refund row that just hit the
// maxManualRefundAttempts 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.
// The row is never re-issued here (reconcile is a read), so the cap cannot
// cause a double refund.
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountPence int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. NEVER mark failed
// on an unknown state (that would let the over-refund guard exclude
// moved money). Re-arm the row under the cap so the next sweep re-picks
// it, and track consecutive reconcile failures: after
// maxConsecutiveReconcileFailures consecutive failures a deduped
// 'critical_payment_log' admin notification surfaces the hard-failing
// reconcile — never silently forever (A5b). Re-issue stays safe: the
// row's idempotency key is persisted (ensureRefundKey), so Square
// dedups a same-key retry to the original refund — and once the row
// crosses stalePendingRefundAge the sweep's age guard reconciles it
// instead of re-issuing.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = $1
WHERE id = $2 AND status = 'pending' AND refund_attempts >= $3
`, maxManualRefundAttempts-1, pr.ID, maxManualRefundAttempts); upErr != nil {
log.Printf("Failed to re-arm manual refund %s under the attempt cap after reconcile error: %v", pr.ID, upErr)
}
trackReconcileFailureReArm(ctx, []string{pr.ID})
log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — re-armed under the cap for the next sweep; never marked failed on an unknown state", pr.ID, rcErr)
case sqRefundID != nil:
resetReconcileFailureCount(pr.ID)
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)
}
default:
resetReconcileFailureCount(pr.ID)
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 %d attempts: %v", pr.ID, maxManualRefundAttempts, 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 %d 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, maxManualRefundAttempts)
}
}
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
}