Backend: - Fix refund idempotency key: clock.Now() → deterministic (pr.ID + amount) - Fix ValidateCardInfo: enforce mutual exclusivity, handle empty strings symmetrically - Fix paymentFromSquare brand fallback (remove dead SourceType fallback) - Fix URL encoding: PathEscape → QueryEscape for customer_id query param - Fix BuyerEmail: log warning on DB error instead of silent discard - Fix idempotency key in createCardOnFileHTTP: time.Now() → deterministic hex hash - Add BuyerEmail to CreateTipPayment Square request - Move realBaseURL from shared file to square_dev.go (only used in dev) - Add TestTipPayment_WithSavedCard test (card_id path coverage) - Fix AMEX brand in mock (AMEX → AMERICAN_EXPRESS, fix test) Frontend: - Fix off-by-month expiry bug in ALL 8 files using year-month arithmetic (parseExpiryParts returns 1-indexed, SvelteDate expects 0-indexed) Files: tip/+page, pay-tip/[id], UserBookingModal, UserPaymentModal, BookingFlow, account/+page (add card + buy gift card sections) - Remove card_expiry/card_cvc from tip request bodies (backend has no fields) Docs: - Mark P9 (placeholder tokens) as completed, add P11 (Square Web Payments SDK) - Mark T13 (rune arithmetic) as completed
569 lines
20 KiB
Go
569 lines
20 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type RefundCalculationResult struct {
|
|
TotalPrePaid float64 `json:"total_pre_paid"`
|
|
ProtectedDeposit float64 `json:"protected_deposit"`
|
|
RefundableAmount float64 `json:"refundable_amount"`
|
|
KeptAmount float64 `json:"kept_amount"`
|
|
HoursUntilAppointment float64 `json:"hours_until_appointment"`
|
|
Tier string `json:"tier"`
|
|
}
|
|
|
|
// 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 && !errors.Is(err, pgx.ErrTxClosed) {
|
|
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))
|
|
// Deterministic idempotency key derived from refund record ID and amount,
|
|
// so retries (network timeout, proxy, etc.) don't create duplicate Square refunds.
|
|
refundReq := square.RefundPaymentReq{
|
|
PaymentID: *pr.SquarePaymentID,
|
|
Amount: refundCents,
|
|
IdempotencyKey: pr.ID + "-square-" + strconv.FormatInt(refundCents, 10),
|
|
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)
|
|
}
|
|
}
|
|
}
|