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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user