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:
2026-08-22 00:34:49 +01:00
parent 54f6bf3c1a
commit ae8735ba2f
33 changed files with 4129 additions and 1868 deletions
@@ -678,3 +678,134 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
t.Error("expected card_last4 to be set")
}
}
// pollCheckoutStatus polls GetCheckoutStatus until the checkout reports
// COMPLETED, returning the decoded response.
func pollCheckoutStatus(t *testing.T, ctx context.Context, checkoutID, bookingID, adminToken string) PaymentStatusResponse {
t.Helper()
var resp PaymentStatusResponse
assert.Eventually(t, func() bool {
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+checkoutID+"/status?booking_id="+bookingID, nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", checkoutID)
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w2 := httptest.NewRecorder()
GetCheckoutStatus(w2, statusReq)
if w2.Code != http.StatusOK {
return false
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
return false
}
return resp.Status == "COMPLETED"
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
return resp
}
// createTerminalCheckout creates a terminal checkout via CreateTerminalPayment
// and returns the checkout ID from the response.
func createTerminalCheckout(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64) string {
t.Helper()
handler := CreateTerminalPayment
req := CreateTerminalPaymentRequest{
Amount: amount,
PaymentType: "full",
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var createResp CheckoutResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set")
}
return createResp.CheckoutID
}
func TestGetCheckoutStatus_DoublePoll_SinglePaymentRow(t *testing.T) {
// A double poll of the same terminal checkout must return the existing
// payment row instead of inserting a duplicate (which previously 500'd on
// the idempotency-key UNIQUE violation after the customer had paid).
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutID := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
first := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if first.PaymentID == "" {
t.Fatal("expected payment_id from first poll")
}
// Second poll of the same checkout — deduped against the existing row.
second := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
if second.PaymentID == "" {
t.Fatal("expected payment_id from second poll")
}
if second.PaymentID != first.PaymentID {
t.Errorf("expected same payment_id on re-poll, got %q then %q", first.PaymentID, second.PaymentID)
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 1 {
t.Errorf("expected exactly 1 payment row after double poll, got %d", rowCount)
}
}
func TestGetCheckoutStatus_TwoEqualAmountCharges_NoCollision(t *testing.T) {
// Two distinct terminal charges on the same booking with the same final
// amount must each create their own payment row (the deposit + equal-amount
// balance case) — no 500 on the idempotency-key UNIQUE collision.
origClient := SquareClient
SquareClient = &testCheckoutClient{
SquareClient: origClient,
hexIDs: make(map[string]string),
}
defer func() { SquareClient = origClient }()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
respA := pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
respB := pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
if respA.PaymentID == "" || respB.PaymentID == "" {
t.Fatal("expected payment_ids for both checkouts")
}
if respA.PaymentID == respB.PaymentID {
t.Error("expected two distinct payment rows for two distinct Square charges")
}
var rowCount int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&rowCount)
if err != nil {
t.Fatalf("failed to count payment rows: %v", err)
}
if rowCount != 2 {
t.Errorf("expected exactly 2 payment rows for two equal-amount charges, got %d", rowCount)
}
}