Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
1434 lines
55 KiB
Go
1434 lines
55 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,
|
|
}
|
|
}
|
|
|
|
// ProcessCancellationRefund calculates and records refunds for a cancelled booking.
|
|
// It processes refunds against completed payments on the booking up to the
|
|
// calculated refundable amount, creating refund records in the database.
|
|
// Returns the refund calculation and whether any refunds were processed.
|
|
// 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, q db.Querier, 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 {
|
|
if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('crussell:refund:' || $1))`, 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.
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
// 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.
|
|
// This avoids "conn busy" errors when db.Conn.QueryRow/Exec are called
|
|
// inside the processing loop with a per-test transaction (pgx.Tx does not
|
|
// support concurrent queries on the same connection).
|
|
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. If any
|
|
// lock fails, log + return the error — the caller (bookings.go
|
|
// DeleteBookingHandler) aborts the cancellation so the user can retry;
|
|
// continuing without the lock reopens the over-refund race.
|
|
if err := lockCancellationPayments(ctx, tx, payments); err != nil {
|
|
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, err)
|
|
return &calc, 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.
|
|
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:
|
|
// discount, on_the_house — no real money to refund.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 &calc, 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 {
|
|
if _, err := pinConn.Exec(ctx, `
|
|
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
|
|
`, pid); err != nil {
|
|
log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, err)
|
|
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:
|
|
// 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 = 'completed', square_refund_id = $1
|
|
WHERE id = ANY($2) AND status = 'pending'
|
|
`, 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 (the handler's ambiguous-error
|
|
// path) eligible for retry by the sweep.
|
|
type manualPendingRow struct {
|
|
ID string
|
|
PaymentID string
|
|
Amount float64
|
|
IdempotencyKey string
|
|
Reason string
|
|
SquarePaymentID string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
|
|
// by the RefundPayment handler's ambiguous-error path. The cancellation passes
|
|
// filter origin='cancellation', so manual rows were never re-attempted: they
|
|
// permanently blocked the over-refund guard and depressed booking TotalPaid.
|
|
// Each row is retried with its 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.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 r.square_refund_id IS NULL
|
|
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
|
|
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedAt); err != nil {
|
|
log.Printf("Failed to scan manual pending refund: %v", err)
|
|
continue
|
|
}
|
|
if key != nil {
|
|
pr.IdempotencyKey = *key
|
|
}
|
|
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()
|
|
if _, err := pinConn.Exec(ctx, `
|
|
SELECT pg_advisory_lock(hashtext('crussell:refund:' || $1))
|
|
`, paymentID); err != nil {
|
|
return 0, err
|
|
}
|
|
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
|
|
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
|
|
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID); err != nil {
|
|
log.Printf("Failed to scan manual pending refund under lock: %v", err)
|
|
continue
|
|
}
|
|
if key != nil {
|
|
pr.IdempotencyKey = *key
|
|
}
|
|
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))
|
|
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
|
|
}
|