Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2444 lines
109 KiB
Go
2444 lines
109 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)
|
|
switch {
|
|
case loyaltyErr != nil:
|
|
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
|
|
case loyaltyTag.RowsAffected() == 0:
|
|
slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID)
|
|
default:
|
|
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: only COMPLETED resolves the group;
|
|
// non-terminal states (PENDING — a later sweep reconciles them via
|
|
// ListPaymentRefunds — APPROVED, CANCELED, unknown) leave the rows
|
|
// pending; FAILED/REJECTED is a definitive failure that must not be
|
|
// marked completed (that would block the amount in the over-refund
|
|
// guard forever).
|
|
sqStatus, sqTerminal := SquareRefundStatusToLocal(sqResult.Status)
|
|
if sqResult.Status == "APPROVED" {
|
|
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — the refund
|
|
// can still transition to FAILED/CANCELED at Square. Resolving it to
|
|
// 'completed' here would strand the row (the FAILED demotion only
|
|
// demotes 'pending'). Keep the rows pending with the square_refund_id
|
|
// recorded so the sweep re-poll and a later FAILED/CANCELED webhook
|
|
// can settle them. COMPLETED stays terminal-completed.
|
|
sqTerminal = false
|
|
}
|
|
if !sqTerminal {
|
|
if sqResult.Status == "APPROVED" {
|
|
// Record the square refund id on the still-pending rows so the
|
|
// sweep's re-poll can look the refund up when it settles.
|
|
if _, upErr := db.Conn.Exec(ctx, `
|
|
UPDATE refunds SET square_refund_id = $1
|
|
WHERE id = ANY($2) AND status = 'pending'
|
|
`, sqResult.ID, idsOf(pending)); upErr != nil {
|
|
log.Printf("Failed to record square refund id %s on pending group for charge %s: %v", sqResult.ID, chargeID, upErr)
|
|
}
|
|
}
|
|
log.Printf("Square refund %s for charge %s is non-terminal (%s) — leaving refunds pending for the sweep", sqResult.ID, chargeID, sqResult.Status)
|
|
} else if 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. Only terminal states write a status: a non-terminal
|
|
// outcome must leave the rows pending untouched.
|
|
if sqTerminal {
|
|
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 — or that a
|
|
// webhook promoted to 'completed' while its PARENT payment row is still
|
|
// pending (the parent is only ever resolved by this re-poll pass).
|
|
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)
|
|
Status string // refunds.status — 'pending', or 'completed' (webhook-promoted, parent unresolved)
|
|
}
|
|
|
|
// sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of
|
|
// replay-induced duplicate charges (B1, refundSweepDuplicateCharge in sweep.go)
|
|
// that Square left PENDING, AND refunds a webhook promoted to 'completed'
|
|
// while the PARENT row is still pending (Loop-B finding: the webhook promotes
|
|
// the refund status but the parent is resolved only here — querying only
|
|
// 'pending' refunds let a promoted refund slip through, the sweep's in-flight
|
|
// guard then missed it and re-replayed the expired key, minting ANOTHER charge).
|
|
// The refund is NON-terminal while the parent is pending: 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, ''), r.status
|
|
FROM refunds r
|
|
LEFT JOIN payments p ON p.id = r.payment_id
|
|
WHERE r.square_refund_id IS NOT NULL
|
|
AND r.origin = 'manual' AND r.reason LIKE 'duplicate charge — sweep replay%'
|
|
AND (
|
|
r.status = 'pending'
|
|
OR (r.status = 'completed' AND (
|
|
p.status = 'pending'
|
|
OR EXISTS (SELECT 1 FROM till_sales ts
|
|
WHERE ts.status = 'pending'
|
|
AND ts.id = SUBSTRING(r.reason FROM '\(till_sale ([^)]+)\)'))
|
|
))
|
|
)
|
|
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, &pr.Status); 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. A refund a webhook promoted to 'completed'
|
|
// AFTER the escalation is re-polled once: the parent can finally be
|
|
// resolved (resolveB1RefundCompleted clears the escalation flag).
|
|
b1EscalatedMu.Lock()
|
|
escalated := b1Escalated[pr.RefundID]
|
|
b1EscalatedMu.Unlock()
|
|
if escalated && pr.Status == "pending" {
|
|
log.Printf("B1 refund %s was already escalated (pending over %s) — not re-polling; manual reconciliation holds the parent pending", pr.RefundID, stalePendingB1RefundAge)
|
|
continue
|
|
}
|
|
if escalated {
|
|
log.Printf("B1 refund %s was escalated but a webhook promoted it to completed — re-polling to resolve the parent", pr.RefundID)
|
|
}
|
|
// 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 terminal COMPLETED
|
|
// resolves to completed; non-terminal states (PENDING, APPROVED,
|
|
// CANCELED, unknown) stay pending for the sweep to reconcile;
|
|
// FAILED/REJECTED is a real failure. Mirrors processChargeGroup and
|
|
// the RefundPayment handler (handlers.go).
|
|
sqStatus, sqTerminal := SquareRefundStatusToLocal(sqResult.Status)
|
|
if sqResult.Status == "APPROVED" {
|
|
// Loop-B finding (MED-HIGH): APPROVED is NON-terminal — a later
|
|
// FAILED/CANCELED must still be able to demote the row, so it
|
|
// stays pending with the square_refund_id recorded.
|
|
sqTerminal = false
|
|
}
|
|
if !sqTerminal {
|
|
if sqResult.Status == "APPROVED" {
|
|
if _, upErr := db.Conn.Exec(ctx, `
|
|
UPDATE refunds SET square_refund_id = $1
|
|
WHERE id = $2
|
|
`, sqResult.ID, pr.ID); upErr != nil {
|
|
log.Printf("Failed to record square refund id %s on pending manual refund %s: %v", sqResult.ID, pr.ID, upErr)
|
|
}
|
|
}
|
|
log.Printf("Square refund %s for manual refund %s is non-terminal (%s) — leaving the row pending for the sweep to resolve", sqResult.ID, pr.ID, sqResult.Status)
|
|
} else if sqStatus == "failed" {
|
|
log.Printf("Square refund %s for manual refund %s FAILED — marking the row failed", sqResult.ID, pr.ID)
|
|
}
|
|
if !sqTerminal {
|
|
log.Printf("Square refund %s for manual refund %s is non-terminal (%s) — leaving the row pending for the sweep to resolve", sqResult.ID, pr.ID, sqResult.Status)
|
|
} else if sqStatus == "failed" {
|
|
log.Printf("Square refund %s for manual refund %s FAILED — marking the row failed", sqResult.ID, pr.ID)
|
|
}
|
|
if sqTerminal {
|
|
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
|
|
}
|