Files
Crussell/backend/handlers/payments/refunds.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

562 lines
20 KiB
Go

package payments
import (
"context"
"log"
"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 tx.Rollback(ctx)
// 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)
}
}
}