Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment): - Serialize cancellation refunds against the manual handler via per-payment advisory locks taken before the prior-refunds read (pg_advisory_xact_lock, ascending, same crussell:refund: key space) - Aggregate pending cancellation refunds into ONE Square refund per charge (stable charge-level -square-agg key); atomic group UPDATE keeps crash-retry amounts identical for Square key-dedup - Persist paymentID-square-amount idempotency keys on cancellation refunds; scheduler reads the stored key (legacy fallback for old rows) - Add sweep-pending-square-refunds cron (*/5, concurrency 1) with refund_attempts cap; sweep retries stale manual pending refunds with each row's own stored idempotency key - Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every terminal failed transition: tri-state result leaves rows pending on reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED resolves to completed - Move over-refund guard inside the lock, counting completed + pending (excluding failed); ErrRefundDeclined distinguishes definitive vs ambiguous outcomes - forgiveFees now executes a real full refund (forceFullRefund override) with admin_forgiven_fees reason threaded to Square - Surface failed card refunds in the admin notification centre (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup) - Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key) DO NOTHING without consuming refundRemaining Frontend: - Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token in request bodies; gate new-card entry behind CardEntryUnavailable notice + newCardDisabled prop across all 8 flows - Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI and CardEntryUnavailable fallback - Update cancellation-policy page to in-person cash pickup wording Tests: - Rewrite the two amount-blind dedup tests to assert real money movement (single call, aggregated amount, shared refund ID) - Add coverage: manual refund vs cancellation serialization (concurrent goroutines), reconcile error vs no-match branches, stale manual retry, forgive-fees real refund row + reason, double-cancel dedup, mock refund key dedup, ListPaymentRefunds filtering - Fix time-dependent booking flakes with fixtures.NextWorkingDayAt - 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
This commit is contained in:
@@ -65,6 +65,8 @@ type RefundRecord struct {
|
||||
SquareRefundID *string
|
||||
Status string
|
||||
Reason string
|
||||
Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop)
|
||||
IdempotencyKey *string
|
||||
CreatedBy *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -154,8 +156,8 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
|
||||
var id string
|
||||
err := db.Conn.QueryRow(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)
|
||||
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)
|
||||
RETURNING id
|
||||
`,
|
||||
record.PaymentID,
|
||||
@@ -164,8 +166,10 @@ func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRe
|
||||
record.SquareRefundID,
|
||||
record.Status,
|
||||
record.Reason,
|
||||
record.IdempotencyKey,
|
||||
record.CreatedBy,
|
||||
record.CreatedAt,
|
||||
record.Origin,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
@@ -239,7 +243,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
summary.TotalNetAmount = totalNetAmount
|
||||
|
||||
refundRows, err := db.Conn.Query(ctx, `
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
|
||||
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
|
||||
FROM refunds
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
ORDER BY created_at ASC
|
||||
@@ -255,7 +259,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
|
||||
var r RefundRecord
|
||||
err := refundRows.Scan(
|
||||
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
|
||||
&r.Status, &r.Reason, &r.CreatedBy, &r.CreatedAt,
|
||||
&r.Status, &r.Reason, &r.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -348,11 +352,17 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a
|
||||
// payment, counting both 'completed' and 'pending' refunds. Pending refunds are
|
||||
// counted because a Square call may already be in flight for them — excluding
|
||||
// them would let a concurrent refund over-refund the payment. 'failed' refunds
|
||||
// are excluded: they were definitively rejected by Square and must not block
|
||||
// future refund attempts.
|
||||
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
|
||||
var amount float64
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(amount), 0) FROM refunds
|
||||
WHERE payment_id = $1 AND status = 'completed'
|
||||
WHERE payment_id = $1 AND status IN ('completed', 'pending')
|
||||
`, paymentID).Scan(&amount)
|
||||
|
||||
if err != nil {
|
||||
@@ -391,18 +401,27 @@ type BookingPaymentInfo struct {
|
||||
}
|
||||
|
||||
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
|
||||
// total completed payments for a booking.
|
||||
// total net paid (completed payments minus completed/pending refunds) for a
|
||||
// booking. Refunds are subtracted so cancellation refunds never double-refund
|
||||
// money that has already been returned (e.g. via a manual admin refund).
|
||||
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
|
||||
var info BookingPaymentInfo
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT b.start_time, b.status,
|
||||
COALESCE(b.total_amount, 0),
|
||||
COALESCE(pt.total_paid, 0)
|
||||
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
|
||||
FROM bookings b
|
||||
LEFT JOIN (
|
||||
SELECT booking_id, SUM(amount) AS total_paid
|
||||
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
|
||||
) pt ON b.id = pt.booking_id
|
||||
LEFT JOIN (
|
||||
SELECT p.booking_id, SUM(r.amount) AS total_refunded
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
|
||||
GROUP BY p.booking_id
|
||||
) rr ON b.id = rr.booking_id
|
||||
WHERE b.id = $1
|
||||
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user