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