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