Implement every finding from the deep payment review (P0-P2, minors, nitpicks), then close the post-implementation re-review items, then align card-form typography and roll out the Square trust badge. Backend - Square API alignment: - tip_settings.allow_tipping nested under device_options (was top-level: terminal tips were silently lost in prod) - CreateCardOnFile now accepts customerID and sends card.customer_id; saved-card (ccof:) charges forward square_customer_id as CustomerID - New SquareClient methods GetPayment, CreateCustomer, CancelCheckout - SCA verification_token accepted + forwarded in all charge paths - ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/ ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts emails, ForceRefundPending hook Backend - money safety: - sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id instead of stranding them forever - SweepStalePendingPayments reconciles at Square before failing (tri-state: leave pending on transport error, rescue completed, fail definitively) - GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution; SweepStaleTerminalCheckouts covers terminal_checkouts table - till gift-card clawback on definitive failure incl. retry path + INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT - cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id)) - customer provisioning (lazy, save-only); one-off/guest mint no customer - discount preview/apply unified in discounts.go (global-milestone visible in preview, N+1 eliminated, redemption counter preserved on failures) - webhook event_id dedup; refund loop dedup; stale comment fixes - test-isolation t.Cleanup on committed sweep tests Frontend: - SCA tokenizeWithVerification across all charge flows (amount as major-units decimal), 5-min token-expiry re-tokenize, verification_token in request bodies - PaymentModal synchronous double-click + zero/negative-amount guards - till online-card UI wired to /api/admin/till/sale - policyPopover generalised; new /privacy-policy route; consent checkbox copy + Square privacy link - Square card iframe styled to app typography (Inter 14px, oklch tokens); mock form md:text-sm parity - 'Secure payment powered by Square' badge on all 8 card-payment flows Schema/docs: terminal_checkouts + square_customer_id + per-user card constraint in init-script.sql; README migrations; P14 plan + backlog + Technical Manual updated. Includes 39 modified/new test files; full backend suite (25 pkgs), -race on payments+square, and frontend build are green.
615 lines
23 KiB
Go
615 lines
23 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"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/jackc/pgx/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// BuyGiftCard / CreateTillSale verification_token wiring
|
|
// =============================================================================
|
|
|
|
// recordingCardOnFileClient records the customerID passed to
|
|
// CreateCardOnFile (the new P14 4th parameter) so tests can assert save-card
|
|
// flows provision the Square customer before tokenizing, while one-off flows
|
|
// pass "".
|
|
type recordingCardOnFileClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
customerID string
|
|
cofCalls int
|
|
}
|
|
|
|
func (c *recordingCardOnFileClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
|
|
c.mu.Lock()
|
|
c.customerID = customerID
|
|
c.cofCalls++
|
|
c.mu.Unlock()
|
|
return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID)
|
|
}
|
|
|
|
func (c *recordingCardOnFileClient) lastCustomerID() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.customerID
|
|
}
|
|
|
|
func (c *recordingCardOnFileClient) callCount() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.cofCalls
|
|
}
|
|
|
|
func TestBuyGiftCard_VerificationTokenPassthrough(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_gc_token_789"
|
|
newToken := "cnon:test-card"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "buy-gc-vrf-key",
|
|
VerificationToken: &vrf,
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
|
|
}
|
|
|
|
func TestBuyGiftCard_VerificationTokenTooLongRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
big := strings.Repeat("a", 600)
|
|
newToken := "cnon:test-card"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
IdempotencyKey: "buy-gc-vrf-long-key",
|
|
VerificationToken: &big,
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestBuyGiftCard_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
newToken := "cnon:test-card"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "buy-gc-save-cust-key",
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, rec.callCount())
|
|
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
|
|
|
|
var cid string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
|
|
require.Equal(t, rec.lastCustomerID(), cid, "the stored square_customer_id must match the id passed to CreateCardOnFile")
|
|
}
|
|
|
|
func TestBuyGiftCard_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
newToken := "cnon:test-card"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "buy-gc-nosave-cust-key",
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, rec.callCount())
|
|
require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer")
|
|
}
|
|
|
|
func TestCreateTillSale_VerificationTokenPassthrough(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_till_token_012"
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:visa",
|
|
IdempotencyKey: "till-vrf-key",
|
|
VerificationToken: &vrf,
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
require.Equal(t, vrf, got, "the SCA verification token completed by the customer must be forwarded to Square")
|
|
}
|
|
|
|
func TestCreateTillSale_VerificationTokenTooLongRejected(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
big := strings.Repeat("a", 600)
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:visa",
|
|
IdempotencyKey: "till-vrf-long-key",
|
|
VerificationToken: &big,
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func TestCreateTillSale_OnlineSquare_NoCustomerProvisioned(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:visa",
|
|
IdempotencyKey: "till-nosave-cust-key",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
require.Equal(t, 1, rec.callCount())
|
|
require.Equal(t, "", rec.lastCustomerID(), "the ephemeral till card is a one-off cnon: charge — no Square customer")
|
|
}
|
|
|
|
// =============================================================================
|
|
// INSUFFICIENT_FUNDS and other definitive decline codes
|
|
// =============================================================================
|
|
|
|
func TestIsDefinitiveChargeFailure_CoversInsufficientFunds(t *testing.T) {
|
|
errs := []error{
|
|
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/INSUFFICIENT_FUNDS] insufficient funds"),
|
|
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/ADDRESS_VERIFICATION_FAILURE] avs mismatch"),
|
|
fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/TRANSACTION_LIMIT] limit reached"),
|
|
}
|
|
for _, err := range errs {
|
|
if !isDefinitiveChargeFailure(err) {
|
|
t.Errorf("expected %v to be classified as a definitive charge failure", err)
|
|
}
|
|
}
|
|
if isDefinitiveChargeFailure(fmt.Errorf("network error: connection reset by peer")) {
|
|
t.Error("expected an ambiguous transport error to NOT be definitive")
|
|
}
|
|
if isDefinitiveChargeFailure(nil) {
|
|
t.Error("expected nil to not be a definitive charge failure")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetCheckoutStatus — cancellation recheck before recording a completed payment
|
|
// =============================================================================
|
|
|
|
func TestGetCheckoutStatus_CancelledBooking_RejectsRecord(t *testing.T) {
|
|
origClient := SquareClient
|
|
SquareClient = &testCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
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)
|
|
|
|
// Cancel the booking after the checkout was created but before it is polled —
|
|
// a terminal payment landing on a cancelled booking must NOT be recorded.
|
|
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, bookingID); err != nil {
|
|
t.Fatalf("failed to cancel booking: %v", err)
|
|
}
|
|
|
|
var w *httptest.ResponseRecorder
|
|
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)
|
|
|
|
w = httptest.NewRecorder()
|
|
GetCheckoutStatus(w, statusReq)
|
|
return w.Code == http.StatusConflict
|
|
}, 10*time.Second, 100*time.Millisecond, "expected the cancelled-booking checkout to be rejected with 409")
|
|
|
|
var completedCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount))
|
|
require.Zero(t, completedCount, "no completed payment may be recorded on a cancelled booking")
|
|
|
|
var rowStatus string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&rowStatus))
|
|
require.Equal(t, "failed", rowStatus, "the terminal_checkouts row must be marked failed so a fresh charge is possible")
|
|
}
|
|
|
|
// =============================================================================
|
|
// activeTerminalCheckoutID — definitively cancelled checkout must not wedge
|
|
// =============================================================================
|
|
|
|
// canceledCheckoutClient makes one checkout report CANCELED at Square (the
|
|
// error the real HTTP client produces for a non-COMPLETED, non-PENDING status)
|
|
// while delegating everything else to the real mock.
|
|
type canceledCheckoutClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
}
|
|
|
|
func (c *canceledCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
if checkoutID == c.checkoutID {
|
|
return nil, fmt.Errorf("square: checkout %s is CANCELED (not COMPLETED)", checkoutID)
|
|
}
|
|
return c.SquareClient.GetCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
func TestActiveTerminalCheckoutID_ResolvesCanceledCheckout(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
checkoutID := "chk_canceled_12345"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00)
|
|
`, checkoutID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed terminal checkout: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &canceledCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
got := activeTerminalCheckoutID(ctx, bookingID)
|
|
require.Equal(t, "", got, "a definitively CANCELED checkout must resolve to \"\" so a new checkout can be created")
|
|
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID).Scan(&status))
|
|
require.Equal(t, "failed", status, "the canceled checkout row must be marked failed")
|
|
}
|
|
|
|
// =============================================================================
|
|
// SweepStaleTerminalCheckouts — terminal_checkouts (booking) coverage
|
|
// =============================================================================
|
|
|
|
func TestSweepStaleTerminalCheckouts_CoversTerminalCheckoutsTable(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
|
|
|
origClient := SquareClient
|
|
mock := square.NewDevClient().(*square.MockClient)
|
|
mock.HoldCheckouts = true
|
|
checkout, err := mock.CreateCheckout(context.Background(), square.CreateCheckoutReq{
|
|
Amount: 5000,
|
|
Currency: "GBP",
|
|
IdempotencyKey: "chk-stale-terminal-booking",
|
|
})
|
|
require.NoError(t, err)
|
|
SquareClient = mock
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
|
`, checkout.ID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
require.NotNil(t, pgxTx)
|
|
require.NoError(t, pgxTx.Commit(ctx))
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
// Drop stale rows left by other sweep tests so the count is deterministic.
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, checkout.ID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, n, "the stale booking terminal checkout must be resolved by the sweep")
|
|
|
|
var status string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, checkout.ID).Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
|
|
// The checkout must no longer be PENDING at Square (it was cancelled).
|
|
if _, gErr := mock.GetCheckout(freshCtx, checkout.ID); gErr == nil || errors.Is(gErr, square.ErrCheckoutPending) {
|
|
t.Errorf("expected checkout %s to be cancelled at Square (no longer pending), GetCheckout err=%v", checkout.ID, gErr)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Till-sale clawback on pending-retry definitive failure
|
|
// =============================================================================
|
|
|
|
func TestCreateTillSale_PendingRetry_DefinitiveFailure_ClawsBack(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
require.NoError(t, err)
|
|
|
|
// Seed a PENDING till_sale whose gift card was already funded by a prior
|
|
// attempt of this same sale (the prior charge failed ambiguously). The
|
|
// retry's definitive failure must claw the funding back.
|
|
key := "till-pending-definitive-clawback-key"
|
|
var giftCardID string
|
|
require.NoError(t, tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 50.00, $1, FALSE, 'SPV') RETURNING id
|
|
`, adminID).Scan(&giftCardID))
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
|
$2, $3, $4, $5, NOW(), NOW())
|
|
`, giftCardID, userID, cardID, key, adminID)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: key,
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusPaymentRequired, w.Code)
|
|
|
|
// The reused sale row must be 'failed' and the previously funded gift card
|
|
// clawed back — a definitive failure on retry means the charge can never
|
|
// complete, so the funded card must not be left behind (free gift card).
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
|
|
var gcCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&gcCount))
|
|
require.Zero(t, gcCount, "the funded gift card must be clawed back after a definitive failure on retry")
|
|
}
|
|
|
|
// =============================================================================
|
|
// customer_id on saved-card (ccof:) charges
|
|
// =============================================================================
|
|
|
|
func TestCreateBookingPayment_SavedCard_ForwardsCustomerID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_saved", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
if _, err := tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = 'cus_test_123' WHERE id = $1`, cardID); err != nil {
|
|
t.Fatalf("failed to set square_customer_id: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "saved-cust-" + bookingID,
|
|
}
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.CustomerID
|
|
rec.mu.Unlock()
|
|
require.Equal(t, "cus_test_123", got, "a saved-card (ccof:) charge must carry the saved-card row's Square customer id")
|
|
}
|
|
|
|
func TestCreateBookingPayment_SaveCard_ProvisionsCustomerForCreateCardOnFile(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "save-cust-" + bookingID,
|
|
}
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, rec.callCount())
|
|
require.NotEmpty(t, rec.lastCustomerID(), "a save-card flow must pass the provisioned Square customer id to CreateCardOnFile")
|
|
|
|
var cid string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cid))
|
|
require.Equal(t, rec.lastCustomerID(), cid)
|
|
}
|
|
|
|
func TestCreateBookingPayment_NoSaveCard_CreateCardOnFileEmptyCustomer(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: false,
|
|
IdempotencyKey: "nosave-cust-" + bookingID,
|
|
}
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, rec.callCount())
|
|
require.Equal(t, "", rec.lastCustomerID(), "a one-off non-save charge needs no Square customer")
|
|
}
|