package payments import ( "context" "crypto/sha256" "database/sql" "errors" "fmt" "log" "log/slog" "math" "sort" "strconv" "strings" "sync" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "github.com/jackc/pgx/v5" ) // Refunds are exempt from buyer verification. Square's RefundPayment endpoint // does not support 3DS/SCA verification tokens (verification is a charge-time // concept — PSD2 SCA applies to the original payment capture, never to the // money flowing back out), so no refund path carries a verification token and // none ever will. The practical already-refunded signal is Square's // REFUND_AMOUNT_INVALID error (PAYMENT_ALREADY_REFUNDED is no longer // documented): on a genuinely-refunded payment that code is NOT a decline — // the handler reconciles via an EXACT-amount COMPLETED-refund check // (reconcileRefundAtSquareExact, see the A11 disambiguation in // processChargeGroup / processManualPaymentGroup) and resolves the refund row // to 'completed' instead of failing it, so the over-refund guard never // re-issues money Square already returned. // // stalePendingRefundAge is the age guard for pending refunds: Square's // idempotency-key retention is finite (~24h), so a pending refund older than // this must be RECONCILED against Square (ListPaymentRefunds) before any // re-issue — re-issuing with a key Square no longer retains would be treated // as a NEW refund (double refund). A pending row older than // stalePendingRefundAge that Square shows no COMPLETED refund for is marked // 'failed' and surfaced for manual arrangement instead. const stalePendingRefundAge = 23 * time.Hour // maxManualRefundAttempts caps retry attempts for a refund stuck on a // decline/ambiguous outcome before it is resolved terminally. Both the manual // sweep (processManualPaymentGroup / resolveManualRefundAtCap) and the // cancellation charge-group sweeps share the same cap: rows are retried while // refund_attempts < maxManualRefundAttempts, and at the cap a refund is // reconciled at Square FIRST (never marked failed while the money state is // unknown — that would let the over-refund guard exclude money that actually // left the business), then resolved to 'completed' or 'failed' + admin // notification. The SQL literals below are formatted with this constant so // the DB filter and the Go cap can never drift apart. const maxManualRefundAttempts = 3 // maxConsecutiveReconcileFailures is the number of consecutive cap-time // reconcile failures (each causing a re-arm under the attempt cap) before a // refund row is surfaced in the admin notification centre. A reconcile failure // is an UNKNOWN money state — the row is never marked 'failed' on it — but // silently re-arming forever would oscillate the row between the cap and // cap-1 indefinitely with zero admin visibility (A5b/A5c). After this many // consecutive failures a deduped 'critical_payment_log' admin notification is // inserted so the owner learns the reconcile is hard-failing. const maxConsecutiveReconcileFailures = 5 // maxTrackedReconcileFailures caps the in-memory consecutive-failure counter // so the map never grows past a sane value AND the ==maxConsecutiveReconcileFailures // notification check can never be skipped by an odd increment pattern (A5): a // row whose counter is already at the cap stops growing, but the notification // fired when it first crossed maxConsecutiveReconcileFailures and is deduped by // the NOT EXISTS guard, so capping loses no visibility. const maxTrackedReconcileFailures = 10 // manualReconcileFailures counts consecutive cap-time reconcile failures per // refund row (keyed by refunds.id). resolveManualRefundAtCap and the // charge-group cap path re-arm a row under the attempt cap on a reconcile // error so the next sweep re-picks it; without a counter that re-arm loops // forever with no notification. The counter is in-memory (no schema change — // the schema is single-source and pre-launch, no ALTERs): it counts // CONSECUTIVE failures, is reset whenever a reconcile succeeds (the row is // resolved completed/failed), and after maxConsecutiveReconcileFailures // failures triggers a deduped critical_payment_log admin notification. A // process restart merely resets the counter, deferring the notification by a // few sweeps — never suppressing it (the row stays pending and keeps being // re-reconciled, so the notification eventually fires). var ( manualReconcileFailureMu sync.Mutex manualReconcileFailures = make(map[string]int) ) // trackReconcileFailureReArm records one more consecutive cap-time reconcile // failure for each refund row and, once a row crosses // maxConsecutiveReconcileFailures, surfaces a deduped 'critical_payment_log' // admin notification for the affected booking(s) — the admin MUST learn a // reconcile is hard-failing instead of the row silently oscillating under the // attempt cap forever (A5b/A5c). func trackReconcileFailureReArm(ctx context.Context, ids []string) { manualReconcileFailureMu.Lock() notify := false for _, id := range ids { // A5: cap the counter so the map never grows unbounded and the // ==maxConsecutiveReconcileFailures check below is never skipped by an // odd increment pattern — a counter that grew past 5 without a reset // (e.g. to 6) would silently stop firing the notification forever. if manualReconcileFailures[id] < maxTrackedReconcileFailures { manualReconcileFailures[id]++ } if manualReconcileFailures[id] == maxConsecutiveReconcileFailures { notify = true } } manualReconcileFailureMu.Unlock() if !notify { return } notifyCriticalReconcileFailure(ctx, ids) } // resetReconcileFailureCount clears a refund row's consecutive-reconcile- // failure counter. Called whenever a reconcile SUCCEEDS (the row is resolved // to 'completed' or definitively 'failed'), so the counter reflects // consecutive failures only — a success in between breaks the streak. func resetReconcileFailureCount(ids ...string) { manualReconcileFailureMu.Lock() for _, id := range ids { delete(manualReconcileFailures, id) } manualReconcileFailureMu.Unlock() } // notifyCriticalReconcileFailure inserts ONE 'critical_payment_log' admin // notification per affected booking (deduped by insertCriticalPaymentNotification) // so a hard-failing reconcile surfaces in the admin notification centre. // Rows without a booking (gift-card purchases) collapse into one booking-less // notification — the money event is surfaced, not lost. func notifyCriticalReconcileFailure(ctx context.Context, ids []string) { rows, err := db.Conn.Query(ctx, ` SELECT DISTINCT booking_id FROM refunds WHERE id = ANY($1) AND booking_id IS NOT NULL `, ids) if err != nil { log.Printf("Failed to query bookings for critical reconcile-failure notification: %v", err) insertCriticalPaymentNotification(ctx, nil, nil) return } var bookingIDs []string for rows.Next() { var b string if err := rows.Scan(&b); err == nil { bookingIDs = append(bookingIDs, b) } } rows.Close() if len(bookingIDs) == 0 { insertCriticalPaymentNotification(ctx, nil, nil) return } for _, b := range bookingIDs { bid := b insertCriticalPaymentNotification(ctx, &bid, nil) } } // reconcileRefundAtSquareExact checks Square for a COMPLETED refund matching // the EXACT payment+amount, WITHOUT an age bound. Same exact-match semantics // as reconcileRefundAtSquare (payment_id AND status COMPLETED AND amount), minus // the begin_time filter: the REFUND_AMOUNT_INVALID disambiguation (A11) must // not miss a pre-existing refund that predates our refund row. Only an // exact-amount COMPLETED refund proves the money for THIS amount already moved // — a smaller partial refund does NOT, and attributing it would mark the row // completed when only part of the amount was refunded. // // Tri-state return matches reconcileRefundAtSquare: // // (id, nil) — exact COMPLETED refund found // (nil, nil) — genuinely no exact match // (nil, err) — reconcile failed (network/API error) func reconcileRefundAtSquareExact(ctx context.Context, chargeID string, amountPence int64) (*string, error) { refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{}) if err != nil { log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err) return nil, err } for i := range refunds { r := &refunds[i] if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == amountPence { return &r.ID, nil } } return nil, nil } type RefundCalculationResult struct { TotalPrePaid float64 `json:"total_pre_paid"` ProtectedDeposit float64 `json:"protected_deposit"` RefundableAmount float64 `json:"refundable_amount"` KeptAmount float64 `json:"kept_amount"` HoursUntilAppointment float64 `json:"hours_until_appointment"` Tier string `json:"tier"` } // paymentRow is one completed payment on a booking, read into a slice before // the refund loop runs (pgx.Tx does not support concurrent queries on the same // connection, so rows must be fully drained before Exec/QueryRow in the loop). type paymentRow struct { ID string Amount float64 PaymentMethod string SquarePaymentID *string GiftCardID *string } // CalculateRefundForCancellation computes the refund amounts for a cancelled booking. // // Track A (universal — same rules for all bookings): // - >72 hours notice: Full refund of all pre-payments // - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest // - <24 hours or no-show: Keep all pre-payments // // The "protected deposit" is defined as min(totalPrePaid, subtotal * // ProtectedDepositMaxPct). // This means up to 50% of the subtotal is always treated as a deposit for // refund purposes, regardless of whether deposit_required was set on the booking. // // These tiers are the DEFAULT retention for a cancellation (customer calls to // cancel, or the admin cancels on the customer's behalf without forgiving // fees). The admin "forgive fees" path (forceFullRefund) overrides the tiers // entirely so the business keeps nothing — see ProcessCancellationRefundTx for // the two intents (excusable cancellation vs business-initiated cancellation) // that share that override. func CalculateRefundForCancellation( subtotal float64, totalPrePaid float64, cancellationTime time.Time, startTime time.Time, ) RefundCalculationResult { hoursUntilAppointment := startTime.Sub(cancellationTime).Hours() protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct) // Round to 2 decimal places protectedDeposit = math.Round(protectedDeposit*100) / 100 totalPrePaidRounded := math.Round(totalPrePaid*100) / 100 var refundableAmount, keptAmount float64 var tier string fullHrs := FullRefundThreshold.Hours() partHrs := PartialRefundThreshold.Hours() switch { case hoursUntilAppointment > fullHrs: refundableAmount = totalPrePaidRounded keptAmount = 0 tier = FullRefundTier case hoursUntilAppointment >= partHrs: keptAmount = protectedDeposit refundableAmount = totalPrePaidRounded - keptAmount if refundableAmount < 0 { refundableAmount = 0 } tier = PartialRefundTier default: keptAmount = totalPrePaidRounded refundableAmount = 0 tier = NoRefundTier } return RefundCalculationResult{ TotalPrePaid: totalPrePaidRounded, ProtectedDeposit: protectedDeposit, RefundableAmount: refundableAmount, KeptAmount: keptAmount, HoursUntilAppointment: hoursUntilAppointment, Tier: tier, } } // lockCancellationPayments serializes a cancellation refund against the manual // RefundPayment handler and the sweep. Both hold // `pg_advisory_xact_lock(hashtext('crussell:refund:' || payment_id))` // (transaction-level, acquired via acquireAdvisoryXactLockBlocking) on the // payment ids they touch; a cancellation that computes residuals without the // same locks can over-refund against a manual refund in flight (the manual // guard read precedes the cancellation's commit). Locks are acquired in // ascending payment_id order (matching processChargeGroup) to avoid deadlocks. // EVERY payment row the cancellation may refund is locked — giftcard and cash // rows included, not just card methods — so two concurrent refunds of the same // giftcard/cash payment serialize on the residual computation and can never // double-credit (H3). func lockCancellationPayments(ctx context.Context, tx pgx.Tx, payments []paymentRow) error { var ids []string for _, p := range payments { // discount/on_the_house rows are already excluded by the caller's // query, so every remaining row is one this loop may refund. ids = append(ids, p.ID) } if len(ids) == 0 { return nil } sort.Strings(ids) for _, pid := range ids { // Blocking xact lock (NOT the bounded try-lock used elsewhere): this is // the admin-only cancellation path, and the manual RefundPayment handler // can hold the same key across its up-to-30s Square round-trip. A timed // out acquire here would abort the cancellation transaction — the caller // (manage.go) would commit the cancellation with ZERO refund rows and no // sweep retry could ever recover the money. Blocking guarantees the // refund runs; the lock auto-releases at the caller's commit/rollback. // See acquireAdvisoryXactLockBlocking for the full rationale. if err := acquireAdvisoryXactLockBlocking(ctx, tx, "crussell:refund:"+pid); err != nil { return fmt.Errorf("failed to acquire cancellation refund lock for payment %s: %w", pid, err) } } return nil } // ProcessCancellationRefundTx is like ProcessCancellationRefund but uses an // externally-provided transaction. The caller owns the transaction lifecycle // (commit/rollback). Pass a non-nil pgx.Tx to share an existing transaction. // // The admin "forgive fees" checkbox (forceFullRefund) serves TWO distinct // purposes, and both route here identically — it overrides the notice-tier // calculation so the ENTIRE net pre-paid amount is refunded regardless of how // close to the appointment the cancellation happens: // // - (A) Genuinely excusable cancellation: the customer has a legitimate // excuse (medical, emergency, technical fault, etc.) and the business // chooses to waive its notice-period fees as a goodwill gesture. // - (B) Business-initiated cancellation: the salon had to cancel the // appointment and chooses NOT to keep the money (the deposit/notice // retention would be unfair when the cancellation is the business's // doing). // // These two intents are deliberately NOT distinguished in the refund // calculation — both mean "the business keeps nothing". When the admin cancels // on a customer's behalf or a customer calls up to cancel without a forgivable // excuse, the notice tiers below apply (forceFullRefund=false); the checkbox // is the explicit opt-out from that retention. func ProcessCancellationRefundTx( ctx context.Context, tx pgx.Tx, bookingID string, subtotal float64, totalPrePaid float64, startTime time.Time, cancellationTime time.Time, reason string, actorID *string, forceFullRefund bool, ) (*RefundCalculationResult, error) { calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime) if forceFullRefund { calc.RefundableAmount = calc.TotalPrePaid calc.KeptAmount = 0 calc.Tier = "admin_full_refund" } if calc.RefundableAmount <= 0 { return &calc, nil } // Get the booking's user info for refund routing. var bookingUserID string var isGuest bool bookingUserLookupFailed := false if err := tx.QueryRow(ctx, ` SELECT b.user_id, COALESCE(u.account_role = 'guest', false) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = $1 `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { log.Printf("Failed to get booking user info for refund: %v", err) // Non-fatal for Square refunds (which route by payment, not user), but // the balance-credit branches below must know the lookup FAILED (vs a // genuine guest) so they never record a 'completed' refund when no // money could be credited — see creditFailed. bookingUserLookupFailed = true } rows, err := tx.Query(ctx, ` SELECT id, amount, payment_method, square_payment_id, gift_card_id FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND payment_type <> 'tip' ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for refund: %v", err) return &calc, nil } defer rows.Close() // Read all payments into a slice, then close rows immediately. var payments []paymentRow for rows.Next() { var p paymentRow if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil { log.Printf("Failed to scan payment row: %v", err) continue } payments = append(payments, p) } if err := rows.Err(); err != nil { log.Printf("Payment row iteration error: %v", err) } // Serialize against the manual RefundPayment handler and the sweep — the // residual calculation below must not race a manual refund in flight. If // any lock fails, abort: continuing without the lock reopens the over-refund // race. pg_advisory_xact_lock auto-releases at the caller's commit. if err := lockCancellationPayments(ctx, tx, payments); err != nil { return nil, err } refundRemaining := calc.RefundableAmount // Prior refunds per payment record (completed + pending) — the loop must // not re-refund money already returned. Sums by payment_id; pending counts // because a Square call may already be in flight. // // This DB-side over-refund guard (completed + pending) is what prevents // Square's REFUND_AMOUNT_INVALID in practice: a refund is never issued past // the residual `amount - already`. Both this cancellation path and the // manual RefundPayment handler compute residuals while holding the same // advisory lock (`hashtext('crussell:refund:' || payment_id)` — see // lockCancellationPayments), so a manual refund cannot slip past the guard // and a cancellation refund cannot be recorded after the manual guard ran // without the two serializing. Square no longer documents // PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is // REFUND_AMOUNT_INVALID. The square client reconciles that code against // Square's refund list (PaymentWasRefunded) and maps an already-refunded // payment to ErrRefundAlreadyProcessed; the charge-group/manual sweep // handlers additionally reconcile REFUND_AMOUNT_INVALID via an exact-amount // COMPLETED-refund check (reconcileRefundAtSquareExact, defense-in-depth — // see the A11 disambiguation) and resolve those rows to 'completed' — never // 'failed' + admin_notification, which would let the over-refund guard // re-issue money Square already returned. priorRefunds := make(map[string]float64) prRows, prErr := tx.Query(ctx, ` SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds WHERE booking_id = $1 AND status IN ('completed', 'pending') GROUP BY payment_id`, bookingID) if prErr != nil { log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr) } else { for prRows.Next() { var pid string var amt float64 if err := prRows.Scan(&pid, &amt); err == nil { priorRefunds[pid] = amt } } prRows.Close() } for _, p := range payments { if refundRemaining <= 0 { break } paymentID := p.ID paymentMethod := p.PaymentMethod amount := p.Amount giftCardID := p.GiftCardID already := priorRefunds[paymentID] residual := math.Round((amount-already)*100) / 100 if residual <= 0 { // already fully refunded — don't consume refundRemaining continue } refundThisPayment := math.Min(residual, refundRemaining) var squareRefundID *string // creditFailed records that money for this payment did NOT actually // move (deleted gift card, failed balance credit) so the refund record // is inserted 'failed' instead of claiming a completed refund (M2). creditFailed := false switch paymentMethod { case "online_square", "in_person_card": // Square API refund is processed AFTER the transaction commits // (see ProcessPendingSquareRefunds). Inside the tx we only record // the refund record as "pending" for post-commit processing. if isGuest || bookingUserID == "" { log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment) } // squareRefundID stays nil — will be set by ProcessPendingSquareRefunds case "giftcard": if giftCardID == nil || *giftCardID == "" { // A giftcard payment with no gift_card_id was made from the // user's gift-card ACCOUNT balance (the terminal giftcard path // stores no gift_card_id for balance payments). Credit the // booking user's balance back — skipping would lose the money // while the completed refund row claims it was returned (C3). // Guests get no balance credit, mirroring the cash branch. if isGuest || bookingUserID == "" { if isGuest { log.Printf("Guest giftcard refund: booking %s, payment %s, amount £%.2f — no balance credit", bookingID, paymentID, refundThisPayment) } else { log.Printf("Giftcard payment %s has no gift_card_id and no booking user — cannot refund. Skipping.", paymentID) } if bookingUserLookupFailed { // The user may well exist — the lookup failed // transiently, so 'completed' would claim money was // credited when none could be. Mark the record failed // for admin reconciliation instead. creditFailed = true } break } log.Printf("Crediting £%.2f to user %s gift-card balance for account-balance giftcard payment %s", refundThisPayment, bookingUserID, paymentID) if _, balErr := tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, bookingUserID, refundThisPayment); balErr != nil { log.Printf("Failed to credit user %s gift-card balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) creditFailed = true } break } // expiry_date is maintained by EVERY gift-card write that counts as // a "use" (balance check, top-up, transfer, redeem, payment, refund // credit), so a non-NULL expiry_date means the rolling timer is // authoritative. NULL semantics stay fail-open: an unset expiry_date // cannot prove the card is expired, so the refund proceeds. var expired bool if err := tx.QueryRow(ctx, ` SELECT expiry_date IS NOT NULL AND expiry_date < NOW() FROM gift_cards WHERE id = $1 `, *giftCardID).Scan(&expired); err != nil { log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) } else if expired { log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) // Money-safety (C4): the UPDATE gift_cards credit above is // SKIPPED for expired cards (money retained by the salon), so // creditFailed must be set here — otherwise the shared tail // below inserts the refund record as 'completed' claiming money // was returned when it never moved. creditFailed = true break } // Refunding to the card is a "use" per the rolling-expiry terms — // reset the timer at the same moment the balance is credited. gcExpiryMonths, expiryErr := GetGiftCardExpiryMonths(ctx, tx) if expiryErr != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, expiryErr) gcExpiryMonths = defaultGiftCardExpiryMonths } gcTag, gcErr := tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, refundThisPayment, *giftCardID, gcExpiryMonths) if gcErr != nil { log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, gcErr) creditFailed = true break } if gcTag.RowsAffected() == 0 { // M2: the gift card no longer exists — the UPDATE matched 0 rows // and no money moved. Marking the refund 'completed' would claim // money was returned when it wasn't. slog.Error("CRITICAL: gift card refund UPDATE affected 0 rows — card deleted?, refund NOT credited", "gift_card_id", *giftCardID, "payment_id", paymentID, "booking_id", bookingID, "amount", refundThisPayment) creditFailed = true break } if _, err := tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { log.Printf("Failed to create gift card transaction for refund: %v", err) } case "cash": if isGuest || bookingUserID == "" { if isGuest { log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) } else { log.Printf("Cash refund: payment %s has no booking user — skipping balance credit", paymentID) } if bookingUserLookupFailed { // The booking-user lookup errored, so this may be a real // user whose balance credit was skipped — a 'completed' // refund would claim money was returned when it wasn't. creditFailed = true } } else { log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) if _, balErr := tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, bookingUserID, refundThisPayment); balErr != nil { log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) creditFailed = true } } default: log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) } recordStatus := "completed" if paymentMethod == "online_square" || paymentMethod == "in_person_card" { recordStatus = "pending" } if creditFailed { recordStatus = "failed" } record := RefundRecord{ PaymentID: paymentID, BookingID: bookingID, Amount: refundThisPayment, SquareRefundID: squareRefundID, Status: recordStatus, Reason: reason, Origin: "cancellation", CreatedBy: actorID, CreatedAt: clock.Now(), } // Deterministic idempotency key so a scheduler retry can never issue a // second Square refund. Format never collides with the handler's // "-refund-" keys. refundKey := paymentID + "-square-" + strconv.FormatInt(int64(math.Round(refundThisPayment*100)), 10) record.IdempotencyKey = &refundKey tag, dbErr := tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (idempotency_key) DO NOTHING `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.IdempotencyKey, record.CreatedBy, record.CreatedAt, record.Origin) if dbErr != nil { log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) continue } // tag.RowsAffected() == 0 means the same idempotency_key already exists // (a prior refund row for this payment+amount in 'failed' state — money // never moved, but a row exists). Dedup — skip WITHOUT consuming // refundRemaining so the loop can allocate to the next payment, exactly // as the pre-ON-CONFLICT UNIQUE-violation path behaved. if tag.RowsAffected() == 0 { log.Printf("Refund for payment %s amount £%.2f already exists (idempotency dedup) — skipping without consuming refundRemaining", paymentID, refundThisPayment) continue } refundRemaining -= refundThisPayment } if bookingUserID != "" { var loyaltyUsed bool if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil { log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err) } else if loyaltyUsed { loyaltyTag, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) if loyaltyErr != nil { log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr) } else if loyaltyTag.RowsAffected() == 0 { slog.Error("CRITICAL: loyalty stamp refund UPDATE affected 0 rows — user not found", "user_id", bookingUserID, "booking_id", bookingID) } else { log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) } } } return &calc, nil } // ProcessCancellationRefund calculates and records refunds for a cancelled // booking, processing refunds against the booking's completed payments up to // the calculated refundable amount. The wrapper owns its own transaction and // delegates the refund loop to ProcessCancellationRefundTx // (forceFullRefund=false) so the standalone and in-transaction callers share // one implementation; after a successful commit it runs the post-commit // Square pass (ProcessPendingSquareRefunds). Returns the refund calculation // and whether any refunds were processed. func ProcessCancellationRefund( ctx context.Context, bookingID string, subtotal float64, totalPrePaid float64, startTime time.Time, cancellationTime time.Time, reason string, actorID *string, ) (*RefundCalculationResult, error) { calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime) if calc.RefundableAmount <= 0 { return &calc, nil } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction for cancellation refund: %v", err) return &calc, nil } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Delegate the whole refund loop to the transactional variant — the // non-tx wrapper exists only to own the transaction lifecycle and fire the // post-commit Square pass (ProcessPendingSquareRefunds) after a successful // commit. The Tx variant returns an error ONLY on lock failure; every other // failure logs internally and returns (calc, nil). res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false) if txErr != nil { log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr) return &calc, txErr } if cErr := tx.Commit(ctx); cErr != nil { slog.Error("CRITICAL: failed to commit cancellation refund", "booking_id", bookingID, "err", cErr) return nil, fmt.Errorf("failed to commit cancellation refund: %w", cErr) } // Process pending Square refunds after the transaction commits successfully. // This ensures Square API calls only happen if the DB records persist. ProcessPendingSquareRefunds(ctx, bookingID, reason) return res, nil } // ProcessPendingSquareRefunds resolves a booking's pending cancellation card // refunds AFTER the enclosing transaction has committed — Square API calls // only happen if the DB records persist. // // Pending refunds are aggregated into ONE Square refund per charge // (square_payment_id): split payment records sharing a single Square charge // are refunded together, never per-row. See processChargeGroup. func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason string) { // (a) Terminal pre-pass scoped to this booking: card refunds with no // Square reference can never be refunded via Square. Mark them failed // so they stop retrying, and surface the affected booking in the admin // notification centre for in-person arrangement. Must run OUTSIDE any // GROUP BY — Postgres lumps NULLs together, so these rows can't be // handled in the charge grouping below. rows, err := db.Conn.Query(ctx, fmt.Sprintf(` UPDATE refunds r SET status = 'failed' FROM payments p WHERE p.id = r.payment_id AND r.booking_id = $1 AND r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'cancellation' RETURNING r.id `, maxManualRefundAttempts), bookingID) if err != nil { log.Printf("Failed to mark Square-less card refunds failed for booking %s: %v", bookingID, err) } else { var failedIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { failedIDs = append(failedIDs, id) } } if err := rows.Err(); err != nil { log.Printf("Failed to iterate Square-less card refunds for booking %s: %v", bookingID, err) } rows.Close() if len(failedIDs) > 0 { log.Printf("Marked %d Square-less card refund(s) failed for booking %s (in-person arrangement needed)", len(failedIDs), bookingID) } insertRefundFailedNotifications(ctx, failedIDs) } // (b) This booking's card charges with pending refunds — one aggregated // Square refund per charge. for _, chargeID := range queryChargesWithPendingRefunds(ctx, "r.booking_id = $1", bookingID) { if _, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), reason); err != nil { log.Printf("Failed to process charge %s for booking %s: %v", chargeID, bookingID, err) } } } // SweepPendingSquareRefunds is the scheduler job that retries every booking's // pending cancellation card refunds. Registered in internal/jobs/cleanup.go as // "sweep-pending-square-refunds". func SweepPendingSquareRefunds(ctx context.Context) (int, error) { // (a) Terminal pre-pass across all bookings: card refunds with no Square // reference can never be refunded via Square → mark failed and surface // for in-person arrangement. Must run OUTSIDE any GROUP BY — Postgres // lumps NULLs together, so these rows can't be handled in the charge // grouping below. rows, err := db.Conn.Query(ctx, fmt.Sprintf(` UPDATE refunds r SET status = 'failed' FROM payments p WHERE p.id = r.payment_id AND r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'cancellation' RETURNING r.id `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to mark Square-less card refunds failed: %v", err) } else { var failedIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { failedIDs = append(failedIDs, id) } } if err := rows.Err(); err != nil { log.Printf("Failed to iterate Square-less card refunds during sweep: %v", err) } rows.Close() if len(failedIDs) > 0 { log.Printf("Marked %d Square-less card refund(s) failed (in-person arrangement needed)", len(failedIDs)) } insertRefundFailedNotifications(ctx, failedIDs) } // (b) Charges with pending refunds — one aggregated Square refund each. processed := 0 for _, chargeID := range queryChargesWithPendingRefunds(ctx, "") { n, err := processChargeGroup(ctx, chargeID, fetchPendingChargeRows(ctx, chargeID), "scheduled_retry") if err != nil { log.Printf("Sweep: failed to process charge %s: %v", chargeID, err) continue } processed += n } // (c) Stale MANUAL pending refunds (the handler's ambiguous-error path) are // invisible to the cancellation passes above (they filter origin='manual' // out) — without this pass they were never retried, permanently blocking // the over-refund guard and depressing booking TotalPaid. n, err := sweepManualPendingSquareRefunds(ctx) if err != nil { log.Printf("Sweep: failed to process manual pending refunds: %v", err) } else { processed += n } return processed, nil } // pendingChargeRow is one eligible pending cancellation refund row tied to a // single Square charge. type pendingChargeRow struct { ID string // refunds.id PaymentID string // payments.id — advisory-lock ordering Amount float64 CreatedAt time.Time } // queryChargesWithPendingRefunds returns the distinct square_payment_ids that // have at least one eligible pending cancellation refund row. extraWhere is an // optional extra SQL predicate bound by args (e.g. "r.booking_id = $1"). func queryChargesWithPendingRefunds(ctx context.Context, extraWhere string, args ...any) []string { q := fmt.Sprintf(` SELECT DISTINCT p.square_payment_id FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE r.status = 'pending' AND r.refund_attempts < %d AND p.square_payment_id IS NOT NULL AND p.payment_method IN ('online_square', 'in_person_card') AND r.origin = 'cancellation'`, maxManualRefundAttempts) if extraWhere != "" { q += " AND " + extraWhere } rows, err := db.Conn.Query(ctx, q, args...) if err != nil { log.Printf("Failed to query charges with pending refunds: %v", err) return nil } defer rows.Close() var out []string for rows.Next() { var s string if err := rows.Scan(&s); err != nil { log.Printf("Failed to scan charge id: %v", err) continue } out = append(out, s) } if err := rows.Err(); err != nil { log.Printf("Charge id iteration error: %v", err) } return out } // fetchPendingChargeRows returns all eligible pending cancellation refund rows // for a single Square charge, ordered by refund id. func fetchPendingChargeRows(ctx context.Context, chargeID string) []pendingChargeRow { rows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT r.id, p.id, r.amount, r.created_at FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE p.square_payment_id = $1 AND r.status = 'pending' AND r.refund_attempts < %d AND r.origin = 'cancellation' ORDER BY r.id `, maxManualRefundAttempts), chargeID) if err != nil { log.Printf("Failed to query pending refunds for charge %s: %v", chargeID, err) return nil } defer rows.Close() var out []pendingChargeRow for rows.Next() { var pr pendingChargeRow if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &pr.CreatedAt); err != nil { log.Printf("Failed to scan pending refund row for charge %s: %v", chargeID, err) continue } out = append(out, pr) } if err := rows.Err(); err != nil { log.Printf("Pending refund row iteration error for charge %s: %v", chargeID, err) } return out } // isRefundAmountInvalid reports whether err carries Square's // REFUND_AMOUNT_INVALID code — structurally (the real HTTP client wraps the // code in a squareAPIError) or by message (the dev mock embeds the code in its // simulated error). func isRefundAmountInvalid(err error) bool { if err == nil { return false } if square.ErrorCode(err) == "REFUND_AMOUNT_INVALID" { return true } return strings.Contains(err.Error(), "REFUND_AMOUNT_INVALID") } // reconcileRefundAtSquare checks Square for a COMPLETED refund matching the // exact charge-level amount before the age guard / attempt cap marks rows // 'failed'. Tri-state return: // // (id, nil) — exact COMPLETED refund found → caller marks rows completed // (nil, nil) — genuinely no match → caller may mark rows failed // (nil, err) — reconcile FAILED (network/API error) → caller MUST leave // rows pending and skip the terminal transition. Marking // failed on an unknown state would let the over-refund guard // exclude money that actually left the business. // // Exact equality on amount AND status COMPLETED AND payment_id: a larger // COMPLETED refund on the same charge is a manual per-record refund, // attributing it would mark our rows completed when the aggregate money never // moved. func reconcileRefundAtSquare(ctx context.Context, chargeID string, totalPence int64, oldestCreatedAt time.Time) (*string, error) { refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, oldestCreatedAt) if err != nil { log.Printf("Failed to reconcile charge %s against Square: %v", chargeID, err) return nil, err } for i := range refunds { r := &refunds[i] if r.PaymentID == chargeID && r.Status == "COMPLETED" && r.Amount == totalPence { return &r.ID, nil } } return nil, nil } // insertRefundFailedNotifications surfaces failed refunds in the admin // notification centre — one row per affected booking. The sweep only processes // 'pending' rows, so this fires once per row transition (no spam); the // NOT EXISTS guard prevents duplicates on re-runs. // // The webhooks package carries the SINGULAR variant of this same insert — // insertRefundFailedNotification (handlers/webhooks/square.go) — which demotes // a single webhook-surfaced FAILED refund to the identical 'refund_failed' // row. The two share the same NOT EXISTS dedup guard on // (reason='refund_failed', booking_id), so a refund resolved by either path // can never be double-notified; keep the reason string and dedup predicate in // lockstep when either changes. // InsertRefundFailedNotifications is the EXPORTED single source for surfacing // failed refunds in the admin notification centre, consumed by both the sweep // path (sweep.go) and the webhook path (handlers/webhooks/square.go). The // webhook package calls this instead of maintaining its own copy, so the SQL // and the (reason='refund_failed', booking_id) dedup predicate live in exactly // one place. It delegates to the package-internal insertRefundFailedNotifications. func InsertRefundFailedNotifications(ctx context.Context, refundIDs []string) { insertRefundFailedNotifications(ctx, refundIDs) } func insertRefundFailedNotifications(ctx context.Context, refundIDs []string) { if len(refundIDs) == 0 { return } tag, err := db.Conn.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, created_at) SELECT DISTINCT 'refund_failed'::admin_notification_reason, booking_id, NOW() FROM refunds WHERE id = ANY($1) AND status = 'failed' AND NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.reason = 'refund_failed' AND an.booking_id = refunds.booking_id ) `, refundIDs) if err != nil { log.Printf("Failed to insert admin_notifications for failed refunds: %v", err) return } if n := int(tag.RowsAffected()); n > 0 { log.Printf("Inserted %d admin_notification(s) for failed refunds", n) } } // pendingRowsAtAttemptCap returns the refund ids still pending at the // maxManualRefundAttempts cap — the candidates for terminal 'failed' resolution. func pendingRowsAtAttemptCap(ctx context.Context, ids []string) []string { if len(ids) == 0 { return nil } rows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT id FROM refunds WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d `, maxManualRefundAttempts), ids) if err != nil { log.Printf("Failed to query refunds at attempt cap: %v", err) return nil } defer rows.Close() var out []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { log.Printf("Failed to scan refund id at attempt cap: %v", err) continue } out = append(out, id) } return out } // processChargeGroup issues ONE Square refund for a charge (square_payment_id) // covering the sum of its pending cancellation refund rows — the owner // directive that split records sharing a charge produce a single Square // refund, never one per row. // // Callers pass the charge's current pending rows (fetchPendingChargeRows); the // rows are re-read under the per-payment advisory lock so a concurrent manual // refund or another sweep can't double-process. func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChargeRow, reason string) (int, error) { if len(rows) == 0 { return 0, nil } // Serialize against the manual RefundPayment handler: acquire the SAME // per-payment advisory lock (`hashtext('crussell:refund:' || payment_id)`) // the handler uses, in ascending payment_id order to avoid deadlocks. pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for refund lock (charge %s): %v", chargeID, err) return 0, nil } defer pinConn.Release() paymentIDs := make([]string, 0, len(rows)) seen := make(map[string]bool, len(rows)) for _, r := range rows { if !seen[r.PaymentID] { seen[r.PaymentID] = true paymentIDs = append(paymentIDs, r.PaymentID) } } sort.Strings(paymentIDs) locked := 0 for _, pid := range paymentIDs { // Bounded try-lock (R6) so the sweep never blocks a pool connection // while the manual handler holds the same key across its Square call. ok, lockErr := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+pid) if lockErr != nil { log.Printf("Failed to acquire refund lock for payment %s (charge %s): %v", pid, chargeID, lockErr) break } if !ok { log.Printf("Refund lock for payment %s (charge %s) not acquired within bound — a refund is in progress", pid, chargeID) break } locked++ } if locked < len(paymentIDs) { // Give up on this charge (the manual handler may hold the lock). The // sweep continues to the next charge rather than aborting fatally. for _, pid := range paymentIDs[:locked] { releasePaymentLock(pinConn, "crussell:refund:"+pid) } return 0, nil } defer func() { for _, pid := range paymentIDs { releasePaymentLock(pinConn, "crussell:refund:"+pid) } }() // Re-read under the lock — only rows still pending and under the attempt // cap are eligible (a concurrent manual refund may have resolved some). ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) } pendingRows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT id, amount, created_at FROM refunds WHERE id = ANY($1) AND status = 'pending' AND refund_attempts < %d `, maxManualRefundAttempts), ids) if err != nil { log.Printf("Failed to re-read pending refunds under lock (charge %s): %v", chargeID, err) return 0, nil } var pending []pendingChargeRow for pendingRows.Next() { var pr pendingChargeRow if err := pendingRows.Scan(&pr.ID, &pr.Amount, &pr.CreatedAt); err != nil { log.Printf("Failed to scan pending refund under lock (charge %s): %v", chargeID, err) continue } pending = append(pending, pr) } pendingRows.Close() if len(pending) == 0 { return 0, nil } // Age guard: Square's idempotency-key retention is finite (~24h). If the // oldest pending row predates stalePendingRefundAge, re-issuing with the // same charge key risks Square treating it as a NEW refund → double refund. // Reconcile FIRST: money may already have moved at Square (response loss), // and failing the rows without checking would let the over-refund guard // exclude money that actually left the business. Only when Square shows no // exact COMPLETED refund do we mark failed and surface for manual review. oldest := pending[0].CreatedAt for _, pr := range pending[1:] { if pr.CreatedAt.Before(oldest) { oldest = pr.CreatedAt } } var totalPence int64 for _, pr := range pending { totalPence += int64(math.Round(pr.Amount * 100)) } if clock.Now().Sub(oldest) > stalePendingRefundAge { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest) switch { case rcErr != nil: // Reconcile failed — unknown whether Square refunded. Leave rows // pending for the next sweep; NEVER mark failed on an unknown state // (that would let the over-refund guard exclude moved money). log.Printf("Reconcile failed for aged charge %s (%v) — leaving %d refund row(s) pending for the next sweep", chargeID, rcErr, len(pending)) return 0, nil case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = ANY($2) AND status = 'pending' `, *sqRefundID, idsOf(pending)); upErr != nil { log.Printf("Failed to mark aged pending refunds completed after Square reconcile (charge %s): %v", chargeID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(idsOf(pending)...) log.Printf("Aged card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID) return len(pending), nil default: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' WHERE id = ANY($1) AND status = 'pending' `, idsOf(pending)); upErr != nil { log.Printf("Failed to mark aged pending refunds failed (charge %s): %v", chargeID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(idsOf(pending)...) insertRefundFailedNotifications(ctx, idsOf(pending)) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. Verify the Square dashboard first. log.Printf("Card refunds for charge %s are older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID) return len(pending), nil } } // ONE Square refund per charge with a CHARGE-STABLE idempotency key. The // key is derived from the charge ID (square_payment_id) ONLY — NEVER from // the set of pending row IDs — so it is identical across every sweep run // no matter how the pending set evolves. The key is used ONLY for the // Square call — never stored in refunds.idempotency_key (the per-row keys // remain the audit trail). // // SAME-set retry (a crash/response-loss where the pending set is // unchanged) → SAME key → Square's idempotency dedup returns the // ORIGINAL refund, so a retry can never double-refund. // // CHANGED set (a new cancellation refund row joined the group while the // old rows still sit 'pending' — the CRITICAL-log path where the // post-refund DB UPDATE failed) → STILL the SAME key. Money for the // original total has ALREADY moved at Square, so a set-derived key would // have hashed to a NEW key and issued a SECOND Square refund on top — // double-refunding the customer (C6). Deduping on the charge key returns // the original refund instead; the new row resolves against it and any // residual gap is a known, admin-visible shortfall rather than lost // money. The stalePendingRefundAge age-guard reconcile above still // protects the cross-sweep case where Square's finite (~24h) key // retention may have lapsed. sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: chargeID, Amount: totalPence, IdempotencyKey: chargeAggKey(chargeID), Reason: reason, }) // REFUND_AMOUNT_INVALID is Square's ambiguous signal for BOTH a genuinely // invalid refund amount AND an already-refunded payment (Square no longer // documents PAYMENT_ALREADY_REFUNDED). Disambiguate with the EXACT amount // BEFORE the classification switch so the reconciliation applies whether // the client classified the code as a definitive decline or as // already-processed: only an exact-amount COMPLETED refund at Square proves // the money for THIS amount already moved (A11). A smaller partial refund // does NOT — marking the rows completed would claim the full amount was // refunded when only part of it was. if isRefundAmountInvalid(sqErr) { sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, chargeID, totalPence) switch { case rcErr != nil: // Reconcile failed — unknown money state. Keep the client's own // classification below (the switch on sqErr) rather than making a // money decision on partial data. case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed' WHERE id = ANY($1) AND status = 'pending' `, idsOf(pending)); upErr != nil { log.Printf("Failed to resolve refunds completed after REFUND_AMOUNT_INVALID on already-refunded charge %s: %v", chargeID, upErr) } log.Printf("Charge %s already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked %d refund row(s) completed, no admin notification", chargeID, *sqRefundID, len(pending)) return len(pending), nil default: // No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID // here is a genuine decline/invalid amount (the attempt would have // over-refunded a partially-refunded payment), NOT an // already-refunded signal. Route through the decline branch. sqErr = square.ErrRefundDeclined } } switch { case sqErr == nil: // Resolve by Square's status: 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 // maxManualRefundAttempts mark failed and surface for manual arrangement. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = ANY($1) AND status = 'pending' `, idsOf(pending)); upErr != nil { log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr) } if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 { if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` UPDATE refunds SET status = 'failed' WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d `, maxManualRefundAttempts), capIDs); upErr != nil { log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr) } insertRefundFailedNotifications(ctx, capIDs) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. Arrange in-person cash pickup at the salon (a day's notice for // cash on hand). log.Printf("Card refund for charge %s definitively declined by Square — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID) } return 0, nil default: // Ambiguous — Square may or may not have processed. Retried by the // sweep, capped at maxManualRefundAttempts attempts. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = ANY($1) AND status = 'pending' `, idsOf(pending)); upErr != nil { log.Printf("Failed to increment refund attempts (charge %s): %v", chargeID, upErr) } // At the cap, money may have moved at Square despite the ambiguous // responses — reconcile BEFORE marking failed (same bug class as the // age guard). An exact COMPLETED refund resolves to completed. if capIDs := pendingRowsAtAttemptCap(ctx, idsOf(pending)); len(capIDs) > 0 { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, chargeID, totalPence, oldest) switch { case rcErr != nil: // Reconcile failed — unknown whether Square refunded. Leave // rows pending for the next sweep; NEVER mark failed on an // unknown state (that would let the over-refund guard exclude // moved money). But leaving the rows AT the cap strands them: // the sweep only re-picks rows with refund_attempts < // maxManualRefundAttempts, so capped rows are never // re-reconciled and never admin-notified — silently stuck // money (A5c). Re-arm the capped rows under the cap (mirroring // resolveManualRefundAtCap) so the next sweep re-picks them, // and track consecutive reconcile failures: after // maxConsecutiveReconcileFailures consecutive failures a // deduped 'critical_payment_log' admin notification surfaces // the hard-failing reconcile. if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` UPDATE refunds SET refund_attempts = %d WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d `, maxManualRefundAttempts-1, maxManualRefundAttempts), capIDs); upErr != nil { log.Printf("Failed to re-arm capped refunds under the attempt cap after reconcile error (charge %s): %v", chargeID, upErr) } trackReconcileFailureReArm(ctx, capIDs) log.Printf("Reconcile failed for ambiguous charge %s (%v) — re-armed %d refund row(s) under the attempt cap for the next sweep; never marked failed on an unknown state", chargeID, rcErr, len(capIDs)) case sqRefundID != nil: resetReconcileFailureCount(capIDs...) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = ANY($2) AND status = 'pending' `, *sqRefundID, capIDs); upErr != nil { log.Printf("Failed to mark ambiguous refunds completed after Square reconcile (charge %s): %v", chargeID, upErr) } log.Printf("Ambiguous card refunds for charge %s reconciled at Square — COMPLETED refund %s found, marked completed", chargeID, *sqRefundID) default: resetReconcileFailureCount(capIDs...) if _, upErr := db.Conn.Exec(ctx, fmt.Sprintf(` UPDATE refunds SET status = 'failed' WHERE id = ANY($1) AND status = 'pending' AND refund_attempts >= %d `, maxManualRefundAttempts), capIDs); upErr != nil { log.Printf("Failed to mark refunds failed after %d attempts (charge %s): %v", maxManualRefundAttempts, chargeID, upErr) } insertRefundFailedNotifications(ctx, capIDs) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. log.Printf("Card refund for charge %s is AMBIGUOUS (Square may have processed it) and no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", chargeID) } } return 0, nil } } func idsOf(rows []pendingChargeRow) []string { ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) } return ids } // aggRefundKeySuffix returns a deterministic 12-hex-char suffix for a set of // identifiers. The identifiers (CHAR(12) refund ids, or an over-length // square_payment_id) are sorted, sha256'd and truncated, so the SAME input // always yields the SAME suffix. Used to compress an over-length chargeID into // a fixed-width prefix for the charge-level idempotency key (see chargeAggKey). func aggRefundKeySuffix(ids []string) string { sorted := append([]string(nil), ids...) sort.Strings(sorted) h := sha256.Sum256([]byte(strings.Join(sorted, ""))) return fmt.Sprintf("%x", h)[:12] } // chargeAggKey builds the charge-level idempotency key for an aggregated // refund as -square-agg. The key depends ONLY on the charge ID — // NEVER on the set of pending row IDs — so it is stable across sweep runs even // when a new cancellation refund row joins the group (see processChargeGroup). // Square's idempotency-key limit is 45 chars; the verbatim form needs the // chargeID ≤34 chars. square_payment_id is an arbitrary TEXT column holding // Square's real payment ID (typically 20-28 chars), so the verbatim form can // exceed the limit — and a >45-char key is rejected with a 400 // INVALID_REQUEST_ERROR (classified ambiguous → stuck pending forever). When // the verbatim form does not fit, the chargeID is sha256'd into a fixed-width // prefix instead — NEVER truncated verbatim: two charges sharing a truncated // prefix would collide on Square's global key dedup and silently swallow the // second charge's refund (lost money). func chargeAggKey(chargeID string) string { key := chargeID + "-square-agg" if len(key) <= maxIdempotencyKeyLength { return key } return aggRefundKeySuffix([]string{chargeID}) + "-square-agg" } // manualPendingRow is one stale manual refund row eligible for the sweep's // resolution. It covers BOTH pending shapes the RefundPayment handler can // leave behind: // - square_refund_id set: the handler's synchronous-PENDING response (Square // already holds the refund, status='pending') — reconciled, never re-issued. // - square_refund_id NULL: the handler's ambiguous-error path — re-issued // with the row's OWN stored idempotency key. type manualPendingRow struct { ID string PaymentID string // BookingID is the payment's booking_id ("" when NULL = gift-card purchase; // such manual refunds are never re-issued, see processManualPaymentGroup). BookingID string Amount float64 IdempotencyKey string Reason string SquarePaymentID string SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id CreatedAt time.Time } // sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending' // by the RefundPayment handler. The cancellation passes filter // origin='cancellation', so manual rows were never re-attempted: they // permanently blocked the over-refund guard and depressed booking TotalPaid. // Rows are split per-row by their stored square_refund_id: rows WITH one (the // handler's synchronous-PENDING response) are reconciled at Square — never // re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with // their OWN stored idempotency key (Square dedups same-key retries, so the // retry is idempotent). // // A terminal pre-pass first sweeps legacy manual rows whose payment has NO // square_payment_id (pre-dating the handler's square-less guard at // handlers.go:2477-2480). Such rows can never be refunded via Square and would // otherwise stay 'pending' forever, blocking the over-refund guard. They are // marked 'failed' and surfaced in the admin notification centre, mirroring the // Square-less cancellation pre-pass (refunds.go:554-583) with a DISTINCT // origin='manual' filter so the two passes never double-process a row. Rows // WITH a square_refund_id are exempt: a sweep auto-refund of a replay-induced // duplicate charge (B1) attaches its refunds row to the still-pending parent // payment (which has no square_payment_id) — demoting it here would kill an // in-flight refund whose parent Square later settles. // // A B1 re-poll pass runs FIRST (sweepPendingB1Refunds): it re-polls sweep // auto-refunds Square left PENDING by square_refund_id and, ONLY when Square // reports the refund COMPLETED, marks the parent payment/till-sale row failed // and claws back a funded gift card — the duplicate charge has been reversed // and only then is the money state settled. func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { // (a0) B1-origin sweep auto-refunds (refunds rows with a square_refund_id // that Square left PENDING): re-poll each by square_refund_id and // resolve the parent row ONLY on COMPLETED. Runs before the passes // below so an in-flight B1 refund is never demoted or re-issued. b1Count, b1Err := sweepPendingB1Refunds(ctx) if b1Err != nil { log.Printf("Failed to re-poll B1 sweep auto-refunds: %v", b1Err) } // (a) Terminal pre-pass: legacy MANUAL card refunds whose payment has no // Square reference can never be refunded via Square → mark them failed // so they stop blocking the over-refund guard, and surface the affected // booking in the admin notification centre for in-person arrangement. // Mirrors the cancellation Square-less pre-pass (origin='cancellation', // refunds.go:554-583) with origin='manual' so the filters stay distinct // and no row is swept by both passes. Must run OUTSIDE any GROUP BY — // Postgres lumps NULLs together, so these rows can't be handled in the // per-payment grouping below. Rows WITH a square_refund_id are exempt — // a B1 auto-refund attaches to a parent payment with no square_payment_id // and its in-flight refund must not be demoted to failed. rows, err := db.Conn.Query(ctx, fmt.Sprintf(` UPDATE refunds r SET status = 'failed' FROM payments p WHERE p.id = r.payment_id AND r.status = 'pending' AND r.refund_attempts < %d AND p.payment_method IN ('online_square', 'in_person_card') AND p.square_payment_id IS NULL AND r.origin = 'manual' AND r.square_refund_id IS NULL RETURNING r.id `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to mark Square-less manual refunds failed: %v", err) } else { var failedIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { failedIDs = append(failedIDs, id) } } if err := rows.Err(); err != nil { log.Printf("Failed to iterate Square-less manual refunds during sweep: %v", err) } rows.Close() if len(failedIDs) > 0 { log.Printf("Marked %d Square-less manual refund(s) failed (in-person arrangement needed)", len(failedIDs)) } insertRefundFailedNotifications(ctx, failedIDs) } // (b) Manual refunds WITH a Square reference — the rows below are the only // ones the retry/reconcile logic can act on. rows, err = db.Conn.Query(ctx, fmt.Sprintf(` SELECT r.id, r.payment_id, p.booking_id, r.amount, r.idempotency_key, r.reason, p.square_payment_id, r.square_refund_id, r.created_at FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE r.status = 'pending' AND r.origin = 'manual' AND r.refund_attempts < %d AND p.square_payment_id IS NOT NULL ORDER BY r.payment_id, r.id `, maxManualRefundAttempts)) if err != nil { log.Printf("Failed to query manual pending refunds for retry: %v", err) return 0, nil } var pending []manualPendingRow for rows.Next() { var pr manualPendingRow var key *string var sqRefundID *string var bookingID sql.NullString if err := rows.Scan(&pr.ID, &pr.PaymentID, &bookingID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil { log.Printf("Failed to scan manual pending refund: %v", err) continue } pr.BookingID = bookingID.String if key != nil { pr.IdempotencyKey = *key } if sqRefundID != nil { pr.SquareRefundID = *sqRefundID } pending = append(pending, pr) } rows.Close() if len(pending) == 0 { return 0, nil } // Group by payment_id, ascending — matching the lock ordering the manual // handler and processChargeGroup use so the sweep never deadlocks them. groups := make(map[string][]manualPendingRow) var paymentIDs []string for _, pr := range pending { if _, ok := groups[pr.PaymentID]; !ok { paymentIDs = append(paymentIDs, pr.PaymentID) } groups[pr.PaymentID] = append(groups[pr.PaymentID], pr) } sort.Strings(paymentIDs) processed := 0 for _, pid := range paymentIDs { n, err := processManualPaymentGroup(ctx, pid, groups[pid]) if err != nil { log.Printf("Sweep: failed to process manual pending refunds for payment %s: %v", pid, err) continue } processed += n } return processed + b1Count, nil } // b1PendingRefund is one refunds row for a sweep auto-refund of a // replay-induced duplicate charge (B1) that Square left PENDING. type b1PendingRefund struct { RefundID string PaymentID string // refunds.payment_id — the parent payment row Amount float64 SquareRefundID string IdempotencyKey string // deterministic "sweepdup-" + duplicate payment id (payments-table rows) Reason string // carries the parent till_sale id for till_sale rows SquarePaymentID string // synthetic till-sale payment row's square_payment_id ("" for payments-table rows) CreatedBy string } // sweepPendingB1Refunds re-polls refunds rows for sweep auto-refunds of // replay-induced duplicate charges (B1, refundSweepDuplicateCharge in sweep.go) // that Square left PENDING. The refund is NON-terminal: the parent row must // stay pending (never marked failed, a funded gift card never clawed back) // until Square settles. When Square reports the refund COMPLETED the parent is // finally resolved — the pending payment marked failed, and a till sale's // funded gift card clawed back (the duplicate charge has been reversed; only // then is the money state settled). FAILED/REJECTED refunds are left pending // for the webhook FAILED-refund reconciliation (coordinated); a reconcile // error is an UNKNOWN state and is never resolved here. The stale-pending // sweeps skip rows with an in-flight B1 refund (hasInFlightSweepDuplicateRefund, // sweep.go), so the parent is only ever resolved from here. func sweepPendingB1Refunds(ctx context.Context) (int, error) { rows, err := db.Conn.Query(ctx, ` SELECT r.id, r.payment_id, r.amount, r.square_refund_id, COALESCE(r.idempotency_key, ''), r.reason, COALESCE(p.square_payment_id, ''), COALESCE(r.created_by, '') FROM refunds r LEFT JOIN payments p ON p.id = r.payment_id WHERE r.status = 'pending' AND r.square_refund_id IS NOT NULL AND r.origin = 'manual' AND r.reason LIKE 'duplicate charge — sweep replay%' ORDER BY r.id `) if err != nil { log.Printf("Failed to query B1 sweep auto-refunds for re-poll: %v", err) return 0, nil } defer rows.Close() var pending []b1PendingRefund for rows.Next() { var pr b1PendingRefund if err := rows.Scan(&pr.RefundID, &pr.PaymentID, &pr.Amount, &pr.SquareRefundID, &pr.IdempotencyKey, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedBy); err != nil { log.Printf("Failed to scan B1 sweep auto-refund: %v", err) continue } pending = append(pending, pr) } if err := rows.Err(); err != nil { log.Printf("Failed to iterate B1 sweep auto-refunds: %v", err) return 0, nil } if len(pending) == 0 { return 0, nil } processed := 0 for i := range pending { pr := &pending[i] // The Square payment id the refund targets. A till_sale's refund is // attached to the synthetic payments row (square_payment_id = the // duplicate charge); a payments-table refund is attached to the // still-pending payment row (no square_payment_id), so the duplicate id // is recovered from the deterministic refund idempotency key. dupPayID := pr.SquarePaymentID if dupPayID == "" { dupPayID = strings.TrimPrefix(pr.IdempotencyKey, "sweepdup-") if dupPayID == pr.IdempotencyKey { log.Printf("Cannot re-poll B1 refund %s: no Square payment id and the stored key %q is not the sweepdup form — leaving pending", pr.RefundID, pr.IdempotencyKey) continue } } refundList, lErr := SquareClient.ListPaymentRefunds(ctx, dupPayID, time.Time{}) if lErr != nil { log.Printf("Re-poll of B1 refund %s failed (%v) — leaving pending", pr.RefundID, lErr) continue } status := "" for j := range refundList { if refundList[j].ID == pr.SquareRefundID { status = refundList[j].Status break } } switch status { case "COMPLETED": if resolveB1RefundCompleted(ctx, pr) { processed++ } case "PENDING", "APPROVED": log.Printf("B1 refund %s is still %q at Square — leaving the parent row pending", pr.RefundID, status) case "": log.Printf("B1 refund %s (%s) was not found at Square via ListPaymentRefunds — leaving pending; manual reconciliation may be required", pr.RefundID, dupPayID) default: // FAILED / REJECTED — the webhook FAILED-refund reconciliation // (coordinated) owns this terminal state; never resolve it here. log.Printf("B1 refund %s is %q at Square — leaving pending for the webhook FAILED-refund reconciliation", pr.RefundID, status) } } return processed, nil } // resolveB1RefundCompleted resolves a B1 sweep auto-refund that Square reports // COMPLETED: the refunds row is marked completed (money moved), and the parent // row — the pending payment, or the pending till sale whose gift-card funding // is clawed back — is resolved to failed. Returns true when the parent was // resolved. func resolveB1RefundCompleted(ctx context.Context, pr *b1PendingRefund) bool { if _, err := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed' WHERE id = $1 AND status = 'pending' `, pr.RefundID); err != nil { log.Printf("Failed to mark B1 refund %s completed after Square settle: %v", pr.RefundID, err) } if pr.Reason == sweepDuplicateRefundReason { // payments-table parent: the refund's payment_id IS the pending row. if failStaleRow(ctx, "payments", pr.PaymentID) { log.Printf("B1 refund %s COMPLETED at Square — marked the pending payment %s failed (the duplicate charge was reversed)", pr.RefundID, pr.PaymentID) return true } log.Printf("B1 refund %s COMPLETED at Square but payment %s was already resolved", pr.RefundID, pr.PaymentID) return false } // till_sale parent: the sale id is encoded in the reason. if idx := strings.Index(pr.Reason, "(till_sale "); idx >= 0 { tillSaleID := strings.TrimSuffix(pr.Reason[idx+len("(till_sale "):], ")") ts, ok := loadTillSaleStaleRow(ctx, tillSaleID) if !ok { log.Printf("CRITICAL: B1 refund %s COMPLETED at Square but parent till sale %s could not be loaded — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID) return false } if ts.HasGiftCard { if clawbackTillSaleFunding(ctx, ts) { log.Printf("B1 refund %s COMPLETED at Square — clawed back till sale %s's funded gift card and marked it failed", pr.RefundID, tillSaleID) return true } log.Printf("CRITICAL: B1 refund %s COMPLETED at Square but clawing back till sale %s's funding failed — MANUAL RECONCILIATION REQUIRED: gift card may still be funded", pr.RefundID, tillSaleID) return false } if failStaleRow(ctx, "till_sales", tillSaleID) { log.Printf("B1 refund %s COMPLETED at Square — marked till sale %s failed", pr.RefundID, tillSaleID) return true } log.Printf("B1 refund %s COMPLETED at Square but till sale %s was already resolved", pr.RefundID, tillSaleID) return false } log.Printf("B1 refund %s COMPLETED at Square but the parent row could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", pr.RefundID, pr.Reason) return false } // loadTillSaleStaleRow reads a till_sale's gift-card context for the B1 // re-poll pass's clawback — the same fields fetchStaleRows/scanStaleRow // populate for the stale-pending sweep. func loadTillSaleStaleRow(ctx context.Context, id string) (staleRow, bool) { var r staleRow var itemID, redeemedBy sql.NullString var isCreate *bool var hasGiftCard bool var total float64 err := db.Conn.QueryRow(ctx, ` SELECT ts.id, ts.item_id, gc.redeemed_by, (ts.created_at = gc.created_at) AS is_create, (gc.id IS NOT NULL) AS has_gift_card, ts.total_amount FROM till_sales ts LEFT JOIN gift_cards gc ON gc.id = ts.item_id WHERE ts.id = $1 `, id).Scan(&r.ID, &itemID, &redeemedBy, &isCreate, &hasGiftCard, &total) if err != nil { log.Printf("Failed to load till sale %s for B1 refund clawback: %v", id, err) return r, false } r.ItemID = itemID.String if redeemedBy.Valid && redeemedBy.String != "" { r.RedeemToUserID = &redeemedBy.String } r.IsCreate = isCreate != nil && *isCreate r.HasGiftCard = hasGiftCard r.TotalAmount = total r.AmountPence = int64(math.Round(total * 100)) return r, true } // ensureRefundKey returns the idempotency key to use when re-issuing a manual // refund, persisting a generated fallback to the refunds row BEFORE Square is // called so every retry reuses the SAME key — Square dedups same-key retries, // so a lost-response retry can never issue a SECOND refund. // // A legacy refund row with a NULL idempotency_key used to get a fresh random // suffix on every resume; if the Square call succeeded but the follow-up DB // UPDATE to 'completed' failed (the CRITICAL-log path), the row stayed // 'pending' with its key STILL NULL and the next resume generated a NEW random // key → a second Square refund (double refund). Persisting the key first // closes that hole: a crash-retry re-reads the persisted key and reuses it. // // The `AND idempotency_key IS NULL` guard makes the persist race-safe: only // one concurrent caller wins the UPDATE; a loser (0 rows affected) re-reads // and returns the winner's key. The generated shape (paymentID + "-refund-" + // amount + "-" + 12 hex chars) stays ≤45 chars: 12 + 8 + up-to-9 + 1 + 12 ≈ 42. func ensureRefundKey(ctx context.Context, refundID, paymentID string, amount int64, storedKey string) (string, error) { if storedKey != "" { return storedKey, nil } key := paymentID + "-refund-" + strconv.FormatInt(amount, 10) + "-" + randomHexSuffix(6) tag, err := db.Conn.Exec(ctx, ` UPDATE refunds SET idempotency_key = $1 WHERE id = $2 AND idempotency_key IS NULL `, key, refundID) if err != nil { return "", fmt.Errorf("failed to persist refund idempotency key for %s: %w", refundID, err) } if tag.RowsAffected() == 0 { var existing string if err := db.Conn.QueryRow(ctx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&existing); err != nil { return "", fmt.Errorf("failed to re-read persisted refund idempotency key for %s: %w", refundID, err) } return existing, nil } return key, nil } // processManualPaymentGroup retries one payment's stale manual pending refunds // under the SAME per-payment advisory lock the manual RefundPayment handler // holds across its guard read — so a re-issued refund can never double-spend // against a concurrent manual refund. Rows are re-read under the lock; each is // issued to Square with its OWN stored idempotency key and stored reason. func processManualPaymentGroup(ctx context.Context, paymentID string, rows []manualPendingRow) (int, error) { pinConn, err := db.Conn.Acquire(ctx) if err != nil { return 0, err } defer pinConn.Release() // Bounded try-lock (R6) so the sweep never blocks a pool connection while // the manual handler holds the same key across its Square call. lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:refund:"+paymentID) if err != nil { return 0, err } if !lockOK { log.Printf("Refund lock for payment %s not acquired within bound — a manual refund is in progress; leaving rows pending for the next sweep", paymentID) return 0, nil } defer releasePaymentLock(pinConn, "crussell:refund:"+paymentID) ids := make([]string, 0, len(rows)) for _, r := range rows { ids = append(ids, r.ID) } // Re-read under the lock — only rows still pending and under the attempt // cap are eligible (a concurrent manual refund may have resolved some). prRows, err := db.Conn.Query(ctx, fmt.Sprintf(` SELECT r.id, p.booking_id, r.amount, r.idempotency_key, r.reason, r.created_at, r.payment_id, p.square_payment_id, r.square_refund_id FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < %d ORDER BY r.id `, maxManualRefundAttempts), ids) if err != nil { return 0, err } var pending []manualPendingRow for prRows.Next() { var pr manualPendingRow var key *string var sqRefundID *string var bookingID sql.NullString if err := prRows.Scan(&pr.ID, &bookingID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil { log.Printf("Failed to scan manual pending refund under lock: %v", err) continue } pr.BookingID = bookingID.String if key != nil { pr.IdempotencyKey = *key } if sqRefundID != nil { pr.SquareRefundID = *sqRefundID } pending = append(pending, pr) } prRows.Close() if len(pending) == 0 { return 0, nil } // Age guard (mirrors processChargeGroup): Square's idempotency-key // retention is finite (~24h). Reconcile FIRST — an exact COMPLETED refund // at Square resolves to completed even though our rows are stale. oldest := pending[0].CreatedAt for _, pr := range pending[1:] { if pr.CreatedAt.Before(oldest) { oldest = pr.CreatedAt } } if clock.Now().Sub(oldest) > stalePendingRefundAge { processedAged := 0 for i := range pending { pr := &pending[i] amountPence := int64(math.Round(pr.Amount * 100)) sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt) switch { case rcErr != nil: // Reconcile failed — unknown whether Square refunded. Leave // this row pending for the next sweep; NEVER mark failed on an // unknown state. B18: track consecutive reconcile failures and // surface the hard-failing reconcile in the admin notification // centre (mirrors resolveManualRefundAtCap) so the row does not // oscillate here silently forever. trackReconcileFailureReArm(ctx, []string{pr.ID}) log.Printf("Reconcile failed for aged manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr) case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2 AND status = 'pending' `, *sqRefundID, pr.ID); upErr != nil { log.Printf("Failed to mark aged manual refund %s completed after Square reconcile: %v", pr.ID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(pr.ID) processedAged++ default: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { log.Printf("Failed to mark aged manual refund %s failed: %v", pr.ID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(pr.ID) insertRefundFailedNotifications(ctx, []string{pr.ID}) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. log.Printf("Manual refund %s is older than stalePendingRefundAge (23h) and Square shows no COMPLETED refund — marked 'failed' and admin notified; TODO email user+admin to arrange in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID) } } return processedAged, nil } processed := 0 for i := range pending { pr := &pending[i] amountPence := int64(math.Round(pr.Amount * 100)) // A payment with NO booking is a gift-card purchase (BuyGiftCard // inserts without a booking) — the handler rejects these outright, so a // pending row that predates that guard must never be re-issued here // (the sweep is a bypass of the handler guard). Reconcile only, // exactly like the square_refund_id branch: an exact COMPLETED refund // resolves to completed (money already moved); a no-match means Square // never refunded — mark failed + admin-notified so the amount unblocks // the over-refund guard and the customer is handled via the gift-card // section. if pr.BookingID == "" { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt) switch { case rcErr != nil: log.Printf("Reconcile failed for gift-card-purchase manual refund %s (%v) — leaving pending for the next sweep", pr.ID, rcErr) case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2 AND status = 'pending' `, *sqRefundID, pr.ID); upErr != nil { log.Printf("Failed to mark gift-card-purchase manual refund %s completed after Square reconcile: %v", pr.ID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(pr.ID) processed++ default: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { log.Printf("Failed to mark gift-card-purchase manual refund %s failed: %v", pr.ID, upErr) } // A5: terminal resolution — clear the consecutive-failure counter. resetReconcileFailureCount(pr.ID) insertRefundFailedNotifications(ctx, []string{pr.ID}) log.Printf("Gift-card-purchase manual refund %s (payment %s) blocked — payment has no booking; Square shows no COMPLETED refund — marked failed, customer must be refunded via the gift-card section", pr.ID, pr.PaymentID) } continue } // Row WITH a stored square_refund_id — the RefundPayment handler's // synchronous-PENDING response. Square already holds the refund, so a // re-issue would risk a SECOND refund (Square's key dedup does not // protect a fresh key). Reconcile instead: an exact COMPLETED refund at // Square resolves the row; a genuine no-match means Square never // recorded it → failed + admin notification; a reconcile error is an // UNKNOWN state → leave pending (never mark failed on an unknown state, // that would let the over-refund guard exclude money that may have // moved). Mirrors the stalePendingRefundAge age-guard branch above. if pr.SquareRefundID != "" { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt) switch { case rcErr != nil: log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr) case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2 AND status = 'pending' `, *sqRefundID, pr.ID); upErr != nil { log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr) } processed++ default: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { log.Printf("Failed to mark manual refund %s failed after Square reconcile showed no refund: %v", pr.ID, upErr) } insertRefundFailedNotifications(ctx, []string{pr.ID}) log.Printf("Manual refund %s (square_refund_id %s) has no COMPLETED refund at Square — marked 'failed' and admin notified; TODO email user+admin to VERIFY the Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, pr.SquareRefundID) } continue } // Route the stored key through ensureRefundKey: a legacy row with a // NULL idempotency_key must NOT reach Square with "" (a 400 // INVALID_REQUEST_ERROR, classified ambiguous → stuck forever). The // helper persists a generated fallback to the row first, so every retry // reuses the SAME key — a lost-response retry can never issue a second // refund (Square dedups same-key retries). idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountPence, pr.IdempotencyKey) if keyErr != nil { // The key could not be persisted — Square must not be called with an // empty/unknown key. Leave the row pending for the next sweep (never // mark failed on an unknown state); the stalePendingRefundAge age // guard above will eventually reconcile it. log.Printf("Failed to ensure refund key for manual refund %s before re-issue: %v", pr.ID, keyErr) continue } sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: pr.SquarePaymentID, Amount: amountPence, IdempotencyKey: idemKey, Reason: pr.Reason, }) // REFUND_AMOUNT_INVALID is Square's ambiguous already-refunded-or- // invalid-amount signal. Disambiguate with the EXACT amount (A11): only // an exact-amount COMPLETED refund at Square proves THIS amount already // moved — a smaller partial refund does NOT, and completing the row // would claim the full amount was refunded when only part of it was. // Mirrors the processChargeGroup disambiguation so the reconciliation // applies whether the client classified the code as a definitive // decline or as already-processed. if isRefundAmountInvalid(sqErr) { sqRefundID, rcErr := reconcileRefundAtSquareExact(ctx, pr.SquarePaymentID, amountPence) switch { case rcErr != nil: // Reconcile failed — unknown money state. Keep the client's // own classification below (the switch on sqErr) rather than // making a money decision on partial data. case sqRefundID != nil: if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed' WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { log.Printf("Failed to resolve manual refund %s completed after REFUND_AMOUNT_INVALID on already-refunded payment: %v", pr.ID, upErr) } log.Printf("Manual refund %s: payment already refunded at Square (REFUND_AMOUNT_INVALID + exact-amount COMPLETED refund %s) — marked completed, no admin notification", pr.ID, *sqRefundID) processed++ continue default: // No exact-amount COMPLETED refund exists — REFUND_AMOUNT_INVALID // is a genuine decline/invalid amount here (the attempt would // have over-refunded a partially-refunded payment), NOT an // already-refunded signal. Route through the decline branch. sqErr = square.ErrRefundDeclined } } switch { case sqErr == nil: 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 >= maxManualRefundAttempts { resolveManualRefundAtCap(ctx, pr, amountPence) } default: // Ambiguous — Square may or may not have processed. Retried by the // sweep, capped at maxManualRefundAttempts. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = refund_attempts + 1 WHERE id = $1 `, pr.ID); upErr != nil { log.Printf("Failed to increment attempts for manual refund %s: %v", pr.ID, upErr) } if attempts := currentRefundAttempts(ctx, pr.ID); attempts >= maxManualRefundAttempts { resolveManualRefundAtCap(ctx, pr, amountPence) } } } return processed, nil } // resolveManualRefundAtCap reconciles a manual refund row that just hit the // maxManualRefundAttempts cap: money may have moved at Square despite // decline/ambiguous responses, so reconcile FIRST — an exact COMPLETED refund // resolves the row to completed; otherwise mark failed and notify the admin. // The row is never re-issued here (reconcile is a read), so the cap cannot // cause a double refund. func resolveManualRefundAtCap(ctx context.Context, pr *manualPendingRow, amountPence int64) { sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountPence, pr.CreatedAt) switch { case rcErr != nil: // Reconcile failed — unknown whether Square refunded. NEVER mark failed // on an unknown state (that would let the over-refund guard exclude // moved money). Re-arm the row under the cap so the next sweep re-picks // it, and track consecutive reconcile failures: after // maxConsecutiveReconcileFailures consecutive failures a deduped // 'critical_payment_log' admin notification surfaces the hard-failing // reconcile — never silently forever (A5b). Re-issue stays safe: the // row's idempotency key is persisted (ensureRefundKey), so Square // dedups a same-key retry to the original refund — and once the row // crosses stalePendingRefundAge the sweep's age guard reconciles it // instead of re-issuing. if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET refund_attempts = $1 WHERE id = $2 AND status = 'pending' AND refund_attempts >= $3 `, maxManualRefundAttempts-1, pr.ID, maxManualRefundAttempts); upErr != nil { log.Printf("Failed to re-arm manual refund %s under the attempt cap after reconcile error: %v", pr.ID, upErr) } trackReconcileFailureReArm(ctx, []string{pr.ID}) log.Printf("Reconcile failed for manual refund %s at attempt cap (%v) — re-armed under the cap for the next sweep; never marked failed on an unknown state", pr.ID, rcErr) case sqRefundID != nil: resetReconcileFailureCount(pr.ID) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2 AND status = 'pending' `, *sqRefundID, pr.ID); upErr != nil { log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr) } default: resetReconcileFailureCount(pr.ID) if _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET status = 'failed' WHERE id = $1 AND status = 'pending' `, pr.ID); upErr != nil { log.Printf("Failed to mark manual refund %s failed after %d attempts: %v", pr.ID, maxManualRefundAttempts, upErr) } insertRefundFailedNotifications(ctx, []string{pr.ID}) // TODO (P6 email/SMS delivery): notify user AND admin when the email/SMS // system lands; until then the admin_notifications row above is the only // channel. log.Printf("Manual refund %s reached %d attempts with no COMPLETED refund found at Square — marked 'failed' and admin notified; TODO email user+admin, VERIFY Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, maxManualRefundAttempts) } } func currentRefundAttempts(ctx context.Context, refundID string) int { var n int if err := db.Conn.QueryRow(ctx, `SELECT refund_attempts FROM refunds WHERE id = $1`, refundID).Scan(&n); err != nil { log.Printf("Failed to read refund_attempts for %s: %v", refundID, err) } return n }