Files
Crussell/backend/handlers/payments/refunds.go
T
popertots 515b828550 Fix gift-card, till, and refund paths: nonce-direct charges, customer_id forwarding, blocking refund lock
BuyGiftCard and till online_square charge the cnon: nonce directly (no synthetic card-on-file); BuyGiftCard.IdempotencyKey is now validate-required (empty key previously collided on the UNIQUE constraint). Saved-card branches forward customer_id and lazily provision legacy cards. lockCancellationPayments uses the deliberately-blocking xact lock so a cancellation never silently drops a refund under contention (admins see the full manual refund round-trip).
2026-08-22 00:34:49 +01:00

1336 lines
53 KiB
Go

package payments
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"math"
"sort"
"strconv"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"github.com/jackc/pgx/v5"
)
type RefundCalculationResult struct {
TotalPrePaid float64 `json:"total_pre_paid"`
ProtectedDeposit float64 `json:"protected_deposit"`
RefundableAmount float64 `json:"refundable_amount"`
KeptAmount float64 `json:"kept_amount"`
HoursUntilAppointment float64 `json:"hours_until_appointment"`
Tier string `json:"tier"`
}
// paymentRow is one completed payment on a booking, read into a slice before
// the refund loop runs (pgx.Tx does not support concurrent queries on the same
// connection, so rows must be fully drained before Exec/QueryRow in the loop).
type paymentRow struct {
ID string
Amount float64
PaymentMethod string
SquarePaymentID *string
GiftCardID *string
}
// CalculateRefundForCancellation computes the refund amounts for a cancelled booking.
//
// Track A (universal — same rules for all bookings):
// - >72 hours notice: Full refund of all pre-payments
// - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest
// - <24 hours or no-show: Keep all pre-payments
//
// The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50).
// This means up to 50% of the subtotal is always treated as a deposit for
// refund purposes, regardless of whether deposit_required was set on the booking.
func CalculateRefundForCancellation(
subtotal float64,
totalPrePaid float64,
cancellationTime time.Time,
startTime time.Time,
) RefundCalculationResult {
hoursUntilAppointment := startTime.Sub(cancellationTime).Hours()
protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct)
// Round to 2 decimal places
protectedDeposit = math.Round(protectedDeposit*100) / 100
totalPrePaidRounded := math.Round(totalPrePaid*100) / 100
var refundableAmount, keptAmount float64
var tier string
fullHrs := FullRefundThreshold.Hours()
partHrs := PartialRefundThreshold.Hours()
switch {
case hoursUntilAppointment > fullHrs:
refundableAmount = totalPrePaidRounded
keptAmount = 0
tier = FullRefundTier
case hoursUntilAppointment >= partHrs:
keptAmount = protectedDeposit
refundableAmount = totalPrePaidRounded - keptAmount
if refundableAmount < 0 {
refundableAmount = 0
}
tier = PartialRefundTier
default:
keptAmount = totalPrePaidRounded
refundableAmount = 0
tier = NoRefundTier
}
return RefundCalculationResult{
TotalPrePaid: totalPrePaidRounded,
ProtectedDeposit: protectedDeposit,
RefundableAmount: refundableAmount,
KeptAmount: keptAmount,
HoursUntilAppointment: hoursUntilAppointment,
Tier: tier,
}
}
// lockCancellationPayments serializes a cancellation refund against the manual
// RefundPayment handler and the sweep. Both hold
// `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level)
// on the card 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,
// and only for card methods the manual handler can touch.
func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []paymentRow) error {
var ids []string
for _, p := range payments {
if p.PaymentMethod == "online_square" || p.PaymentMethod == "in_person_card" {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
sort.Strings(ids)
for _, pid := range ids {
// Blocking xact lock (NOT the bounded try-lock used elsewhere): this is
// the admin-only cancellation path, and the manual RefundPayment handler
// can hold the same key across its up-to-30s Square round-trip. A timed
// out acquire here would abort the cancellation transaction — the caller
// (manage.go) would commit the cancellation with ZERO refund rows and no
// sweep retry could ever recover the money. Blocking guarantees the
// refund runs; the lock auto-releases at the caller's commit/rollback.
// See acquireAdvisoryXactLockBlocking for the full rationale.
if err := acquireAdvisoryXactLockBlocking(ctx, tx, "crussell:refund:"+pid); err != nil {
return fmt.Errorf("failed to acquire cancellation refund lock for payment %s: %w", pid, err)
}
}
return nil
}
// ProcessCancellationRefundTx is like ProcessCancellationRefund but uses an
// externally-provided transaction. The caller owns the transaction lifecycle
// (commit/rollback). Pass a non-nil pgx.Tx to share an existing transaction.
// forceFullRefund overrides the notice-tier calculation so the ENTIRE net
// pre-paid amount is refunded (admin "forgive fees" path) regardless of how
// close to the appointment the cancellation happens.
func ProcessCancellationRefundTx(
ctx context.Context,
tx pgx.Tx,
bookingID string,
subtotal float64,
totalPrePaid float64,
startTime time.Time,
cancellationTime time.Time,
reason string,
actorID *string,
forceFullRefund bool,
) (*RefundCalculationResult, error) {
calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime)
if forceFullRefund {
calc.RefundableAmount = calc.TotalPrePaid
calc.KeptAmount = 0
calc.Tier = "admin_full_refund"
}
if calc.RefundableAmount <= 0 {
return &calc, nil
}
// Get the booking's user info for refund routing.
var bookingUserID string
var isGuest bool
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 — we'll still process Square refunds but skip balance credits.
}
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')
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for refund: %v", err)
return &calc, nil
}
defer rows.Close()
// Read all payments into a slice, then close rows immediately.
var payments []paymentRow
for rows.Next() {
var p paymentRow
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
payments = append(payments, p)
}
if err := rows.Err(); err != nil {
log.Printf("Payment row iteration error: %v", err)
}
// Serialize against the manual RefundPayment handler and the sweep — the
// residual calculation below must not race a manual refund in flight. If
// any lock fails, abort: continuing without the lock reopens the over-refund
// race. pg_advisory_xact_lock auto-releases at the caller's commit.
if err := lockCancellationPayments(ctx, tx, payments); err != nil {
return nil, err
}
refundRemaining := calc.RefundableAmount
// Prior refunds per payment record (completed + pending) — the loop must
// not re-refund money already returned. Sums by payment_id; pending counts
// because a Square call may already be in flight.
//
// This DB-side over-refund guard (completed + pending) is what prevents
// Square's REFUND_AMOUNT_INVALID in practice: a refund is never issued past
// the residual `amount - already`. Both this cancellation path and the
// manual RefundPayment handler compute residuals while holding the same
// advisory lock (`hashtext('crussell:refund:' || payment_id)` — see
// lockCancellationPayments), so a manual refund cannot slip past the guard
// and a cancellation refund cannot be recorded after the manual guard ran
// without the two serializing. Square no longer documents
// PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is
// REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined
// (definitive) — the charge-group/manual sweep handlers fail those rows and
// surface them via admin_notification rather than silently blocking the
// amount in the guard.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(ctx, `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
WHERE booking_id = $1 AND status IN ('completed', 'pending')
GROUP BY payment_id`, bookingID)
if prErr != nil {
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
} else {
for prRows.Next() {
var pid string
var amt float64
if err := prRows.Scan(&pid, &amt); err == nil {
priorRefunds[pid] = amt
}
}
prRows.Close()
}
for _, p := range payments {
if refundRemaining <= 0 {
break
}
paymentID := p.ID
paymentMethod := p.PaymentMethod
amount := p.Amount
giftCardID := p.GiftCardID
already := priorRefunds[paymentID]
residual := math.Round((amount-already)*100) / 100
if residual <= 0 {
// already fully refunded — don't consume refundRemaining
continue
}
refundThisPayment := math.Min(residual, refundRemaining)
var squareRefundID *string
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 == "" {
log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID)
break
}
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)
break
}
if _, err := tx.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW()
WHERE id = $2
`, refundThisPayment, *giftCardID); err != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err)
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 == "" {
log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment)
} 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)
}
}
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"
}
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 {
_, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if loyaltyErr != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
} else {
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
}
}
}
return &calc, nil
}
// ProcessCancellationRefund calculates and records refunds for a cancelled
// booking, processing refunds against the booking's completed payments up to
// the calculated refundable amount. The wrapper owns its own transaction and
// delegates the refund loop to ProcessCancellationRefundTx
// (forceFullRefund=false) so the standalone and in-transaction callers share
// one implementation; after a successful commit it runs the post-commit
// Square pass (ProcessPendingSquareRefunds). Returns the refund calculation
// and whether any refunds were processed.
func ProcessCancellationRefund(
ctx context.Context,
bookingID string,
subtotal float64,
totalPrePaid float64,
startTime time.Time,
cancellationTime time.Time,
reason string,
actorID *string,
) (*RefundCalculationResult, error) {
calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime)
if calc.RefundableAmount <= 0 {
return &calc, nil
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction for cancellation refund: %v", err)
return &calc, nil
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Delegate the whole refund loop to the transactional variant — the
// non-tx wrapper exists only to own the transaction lifecycle and fire the
// post-commit Square pass (ProcessPendingSquareRefunds) after a successful
// commit. The Tx variant returns an error ONLY on lock failure; every other
// failure logs internally and returns (calc, nil).
res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false)
if txErr != nil {
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr)
return &calc, txErr
}
if cErr := tx.Commit(ctx); cErr != nil {
log.Printf("CRITICAL: Failed to commit cancellation refund transaction for booking %s: %v", bookingID, cErr)
return &calc, nil
}
// Process pending Square refunds after the transaction commits successfully.
// This ensures Square API calls only happen if the DB records persist.
ProcessPendingSquareRefunds(ctx, bookingID, reason)
return res, nil
}
// ProcessPendingSquareRefunds resolves a booking's pending cancellation card
// refunds AFTER the enclosing transaction has committed — Square API calls
// only happen if the DB records persist.
//
// Pending refunds are aggregated into ONE Square refund per charge
// (square_payment_id): split payment records sharing a single Square charge
// are refunded together, never per-row. See processChargeGroup.
func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason string) {
// (a) Terminal pre-pass scoped to this booking: card refunds with no
// Square reference can never be refunded via Square. Mark them failed
// so they stop retrying, and surface the affected booking in the admin
// notification centre for in-person arrangement. Must run OUTSIDE any
// GROUP BY — Postgres lumps NULLs together, so these rows can't be
// handled in the charge grouping below.
rows, err := db.Conn.Query(ctx, `
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.booking_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`, bookingID)
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed for booking %s: %v", bookingID, err)
} else {
var failedIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
failedIDs = append(failedIDs, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate Square-less card refunds for booking %s: %v", bookingID, err)
}
rows.Close()
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less card refund(s) failed for booking %s (in-person arrangement needed)", len(failedIDs), bookingID)
}
insertRefundFailedNotifications(ctx, failedIDs)
}
// (b) This booking's card charges with pending refunds — one aggregated
// Square refund per charge.
for _, chargeID := range queryChargesWithPendingRefunds(ctx, "r.booking_id = $1", bookingID) {
if _, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), reason); err != nil {
log.Printf("Failed to process charge %s for booking %s: %v", chargeID, bookingID, err)
}
}
}
// SweepPendingSquareRefunds is the scheduler job that retries every booking's
// pending cancellation card refunds. Registered in internal/jobs/cleanup.go as
// "sweep-pending-square-refunds".
func SweepPendingSquareRefunds(ctx context.Context) (int, error) {
// (a) Terminal pre-pass across all bookings: card refunds with no Square
// reference can never be refunded via Square → mark failed and surface
// for in-person arrangement. Must run OUTSIDE any GROUP BY — Postgres
// lumps NULLs together, so these rows can't be handled in the charge
// grouping below.
rows, err := db.Conn.Query(ctx, `
UPDATE refunds r SET status = 'failed'
FROM payments p
WHERE p.id = r.payment_id
AND r.status = 'pending' AND r.refund_attempts < 3
AND p.payment_method IN ('online_square', 'in_person_card')
AND p.square_payment_id IS NULL
AND r.origin = 'cancellation'
RETURNING r.id
`)
if err != nil {
log.Printf("Failed to mark Square-less card refunds failed: %v", err)
} else {
var failedIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
failedIDs = append(failedIDs, id)
}
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate Square-less card refunds during sweep: %v", err)
}
rows.Close()
if len(failedIDs) > 0 {
log.Printf("Marked %d Square-less card refund(s) failed (in-person arrangement needed)", len(failedIDs))
}
insertRefundFailedNotifications(ctx, failedIDs)
}
// (b) Charges with pending refunds — one aggregated Square refund each.
processed := 0
for _, chargeID := range queryChargesWithPendingRefunds(ctx, "") {
n, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), "scheduled_retry")
if err != nil {
log.Printf("Sweep: failed to process charge %s: %v", chargeID, err)
continue
}
processed += n
}
// (c) Stale MANUAL pending refunds (the handler's ambiguous-error path) are
// invisible to the cancellation passes above (they filter origin='manual'
// out) — without this pass they were never retried, permanently blocking
// the over-refund guard and depressing booking TotalPaid.
n, err := sweepManualPendingSquareRefunds(ctx)
if err != nil {
log.Printf("Sweep: failed to process manual pending refunds: %v", err)
} else {
processed += n
}
return processed, nil
}
// pendingChargeRow is one eligible pending cancellation refund row tied to a
// single Square charge.
type pendingChargeRow struct {
ID string // refunds.id
PaymentID string // payments.id — advisory-lock ordering
Amount float64
CreatedAt time.Time
}
// queryChargesWithPendingRefunds returns the distinct square_payment_ids that
// have at least one eligible pending cancellation refund row. extraWhere is an
// optional extra SQL predicate bound by args (e.g. "r.booking_id = $1").
func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args ...any) []string {
q := `
SELECT DISTINCT p.square_payment_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.refund_attempts < 3
AND p.square_payment_id IS NOT NULL
AND p.payment_method IN ('online_square', 'in_person_card')
AND r.origin = 'cancellation'`
if extraWhere != "" {
q += " AND " + extraWhere
}
rows, err := db.Conn.Query(ctx, q, args...)
if err != nil {
log.Printf("Failed to query charges with pending refunds: %v", err)
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
log.Printf("Failed to scan charge id: %v", err)
continue
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
log.Printf("Charge id iteration error: %v", err)
}
return out
}
// fetchPendingChargeRows returns all eligible pending cancellation refund rows
// for a single Square charge, ordered by refund id.
func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingChargeRow {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, p.id, r.amount, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE p.square_payment_id = $1
AND r.status = 'pending' AND r.refund_attempts < 3
AND r.origin = 'cancellation'
ORDER BY r.id
`, chargeID)
if err != nil {
log.Printf("Failed to query pending refunds for charge %s: %v", chargeID, err)
return nil
}
defer rows.Close()
var out []pendingChargeRow
for rows.Next() {
var pr pendingChargeRow
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan pending refund row for charge %s: %v", chargeID, err)
continue
}
out = append(out, pr)
}
if err := rows.Err(); err != nil {
log.Printf("Pending refund row iteration error for charge %s: %v", chargeID, err)
}
return out
}
// reconcileRefundAtSquare checks Square for a COMPLETED refund matching the
// exact charge-level amount before the age guard / attempt cap marks rows
// 'failed'. Tri-state return:
//
// (id, nil) — exact COMPLETED refund found → caller marks rows completed
// (nil, nil) — genuinely no match → caller may mark rows failed
// (nil, err) — reconcile FAILED (network/API error) → caller MUST leave
// rows pending and skip the terminal transition. Marking
// failed on an unknown state would let the over-refund guard
// exclude money that actually left the business.
//
// Exact equality on amount AND status COMPLETED AND payment_id: a larger
// COMPLETED refund on the same charge is a manual per-record refund,
// attributing it would mark our rows completed when the aggregate money never
// moved.
func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalCents int64, oldestCreatedAt time.Time) (*string, error) {
refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, oldestCreatedAt)
if err != nil {
log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err)
return nil, err
}
for i := range refunds {
r := &refunds[i]
if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalCents {
return &r.ID, nil
}
}
return nil, nil
}
// insertRefundFailedNotifications surfaces failed refunds in the admin
// notification centre — one row per affected booking. The sweep only processes
// 'pending' rows, so this fires once per row transition (no spam); the
// NOT EXISTS guard prevents duplicates on re-runs.
func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) {
if len(refundIDs) == 0 {
return
}
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, created_at)
SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW()
FROM refunds
WHERE id = ANY($1) AND status = 'failed'
AND NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id
)
`, refundIDs)
if err != nil {
log.Printf("Failed to insert admin_notifications for failed refunds: %v", err)
return
}
if n := int(tag.RowsAffected()); n > 0 {
log.Printf("Inserted %d admin_notification(s) for failed refunds", n)
}
}
// pendingRowsAtAttemptCap returns the refund ids still pending at the 3-attempt
// cap — the candidates for terminal 'failed' resolution.
func pendingRowsAtAttemptCap(ctx context.Context, ids []string) []string {
if len(ids) == 0 {
return nil
}
rows, err := db.Conn.Query(ctx, `
SELECT id FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, ids)
if err != nil {
log.Printf("Failed to query refunds at attempt cap: %v", err)
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
log.Printf("Failed to scan refund id at attempt cap: %v", err)
continue
}
out = append(out, id)
}
return out
}
// processChargeGroup issues ONE Square refund for a charge (square_payment_id)
// covering the sum of its pending cancellation refund rows — the owner
// directive that split records sharing a charge produce a single Square
// refund, never one per row.
//
// Callers pass the charge's current pending rows (fetchPendingChargeRows); the
// rows are re-read under the per-payment advisory lock so a concurrent manual
// refund or another sweep can't double-process.
func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChargeRow, reason string) (int, error) {
if len(rows) == 0 {
return 0, nil
}
// Serialize against the manual RefundPayment handler: acquire the SAME
// per-payment advisory lock (`hashtext('crussell:refund:' || payment_id)`)
// the handler uses, in ascending payment_id order to avoid deadlocks.
pinConn, err := db.Conn.Acquire(ctx)
if err != nil {
log.Printf("Failed to acquire connection for refund lock (charge %s): %v", chargeID, err)
return 0, nil
}
defer pinConn.Release()
paymentIDs := make([]string, 0, len(rows))
seen := make(map[string]bool, len(rows))
for _, r := range rows {
if !seen[r.PaymentID] {
seen[r.PaymentID] = true
paymentIDs = append(paymentIDs, r.PaymentID)
}
}
sort.Strings(paymentIDs)
locked := 0
for _, pid := range paymentIDs {
// Bounded try-lock (R6) so the sweep never blocks a pool connection
// while the manual handler holds the same key across its Square call.
ok, lockErr := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+pid)
if lockErr != nil {
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, lockErr)
break
}
if !ok {
log.Printf("Refund lock for payment %s (charge %s) not acquired within bound — a refund is in progress", pid, chargeID)
break
}
locked++
}
if locked < len(paymentIDs) {
// Give up on this charge (the manual handler may hold the lock). The
// sweep continues to the next charge rather than aborting fatally.
for _, pid := range paymentIDs[:locked] {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, pid); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", pid, err)
}
}
return 0, nil
}
defer func() {
for _, pid := range paymentIDs {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, pid); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", pid, err)
}
}
}()
// Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some).
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
pendingRows, err := db.Conn.Query(ctx, `
SELECT id, amount, created_at FROM refunds
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < 3
`, ids)
if err != nil {
log.Printf("Failed to re-read pending refunds under lock (charge %s): %v", chargeID, err)
return 0, nil
}
var pending []pendingChargeRow
for pendingRows.Next() {
var pr pendingChargeRow
if err := pendingRows.Scan(&pr.ID, &pr.Amount, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan pending refund under lock (charge %s): %v", chargeID, err)
continue
}
pending = append(pending, pr)
}
pendingRows.Close()
if len(pending) == 0 {
return 0, nil
}
// Age guard: Square's idempotency-key retention is finite (~24h). If the
// oldest pending row predates 23 hours, re-issuing with the same charge key
// risks Square treating it as a NEW refund → double refund. Reconcile FIRST:
// money may already have moved at Square (response loss), and failing the
// rows without checking would let the over-refund guard exclude money that
// actually left the business. Only when Square shows no exact COMPLETED
// refund do we mark failed and surface for manual review.
oldest := pending[0].CreatedAt
for _, pr := range pending[1:] {
if pr.CreatedAt.Before(oldest) {
oldest = pr.CreatedAt
}
}
var totalCents int64
for _, pr := range pending {
totalCents += int64(math.Round(pr.Amount * 100))
}
if clock.Now().Sub(oldest) > 23*time.Hour {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave rows
// pending for the next sweep; NEVER mark failed on an unknown state
// (that would let the over-refund guard exclude moved money).
log.Printf("Reconcile failed for aged charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(pending))
return 0, nil
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = ANY($2) AND status = 'pending'
`, *sqRefundID, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
}
log.Printf("Aged card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
return len(pending), nil
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to mark aged pending refunds failed (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, idsOf(pending))
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel. Verify the Square dashboard first.
log.Printf("Card refunds for charge %s are older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
return len(pending), nil
}
}
// ONE Square refund per charge with a STABLE charge-level idempotency key.
// The key is used ONLY for the Square call — never stored in
// refunds.idempotency_key (the per-row keys remain the audit trail). Square
// dedups same-key retries, so crash-retry amounts stay identical.
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: chargeID,
Amount: totalCents,
IdempotencyKey: chargeID + "-square-agg",
Reason: reason,
})
switch {
case sqErr == nil:
// Resolve by Square's status: COMPLETED resolves the group; PENDING
// leaves the rows pending (a later sweep reconciles them via
// ListPaymentRefunds); FAILED/REJECTED is a definitive failure that
// must not be marked completed (that would block the amount in the
// over-refund guard forever).
sqStatus := "completed"
if sqResult.Status == "PENDING" {
sqStatus = "pending"
log.Printf("Square refund %s for charge %s is PENDING — leaving refunds pending for the sweep", sqResult.ID, chargeID)
} else if sqResult.Status == "FAILED" || sqResult.Status == "REJECTED" {
sqStatus = "failed"
log.Printf("Square refund %s for charge %s FAILED — marking refunds failed", sqResult.ID, chargeID)
}
// ATOMIC — one statement for the whole group, never per-row. Keeps
// crash-retry amounts identical so Square's key-dedup returns the
// original refund.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = $1, square_refund_id = $2
WHERE id = ANY($3) AND status = 'pending'
`, sqStatus, sqResult.ID, idsOf(pending)); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for charge %s failed — manual reconciliation required: %v", sqResult.ID, chargeID, upErr)
}
return len(pending), nil
case errors.Is(sqErr, square.ErrRefundAlreadyProcessed):
// PAYMENT_ALREADY_REFUNDED — money already moved at Square.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to resolve refunds after PAYMENT_ALREADY_REFUNDED (charge %s): %v", chargeID, upErr)
}
return len(pending), nil
case errors.Is(sqErr, square.ErrRefundDeclined):
// Definitive decline — money will never move. Bump attempts; at >=3
// mark failed and surface for manual arrangement.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr)
}
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel. Arrange in-person cash pickup at the salon (a day's notice for
// cash on hand).
log.Printf("Card refund for charge %s definitively declined by Square — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
}
return 0, nil
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = ANY($1) AND status = 'pending'
`, idsOf(pending)); upErr != nil {
log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr)
}
// At the cap, money may have moved at Square despite the ambiguous
// responses — reconcile BEFORE marking failed (same bug class as the
// age guard). An exact COMPLETED refund resolves to completed.
if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalCents, oldest)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
// rows pending for the next sweep; NEVER mark failed on an
// unknown state (that would let the over-refund guard exclude
// moved money).
log.Printf("Reconcile failed for ambiguous charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(capIDs))
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = ANY($2) AND status = 'pending'
`, *sqRefundID, capIDs); upErr != nil {
log.Printf("Failed to mark ambiguous refunds completed after Square reconcile (charge %s): %v", chargeID, upErr)
}
log.Printf("Ambiguous card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID)
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= 3
`, capIDs); upErr != nil {
log.Printf("Failed to mark refunds failed after 3 attempts (charge %s): %v", chargeID, upErr)
}
insertRefundFailedNotifications(ctx, capIDs)
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Card refund for charge %s is AMBIGUOUS (Square may have processed it) and no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID)
}
}
return 0, nil
}
}
func idsOf(rows []pendingChargeRow) []string {
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
return ids
}
// manualPendingRow is one stale manual refund row eligible for the sweep's
// resolution. It covers BOTH pending shapes the RefundPayment handler can
// leave behind:
// - square_refund_id set: the handler's synchronous-PENDING response (Square
// already holds the refund, status='pending') — reconciled, never re-issued.
// - square_refund_id NULL: the handler's ambiguous-error path — re-issued
// with the row's OWN stored idempotency key.
type manualPendingRow struct {
ID string
PaymentID string
Amount float64
IdempotencyKey string
Reason string
SquarePaymentID string
SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id
CreatedAt time.Time
}
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
// by the RefundPayment handler. The cancellation passes filter
// origin='cancellation', so manual rows were never re-attempted: they
// permanently blocked the over-refund guard and depressed booking TotalPaid.
// Rows are split per-row by their stored square_refund_id: rows WITH one (the
// handler's synchronous-PENDING response) are reconciled at Square — never
// re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with
// their OWN stored idempotency key (Square dedups same-key retries, so the
// retry is idempotent).
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason,
p.square_payment_id, r.square_refund_id, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.origin = 'manual'
AND r.refund_attempts < 3
AND p.square_payment_id IS NOT NULL
ORDER BY r.payment_id, r.id
`)
if err != nil {
log.Printf("Failed to query manual pending refunds for retry: %v", err)
return 0, nil
}
var pending []manualPendingRow
for rows.Next() {
var pr manualPendingRow
var key *string
var sqRefundID *string
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan manual pending refund: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
rows.Close()
if len(pending) == 0 {
return 0, nil
}
// Group by payment_id, ascending — matching the lock ordering the manual
// handler and processChargeGroup use so the sweep never deadlocks them.
groups := make(map[string][]manualPendingRow)
var paymentIDs []string
for _, pr := range pending {
if _, ok := groups[pr.PaymentID]; !ok {
paymentIDs = append(paymentIDs, pr.PaymentID)
}
groups[pr.PaymentID] = append(groups[pr.PaymentID], pr)
}
sort.Strings(paymentIDs)
processed := 0
for _, pid := range paymentIDs {
n, err := processManualPaymentGroup(ctx, pid, groups[pid])
if err != nil {
log.Printf("Sweep: failed to process manual pending refunds for payment %s: %v", pid, err)
continue
}
processed += n
}
return processed, nil
}
// processManualPaymentGroup retries one payment's stale manual pending refunds
// under the SAME per-payment advisory lock the manual RefundPayment handler
// holds across its guard read — so a re-issued refund can never double-spend
// against a concurrent manual refund. Rows are re-read under the lock; each is
// issued to Square with its OWN stored idempotency key and stored reason.
func processManualPaymentGroup(ctx context.Context, paymentID string, rows []manualPendingRow) (int, error) {
pinConn, err := db.Conn.Acquire(ctx)
if err != nil {
return 0, err
}
defer pinConn.Release()
// Bounded try-lock (R6) so the sweep never blocks a pool connection while
// the manual handler holds the same key across its Square call.
lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+paymentID)
if err != nil {
return 0, err
}
if !lockOK {
log.Printf("Refund lock for payment %s not acquired within bound — a manual refund is in progress; leaving rows pending for the next sweep", paymentID)
return 0, nil
}
defer func() {
if _, err := pinConn.Exec(context.Background(), `
SELECT pg_advisory_unlock(hashtext('crussell:refund:' || $1))
`, paymentID); err != nil {
log.Printf("Failed to release refund lock for payment %s: %v", paymentID, err)
}
}()
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
// Re-read under the lock — only rows still pending and under the attempt
// cap are eligible (a concurrent manual refund may have resolved some).
prRows, err := db.Conn.Query(ctx, `
SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at,
r.payment_id, p.square_payment_id, r.square_refund_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3
ORDER BY r.id
`, ids)
if err != nil {
return 0, err
}
var pending []manualPendingRow
for prRows.Next() {
var pr manualPendingRow
var key *string
var sqRefundID *string
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil {
log.Printf("Failed to scan manual pending refund under lock: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
prRows.Close()
if len(pending) == 0 {
return 0, nil
}
// Age guard (mirrors processChargeGroup): Square's idempotency-key
// retention is finite (~24h). Reconcile FIRST — an exact COMPLETED refund
// at Square resolves to completed even though our rows are stale.
oldest := pending[0].CreatedAt
for _, pr := range pending[1:] {
if pr.CreatedAt.Before(oldest) {
oldest = pr.CreatedAt
}
}
if clock.Now().Sub(oldest) > 23*time.Hour {
processedAged := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave
// this row pending for the next sweep; NEVER mark failed on an
// unknown state.
log.Printf("Reconcile failed for aged manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processedAged++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark aged manual refund %s failed: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s is older than 23h and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
}
}
return processedAged, nil
}
processed := 0
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
// Row WITH a stored square_refund_id — the RefundPayment handler's
// synchronous-PENDING response. Square already holds the refund, so a
// re-issue would risk a SECOND refund (Square's key dedup does not
// protect a fresh key). Reconcile instead: an exact COMPLETED refund at
// Square resolves the row; a genuine no-match means Square never
// recorded it → failed + admin notification; a reconcile error is an
// UNKNOWN state → leave pending (never mark failed on an unknown state,
// that would let the over-refund guard exclude money that may have
// moved). Mirrors the 23h age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processed++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after Square reconcile showed no refund: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Manual refund %s (square_refund_id %s) has no COMPLETED refund at Square — marked 'failed' and admin notified; TODO email user+admin to VERIFY the Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, pr.SquareRefundID)
}
continue
}
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.SquarePaymentID,
Amount: amountCents,
IdempotencyKey: pr.IdempotencyKey,
Reason: pr.Reason,
})
switch {
case sqErr == nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2
`, sqResult.ID, pr.ID); upErr != nil {
log.Printf("CRITICAL: Square refund committed (%s) but DB update for manual refund %s failed — manual reconciliation required: %v", sqResult.ID, pr.ID, upErr)
}
processed++
case errors.Is(sqErr, square.ErrRefundAlreadyProcessed):
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed'
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to resolve manual refund %s completed after PAYMENT_ALREADY_REFUNDED: %v", pr.ID, upErr)
}
processed++
case errors.Is(sqErr, square.ErrRefundDeclined):
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
default:
// Ambiguous — Square may or may not have processed. Retried by the
// sweep, capped at 3 attempts.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET refund_attempts = refund_attempts + 1
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr)
}
if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= 3 {
resolveManualRefundAtCap(ctx, pr, amountCents)
}
}
}
return processed, nil
}
// resolveManualRefundAtCap reconciles a manual refund row that just hit the
// 3-attempt cap: money may have moved at Square despite decline/ambiguous
// responses, so reconcile FIRST — an exact COMPLETED refund resolves the row to
// completed; otherwise mark failed and notify the admin.
func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountCents int64) {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
// Reconcile failed — unknown whether Square refunded. Leave the row
// pending for the next sweep; NEVER mark failed on an unknown state
// (that would let the over-refund guard exclude moved money).
log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — leaving pending for the next sweep", pr.ID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after 3 attempts: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
// TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS
// system lands; until then the admin_notifications row above is the only
// channel.
log.Printf("Manual refund %s reached 3 attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID)
}
}
func currentRefundAttempts(ctx context.Context, refundID string) int {
var n int
if err := db.Conn.QueryRow(ctx, `SELECT refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&n); err != nil {
log.Printf("Failed to read refund_attempts for %s: %v", refundID, err)
}
return n
}