package payments import ( "context" "log" "log/slog" "math" "time" "crussell/db" "crussell/clock" "crussell/internal/square" "github.com/jackc/pgx/v5" ) type RefundCalculationResult struct { TotalPrePaid float64 `json:"total_pre_paid"` ProtectedDeposit float64 `json:"protected_deposit"` RefundableAmount float64 `json:"refundable_amount"` KeptAmount float64 `json:"kept_amount"` HoursUntilAppointment float64 `json:"hours_until_appointment"` Tier string `json:"tier"` } // CalculateRefundForCancellation computes the refund amounts for a cancelled booking. // // Track A (universal — same rules for all bookings): // - >72 hours notice: Full refund of all pre-payments // - 24-72 hours notice: Keep protected deposit (up to 50%), refund the rest // - <24 hours or no-show: Keep all pre-payments // // The "protected deposit" is defined as min(totalPrePaid, subtotal * 0.50). // This means up to 50% of the subtotal is always treated as a deposit for // refund purposes, regardless of whether deposit_required was set on the booking. func CalculateRefundForCancellation( subtotal float64, totalPrePaid float64, cancellationTime time.Time, startTime time.Time, ) RefundCalculationResult { hoursUntilAppointment := startTime.Sub(cancellationTime).Hours() protectedDeposit := math.Min(totalPrePaid, subtotal*ProtectedDepositMaxPct) // Round to 2 decimal places protectedDeposit = math.Round(protectedDeposit*100) / 100 totalPrePaidRounded := math.Round(totalPrePaid*100) / 100 var refundableAmount, keptAmount float64 var tier string fullHrs := FullRefundThreshold.Hours() partHrs := PartialRefundThreshold.Hours() switch { case hoursUntilAppointment > fullHrs: refundableAmount = totalPrePaidRounded keptAmount = 0 tier = FullRefundTier case hoursUntilAppointment >= partHrs: keptAmount = protectedDeposit refundableAmount = totalPrePaidRounded - keptAmount if refundableAmount < 0 { refundableAmount = 0 } tier = PartialRefundTier default: keptAmount = totalPrePaidRounded refundableAmount = 0 tier = NoRefundTier } return RefundCalculationResult{ TotalPrePaid: totalPrePaidRounded, ProtectedDeposit: protectedDeposit, RefundableAmount: refundableAmount, KeptAmount: keptAmount, HoursUntilAppointment: hoursUntilAppointment, Tier: tier, } } // ProcessCancellationRefund calculates and records refunds for a cancelled booking. // It processes refunds against completed payments on the booking up to the // calculated refundable amount, creating refund records in the database. // Returns the refund calculation and whether any refunds were processed. // 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. func ProcessCancellationRefundTx( ctx context.Context, tx pgx.Tx, 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 } // Get the booking's user info for refund routing. var bookingUserID string var isGuest bool if err := tx.QueryRow(ctx, ` SELECT b.user_id, COALESCE(u.account_role = 'guest', false) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = $1 `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { log.Printf("Failed to get booking user info for refund: %v", err) // Non-fatal — we'll still process Square refunds but skip balance credits. } rows, err := tx.Query(ctx, ` SELECT id, amount, payment_method, square_payment_id, gift_card_id FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for refund: %v", err) return &calc, nil } defer rows.Close() // Read all payments into a slice, then close rows immediately. type paymentRow struct { ID string Amount float64 PaymentMethod string SquarePaymentID *string GiftCardID *string } 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) } refundRemaining := calc.RefundableAmount for _, p := range payments { if refundRemaining <= 0 { break } paymentID := p.ID paymentMethod := p.PaymentMethod amount := p.Amount giftCardID := p.GiftCardID refundThisPayment := math.Min(amount, refundRemaining) var squareRefundID *string switch paymentMethod { case "online_square", "in_person_card": // Square API refund is processed AFTER the transaction commits // (see ProcessPendingSquareRefunds). Inside the tx we only record // the refund record as "pending" for post-commit processing. if isGuest || bookingUserID == "" { log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment) } // squareRefundID stays nil — will be set by ProcessPendingSquareRefunds case "giftcard": if giftCardID == nil || *giftCardID == "" { log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID) break } var expired bool if err := tx.QueryRow(ctx, ` SELECT expiry_date IS NOT NULL AND expiry_date < NOW() FROM gift_cards WHERE id = $1 `, *giftCardID).Scan(&expired); err != nil { log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) } else if expired { log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) break } if _, err := tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW() WHERE id = $2 `, refundThisPayment, *giftCardID); err != nil { log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err) break } if _, err := tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { log.Printf("Failed to create gift card transaction for refund: %v", err) } case "cash": if isGuest || bookingUserID == "" { log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) } else { log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) if _, balErr := tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, bookingUserID, refundThisPayment); balErr != nil { log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) } } default: log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) } recordStatus := "completed" if paymentMethod == "online_square" || paymentMethod == "in_person_card" { recordStatus = "pending" } record := RefundRecord{ PaymentID: paymentID, BookingID: bookingID, Amount: refundThisPayment, SquareRefundID: squareRefundID, Status: recordStatus, Reason: reason, CreatedBy: actorID, CreatedAt: clock.Now(), } _, dbErr := tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.CreatedBy, record.CreatedAt) if dbErr != nil { log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) continue } refundRemaining -= refundThisPayment } if bookingUserID != "" { var loyaltyUsed bool if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil { log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err) } else if loyaltyUsed { _, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) if loyaltyErr != nil { log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr) } else { log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) } } } return &calc, nil } func ProcessCancellationRefund( ctx context.Context, bookingID string, subtotal float64, totalPrePaid float64, startTime time.Time, cancellationTime time.Time, reason string, actorID *string, ) (*RefundCalculationResult, error) { calc := CalculateRefundForCancellation(subtotal, totalPrePaid, cancellationTime, startTime) if calc.RefundableAmount <= 0 { return &calc, nil } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction for cancellation refund: %v", err) return &calc, nil } defer func() { if err := tx.Rollback(ctx); err != nil && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() // Get the booking's user info for refund routing. var bookingUserID string var isGuest bool if err := tx.QueryRow(ctx, ` SELECT b.user_id, COALESCE(u.account_role = 'guest', false) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id = $1 `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { log.Printf("Failed to get booking user info for refund: %v", err) // Non-fatal — we'll still process Square refunds but skip balance credits. } rows, err := tx.Query(ctx, ` SELECT id, amount, payment_method, square_payment_id, gift_card_id FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC `, bookingID) if err != nil { log.Printf("Failed to fetch payments for refund: %v", err) return &calc, nil } defer rows.Close() // Read all payments into a slice, then close rows immediately. // This avoids "conn busy" errors when db.Conn.QueryRow/Exec are called // inside the processing loop with a per-test transaction (pgx.Tx does not // support concurrent queries on the same connection). type paymentRow struct { ID string Amount float64 PaymentMethod string SquarePaymentID *string GiftCardID *string } 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) } refundRemaining := calc.RefundableAmount for _, p := range payments { if refundRemaining <= 0 { break } paymentID := p.ID paymentMethod := p.PaymentMethod amount := p.Amount giftCardID := p.GiftCardID refundThisPayment := math.Min(amount, refundRemaining) var squareRefundID *string switch paymentMethod { case "online_square", "in_person_card": // Square API refund is processed AFTER the transaction commits // (see ProcessPendingSquareRefunds). Inside the tx we only record // the refund record as "pending" for post-commit processing. if isGuest || bookingUserID == "" { log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment) } // squareRefundID stays nil — will be set by ProcessPendingSquareRefunds case "giftcard": if giftCardID == nil || *giftCardID == "" { log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID) break } var expired bool if err := tx.QueryRow(ctx, ` SELECT expiry_date IS NOT NULL AND expiry_date < NOW() FROM gift_cards WHERE id = $1 `, *giftCardID).Scan(&expired); err != nil { log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) } else if expired { log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) break } if _, err := tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW() WHERE id = $2 `, refundThisPayment, *giftCardID); err != nil { log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err) break } if _, err := tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { log.Printf("Failed to create gift card transaction for refund: %v", err) } case "cash": if isGuest || bookingUserID == "" { log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) } else { log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) if _, balErr := tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, bookingUserID, refundThisPayment); balErr != nil { log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) } } default: // discount, on_the_house — no real money to refund. log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) } recordStatus := "completed" if paymentMethod == "online_square" || paymentMethod == "in_person_card" { recordStatus = "pending" } record := RefundRecord{ PaymentID: paymentID, BookingID: bookingID, Amount: refundThisPayment, SquareRefundID: squareRefundID, Status: recordStatus, Reason: reason, CreatedBy: actorID, CreatedAt: clock.Now(), } _, dbErr := tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.CreatedBy, record.CreatedAt) if dbErr != nil { log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) continue } refundRemaining -= refundThisPayment } if bookingUserID != "" { var loyaltyUsed bool if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil { log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err) } else if loyaltyUsed { _, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) if loyaltyErr != nil { log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr) } else { log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) } } } if cErr := tx.Commit(ctx); cErr != nil { log.Printf("CRITICAL: Failed to commit cancellation refund transaction for booking %s: %v", bookingID, cErr) return &calc, nil } // Process pending Square refunds after the transaction commits successfully. // This ensures Square API calls only happen if the DB records persist. ProcessPendingSquareRefunds(ctx, bookingID, reason) return &calc, nil } // ProcessPendingSquareRefunds queries for refund records where the refund was // inserted as "pending" (Square refund not yet processed) and calls the Square // API to process them. This ensures Square API calls happen AFTER the DB // transaction commits — if the commit fails, no Square money is lost. // // Call this AFTER the enclosing transaction (if any) has been committed. func ProcessPendingSquareRefunds(ctx context.Context, bookingID string, reason string) { rows, err := db.Conn.Query(ctx, ` SELECT r.id, r.amount, p.square_payment_id FROM refunds r JOIN payments p ON r.payment_id = p.id WHERE r.booking_id = $1 AND r.status = 'pending' AND p.payment_method IN ('online_square', 'in_person_card') AND r.square_refund_id IS NULL `, bookingID) if err != nil { log.Printf("Failed to query pending Square refunds for booking %s: %v", bookingID, err) return } defer rows.Close() type pendingRefund struct { ID string Amount float64 SquarePaymentID *string } var pending []pendingRefund for rows.Next() { var pr pendingRefund if err := rows.Scan(&pr.ID, &pr.Amount, &pr.SquarePaymentID); err != nil { log.Printf("Failed to scan pending refund row: %v", err) continue } pending = append(pending, pr) } if err := rows.Err(); err != nil { log.Printf("Pending refund row iteration error: %v", err) } // Deduplicate: multiple split payment records can share the same // square_payment_id — only refund each Square payment once. refundedSquareIDs := make(map[string]bool) for _, pr := range pending { if pr.SquarePaymentID == nil || *pr.SquarePaymentID == "" { // No Square payment ID — mark as completed (no API call needed) _, upErr := db.Conn.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE id = $1`, pr.ID) if upErr != nil { log.Printf("Failed to update refund %s to completed: %v", pr.ID, upErr) } continue } if refundedSquareIDs[*pr.SquarePaymentID] { // Already refunded this Square payment via a previous split record. // Mark this refund as completed since the money is already returned. if _, upErr := db.Conn.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE id = $1`, pr.ID); upErr != nil { log.Printf("Failed to update refund %s to completed (deduped): %v", pr.ID, upErr) } continue } refundCents := int64(math.Round(pr.Amount * 100)) refundReq := square.RefundPaymentReq{ PaymentID: *pr.SquarePaymentID, Amount: refundCents, IdempotencyKey: pr.ID + "-square-" + clock.Now().Format("20060102150405"), Reason: reason, } sqResult, sqErr := SquareClient.RefundPayment(ctx, refundReq) if sqErr != nil { log.Printf("Square refund failed for pending refund %s (payment %s): %v — record left as 'pending' for manual retry", pr.ID, *pr.SquarePaymentID, sqErr) // Leave status as 'pending' — can be retried manually or via admin tool. continue } refundedSquareIDs[*pr.SquarePaymentID] = true _, upErr := db.Conn.Exec(ctx, ` UPDATE refunds SET square_refund_id = $1, status = 'completed' WHERE id = $2 `, sqResult.ID, pr.ID) if upErr != nil { log.Printf("CRITICAL: Square refund succeeded (ID=%s) but DB record %s update failed: %v — manual reconciliation required", sqResult.ID, pr.ID, upErr) } } }