Files
Crussell/backend/handlers/payments/refunds.go
T

281 lines
10 KiB
Go

package payments
import (
"context"
"log"
"math"
"time"
"crussell/db"
"crussell/internal/square"
)
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.
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
}
// Get the booking's user info for refund routing.
var bookingUserID string
var isGuest bool
if err := db.DB.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 := db.DB.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()
refundRemaining := calc.RefundableAmount
// Track which Square payment IDs have already been refunded through Square.
// Multiple split payment records (deposit + balance) can share the same
// square_payment_id — we must only refund each Square payment once.
refundedSquareIDs := make(map[string]bool)
for rows.Next() {
if refundRemaining <= 0 {
break
}
var paymentID, paymentMethod string
var amount float64
var squarePaymentID *string
var giftCardID *string
if err := rows.Scan(&paymentID, &amount, &paymentMethod, &squarePaymentID, &giftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
refundThisPayment := math.Min(amount, refundRemaining)
refundCents := int64(math.Round(refundThisPayment * 100))
var squareRefundID *string
switch paymentMethod {
case "online_square", "in_person_card":
// Card payments can be refunded through Square if we have a payment reference.
// Skip the Square API call if this square_payment_id was already processed
// (possible when split payment records share the same charge).
if squarePaymentID != nil && *squarePaymentID != "" && !refundedSquareIDs[*squarePaymentID] {
refundReq := square.RefundPaymentReq{
PaymentID: *squarePaymentID,
Amount: refundCents,
IdempotencyKey: paymentID + "-cancel-" + time.Now().Format("20060102150405"),
Reason: reason,
}
result, sqErr := SquareClient.RefundPayment(ctx, refundReq)
if sqErr != nil {
log.Printf("Square refund failed for payment %s (will record refund locally): %v", paymentID, sqErr)
} else {
squareRefundID = &result.ID
refundedSquareIDs[*squarePaymentID] = true
}
} else if squarePaymentID != nil && refundedSquareIDs[*squarePaymentID] {
log.Printf("Square payment %s already refunded through split record %s — crediting balance for £%.2f", *squarePaymentID, paymentID, refundThisPayment)
}
// If Square refund failed or wasn't available, credit the user's balance.
// Guests don't get balance credits — admin handles those manually.
if squareRefundID == nil && bookingUserID != "" {
if isGuest {
log.Printf("Guest card refund (Square unavailable): booking %s, payment %s, amount £%.2f — admin must process at till", bookingID, paymentID, refundThisPayment)
} else {
log.Printf("Crediting £%.2f to user %s balance for card payment %s (Square refund unavailable)", refundThisPayment, bookingUserID, paymentID)
creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason)
}
}
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 := db.DB.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 := db.DB.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 := db.DB.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)
creditUserBalance(ctx, bookingUserID, bookingID, paymentID, refundThisPayment, reason)
}
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)
}
record := RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: refundThisPayment,
SquareRefundID: squareRefundID,
Status: "completed",
Reason: reason,
CreatedBy: actorID,
CreatedAt: time.Now(),
}
_, dbErr := db.DB.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at)
VALUES ($1, $2, $3, $4, 'completed', $5, $6, $7)
`, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, 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
db.DB.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed)
if loyaltyUsed {
_, err := db.DB.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if err != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err)
} else {
log.Printf("Refunded 10 loyalty stamps to user %s after cancellation of booking %s", bookingUserID, bookingID)
}
}
}
return &calc, nil
}
// creditUserBalance credits a refund amount to the user's gift card balance.
// The refunds table (payment_id, booking_id, amount, reason, created_by, created_at)
// provides the primary audit trail for FreeAgent reconciliation.
func creditUserBalance(ctx context.Context, userID, bookingID, paymentID string, amount float64, reason string) {
_, err := db.DB.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()
`, userID, amount)
if err != nil {
log.Printf("Failed to credit user %s balance for refund of booking %s: %v", userID, bookingID, err)
}
}