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
812 lines
28 KiB
Go
812 lines
28 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Unit tests — IsValidBookingStatusForPayment (pure function, no DB)
|
|
// =============================================================================
|
|
|
|
func TestIsValidBookingStatusForPayment_Confirmed(t *testing.T) {
|
|
t.Parallel()
|
|
if !IsValidBookingStatusForPayment("confirmed") {
|
|
t.Error("expected 'confirmed' to be valid for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_Pending(t *testing.T) {
|
|
t.Parallel()
|
|
if !IsValidBookingStatusForPayment("pending") {
|
|
t.Error("expected 'pending' to be valid for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_PendingRelease(t *testing.T) {
|
|
t.Parallel()
|
|
if !IsValidBookingStatusForPayment("pending_release") {
|
|
t.Error("expected 'pending_release' to be valid for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_InProgress(t *testing.T) {
|
|
t.Parallel()
|
|
if !IsValidBookingStatusForPayment("in_progress") {
|
|
t.Error("expected 'in_progress' to be valid for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_RejectsDepositLapsed(t *testing.T) {
|
|
t.Parallel()
|
|
if IsValidBookingStatusForPayment("deposit_lapsed") {
|
|
t.Error("expected 'deposit_lapsed' to be rejected for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_RejectsClientCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
if IsValidBookingStatusForPayment("client_cancelled") {
|
|
t.Error("expected 'client_cancelled' to be rejected for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_RejectsWeCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
if IsValidBookingStatusForPayment("we_cancelled") {
|
|
t.Error("expected 'we_cancelled' to be rejected for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_RejectsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
if IsValidBookingStatusForPayment("completed") {
|
|
t.Error("expected 'completed' to be rejected for payment")
|
|
}
|
|
}
|
|
|
|
func TestIsValidBookingStatusForPayment_RejectsNoShow(t *testing.T) {
|
|
t.Parallel()
|
|
if IsValidBookingStatusForPayment("no_show") {
|
|
t.Error("expected 'no_show' to be rejected for payment")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration tests — CreateBookingPayment status guard
|
|
// =============================================================================
|
|
|
|
// setupPaymentStatusTest creates a user, service, and booking with the given
|
|
// status, returning the userID, bookingID, and user token.
|
|
func setupPaymentStatusTest(t *testing.T, ctx context.Context, q db.Querier, status string) (string, string, string) {
|
|
t.Helper()
|
|
|
|
userID, err := fixtures.CreateTestUser(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
serviceID, err := fixtures.CreateTestService(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test service: %v", err)
|
|
}
|
|
|
|
// Use a far-future date so the booking is never in the cleanup window.
|
|
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create test booking: %v", err)
|
|
}
|
|
|
|
_, err = q.Exec(ctx, "UPDATE bookings SET status = $1 WHERE id = $2", status, bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set booking status to %q: %v", status, err)
|
|
}
|
|
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
return userID, bookingID, userToken
|
|
}
|
|
|
|
func TestCreateBookingPayment_AcceptsPendingRelease(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "deposit-pending-release-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the booking was promoted back to confirmed.
|
|
var status string
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking status: %v", err)
|
|
}
|
|
if status != "confirmed" {
|
|
t.Errorf("expected booking promoted from pending_release to 'confirmed', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_ThresholdMet_SmallPaymentPromotes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
// Pay 15% (£7.50 on a £50 booking) — below 20% threshold.
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 750, // £7.50 = 15% of £50
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "below-threshold-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Payment below 20% threshold — booking should remain pending_release.
|
|
var status string
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking: %v", err)
|
|
}
|
|
if status != "pending_release" {
|
|
t.Errorf("expected booking to remain pending_release, got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_ThresholdMet_BalancePaymentPromotes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
// Pay £25 via "balance" type — still should meet the 20% threshold.
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500, // £25.00 = 50% of £50
|
|
PaymentType: "balance",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "balance-promotes-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking: %v", err)
|
|
}
|
|
if status != "confirmed" {
|
|
t.Errorf("expected booking promoted to 'confirmed', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_AcceptsConfirmed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "deposit-confirmed-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_RejectsPending(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "deposit-pending-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409 for pending booking (not yet confirmed), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_RejectsDepositLapsed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "reject-lapsed-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_RejectsClientCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled")
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "reject-cancelled-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected status 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Payment Lock Tests
|
|
// =============================================================================
|
|
|
|
func paymentLockRequest(method, path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
bookingID, _ := extractPaymentIDFromPath(path)
|
|
rctx.URLParams.Add("id", bookingID)
|
|
|
|
ctx := context.Background()
|
|
if len(baseCtx) > 0 {
|
|
ctx = baseCtx[0]
|
|
}
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
|
|
if token != "" {
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
|
|
}
|
|
}
|
|
|
|
AcquirePaymentLock(w, req.WithContext(ctx))
|
|
return w
|
|
}
|
|
|
|
func TestAcquirePaymentLock_Confirmed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for confirmed booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_InProgress(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for in_progress booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_PendingRelease(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for pending_release booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsDepositLapsed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "deposit_lapsed")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for deposit_lapsed booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsClientCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for client_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsWeCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "we_cancelled")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for we_cancelled booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsNoShow(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for no_show booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsPending(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for pending booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAcquirePaymentLock_RejectsCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "completed")
|
|
w := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusConflict {
|
|
t.Errorf("expected 409 for completed booking, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ReleasePaymentLock — DELETE /api/bookings/{id}/payment-lock
|
|
// =============================================================================
|
|
|
|
func releasePaymentLockRequest(path string, token string, baseCtx ...context.Context) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest("DELETE", path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
rctx := chi.NewRouteContext()
|
|
bookingID, _ := extractPaymentIDFromPath(path)
|
|
rctx.URLParams.Add("id", bookingID)
|
|
|
|
ctx := context.Background()
|
|
if len(baseCtx) > 0 {
|
|
ctx = baseCtx[0]
|
|
}
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
|
|
if token != "" {
|
|
if info := extractUserFromTestJWT(token); info != nil {
|
|
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
|
|
}
|
|
}
|
|
|
|
ReleasePaymentLock(w, req.WithContext(ctx))
|
|
return w
|
|
}
|
|
|
|
func TestReleasePaymentLock_HappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
// First, acquire the lock to create a PAYMENT_IN_FLIGHT time_blocker
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 when acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
// Verify the lock exists in the DB
|
|
var lockCount int
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query time_blockers: %v", err)
|
|
}
|
|
if lockCount != 1 {
|
|
t.Fatalf("expected 1 time_blocker before release, got %d", lockCount)
|
|
}
|
|
|
|
// Now release the lock
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected 204 No Content, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify the lock was removed from the DB
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query time_blockers after release: %v", err)
|
|
}
|
|
if lockCount != 0 {
|
|
t.Errorf("expected 0 time_blockers after release, got %d", lockCount)
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_NoExistingLock(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
// Release without acquiring first — should be idempotent (204)
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected 204 No Content for idempotent release, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_InvalidBookingID(t *testing.T) {
|
|
w := releasePaymentLockRequest("/api/bookings/invalid/payment-lock", "")
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for invalid booking ID, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_EmptyBookingID(t *testing.T) {
|
|
w := releasePaymentLockRequest("/api/bookings//payment-lock", "")
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404 for empty booking ID, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
func TestReleasePaymentLock_AfterMultipleAcquires(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
// Acquire the lock twice — AcquirePaymentLock should be idempotent
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 on first acquire, got %d", lockW.Code)
|
|
}
|
|
lockW = paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 on second acquire, got %d", lockW.Code)
|
|
}
|
|
|
|
// Release should still succeed
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", userToken, ctx)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected 204 No Content after multiple acquires, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify all locks are gone
|
|
var lockCount int
|
|
err := tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query time_blockers: %v", err)
|
|
}
|
|
if lockCount != 0 {
|
|
t.Errorf("expected 0 time_blockers after release, got %d", lockCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetCheckoutStatus — COMPLETED/Pending paths (via wrapper client)
|
|
// =============================================================================
|
|
|
|
// testCheckoutClient wraps square.SquareClient to generate checkout IDs that
|
|
// pass validators.IsValidID (12-char hex). The mock generates IDs like
|
|
// "chk_mock_..." which fail that check, so we map valid hex IDs to mock IDs.
|
|
type testCheckoutClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
hexIDs map[string]string
|
|
idSeq int
|
|
}
|
|
|
|
func (c *testCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) {
|
|
result, err := c.SquareClient.CreateCheckout(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.mu.Lock()
|
|
c.idSeq++
|
|
hexID := fmt.Sprintf("%012x", c.idSeq)
|
|
c.hexIDs[hexID] = result.ID
|
|
c.mu.Unlock()
|
|
result.ID = hexID
|
|
return result, nil
|
|
}
|
|
|
|
func (c *testCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
c.mu.Lock()
|
|
mockID, ok := c.hexIDs[checkoutID]
|
|
c.mu.Unlock()
|
|
if ok {
|
|
checkoutID = mockID
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
func TestGetCheckoutStatus_Pending(t *testing.T) {
|
|
// NOTE: This test is skipped because the mock's goroutine completes instantly
|
|
// when GO_TESTING=1 (mockSleep is a no-op). By the time we call
|
|
// GetCheckoutStatus, the checkout is already COMPLETED. The PENDING state is
|
|
// only observable with real Square (3s delay) or when mockSleep actually sleeps.
|
|
// The underlying paths are exercised by TestGetCheckoutStatus_Completed and
|
|
// the existing TestGetTillCheckoutStatus_Pending test.
|
|
t.Skip("mock goroutine completes instantly in test mode; PENDING state not observable")
|
|
}
|
|
|
|
func TestGetCheckoutStatus_Completed(t *testing.T) {
|
|
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()
|
|
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: 5000,
|
|
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")
|
|
}
|
|
|
|
// Poll for the mock goroutine to complete using assert.Eventually
|
|
var resp PaymentStatusResponse
|
|
assert.Eventually(t, func() bool {
|
|
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil)
|
|
statusRCtx := chi.NewRouteContext()
|
|
statusRCtx.URLParams.Add("checkout_id", createResp.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")
|
|
if resp.PaymentID == "" {
|
|
t.Error("expected payment_id to be set")
|
|
}
|
|
if resp.CardBrand == "" {
|
|
t.Error("expected card_brand to be set")
|
|
}
|
|
if resp.CardLast4 == "" {
|
|
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)
|
|
}
|
|
}
|