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.
929 lines
34 KiB
Go
929 lines
34 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Cancellation-race recheck — booking cancelled between pending commit and tx2
|
|
// =============================================================================
|
|
|
|
// cancellingCreatePaymentClient cancels the booking just before the Square
|
|
// charge succeeds, simulating a concurrent cancellation landing between the
|
|
// pending-record commit (step 1) and the post-charge transaction (step 2).
|
|
type cancellingCreatePaymentClient struct {
|
|
square.SquareClient
|
|
bookingID string
|
|
}
|
|
|
|
func (c *cancellingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
if _, err := db.Conn.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, c.bookingID); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.SquareClient.CreatePayment(ctx, req)
|
|
}
|
|
|
|
func TestCreateBookingPayment_CancellationRace_MarksFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &cancellingCreatePaymentClient{SquareClient: square.NewDevClient(), bookingID: bookingID}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
key := "cancel-race-" + bookingID
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when booking cancelled mid-charge, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The pending payment must be marked failed, never completed with splits.
|
|
var status string
|
|
err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payment status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected payment status 'failed' after cancellation race, got %q", status)
|
|
}
|
|
|
|
// No completed payment may exist on the cancelled booking (the deposit
|
|
// would otherwise bypass the cancellation refund computation).
|
|
var completedCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count completed payments: %v", err)
|
|
}
|
|
if completedCount != 0 {
|
|
t.Errorf("expected 0 completed payments on cancelled booking, got %d", completedCount)
|
|
}
|
|
|
|
// The booking itself stays cancelled.
|
|
var bookingStatus string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking status: %v", err)
|
|
}
|
|
if bookingStatus != "client_cancelled" {
|
|
t.Errorf("expected booking to remain client_cancelled, got %q", bookingStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tips on cancelled bookings rejected
|
|
// =============================================================================
|
|
|
|
func TestCreateTipPayment_RejectsCancelledBooking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled")
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 for tip on cancelled booking, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No tip payment record may be created.
|
|
var tipCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count tip payments: %v", err)
|
|
}
|
|
if tipCount != 0 {
|
|
t.Errorf("expected 0 tip payments on cancelled booking, got %d", tipCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateTipPayment_RejectsNoShowBooking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show")
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken}
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 for tip on no-show booking, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ReleasePaymentLock ownership checks
|
|
// =============================================================================
|
|
|
|
func TestReleasePaymentLock_CrossUserRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
otherToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", otherToken, ctx)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for cross-user release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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.Errorf("expected lock to remain after cross-user release, got %d blockers", lockCount)
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_AdminAllowed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", jwt.GenerateAdminToken(), ctx)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for admin release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_UnauthenticatedRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", "", ctx)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetBookingPaymentSummary fail-closed auth
|
|
// =============================================================================
|
|
|
|
func TestGetBookingPaymentSummary_UnauthenticatedRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated summary request, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Terminal payment type recorded + in-flight checkout guard
|
|
// =============================================================================
|
|
|
|
// createTerminalCheckoutWithType is like createTerminalCheckout but records the
|
|
// given payment type instead of hardcoding "full".
|
|
func createTerminalCheckoutWithType(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64, paymentType string) string {
|
|
t.Helper()
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: amount,
|
|
PaymentType: paymentType,
|
|
}
|
|
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_RecordsChargedPaymentType(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 := createTerminalCheckoutWithType(t, ctx, bookingID, adminToken, 5000, "balance")
|
|
|
|
resp := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
|
if resp.PaymentID == "" {
|
|
t.Fatal("expected payment_id to be set")
|
|
}
|
|
|
|
var paymentType string
|
|
err := tx.QueryRow(ctx, `SELECT payment_type FROM payments WHERE id = $1`, resp.PaymentID).Scan(&paymentType)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payment type: %v", err)
|
|
}
|
|
if paymentType != "balance" {
|
|
t.Errorf("expected recorded payment_type 'balance', got %q", paymentType)
|
|
}
|
|
}
|
|
|
|
func TestCreateTerminalPayment_InFlightGuard_ReturnsExisting(t *testing.T) {
|
|
origClient := SquareClient
|
|
mc := square.NewDevClient().(*square.MockClient)
|
|
mc.HoldCheckouts = true // keep the first checkout pending so the guard fires
|
|
SquareClient = mc
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
first := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
|
|
|
// A second attempt while the first is still in flight must reuse it, not
|
|
// create a second live checkout.
|
|
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 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp CheckoutResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if resp.CheckoutID != first {
|
|
t.Errorf("expected the in-flight checkout %q to be returned, got %q", first, resp.CheckoutID)
|
|
}
|
|
|
|
var rowCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count terminal checkouts: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Errorf("expected exactly 1 terminal_checkouts row, got %d", rowCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateTerminalPayment_InFlightGuard_AllowsAfterCompletion(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()
|
|
|
|
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
|
pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
|
|
|
|
// Once the first checkout is recorded COMPLETED, a new charge is allowed.
|
|
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000)
|
|
if checkoutB == checkoutA {
|
|
t.Error("expected a new checkout after the previous one completed")
|
|
}
|
|
pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
|
|
|
|
var rowCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count terminal checkouts: %v", err)
|
|
}
|
|
if rowCount != 2 {
|
|
t.Errorf("expected 2 terminal_checkouts rows (one completed, one new), got %d", rowCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Discount preview — global milestone + shared-eligibility semantics
|
|
// =============================================================================
|
|
|
|
func TestDiscountPreview_GlobalMilestoneIncluded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil {
|
|
t.Fatalf("failed to complete past booking: %v", err)
|
|
}
|
|
|
|
// The first payment on the booking is in-person, which is the global
|
|
// milestone's eligibility condition.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create in-person payment: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
|
|
VALUES ('Global Milestone', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0)
|
|
`); err != nil {
|
|
t.Fatalf("failed to create global milestone campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, d := range resp.Discounts {
|
|
if d.Source == "campaign" && d.Percent == 10 {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected the global milestone discount in the preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
func TestDiscountPreview_Anniversary_AlreadyAppliedSkipped(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
// First visit years ago so anniversary campaigns qualify.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed')
|
|
`, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("failed to create first-visit booking: %v", err)
|
|
}
|
|
|
|
// Campaign A: already applied to this booking.
|
|
var campA string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
|
|
VALUES ('Anniv A', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
|
|
RETURNING id
|
|
`).Scan(&campA)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign A: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', 10, 100, 10)
|
|
`, bookingID, userID, campA); err != nil {
|
|
t.Fatalf("failed to apply campaign A: %v", err)
|
|
}
|
|
|
|
// Campaign B: eligible.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
|
|
VALUES ('Anniv B', 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
|
|
`); err != nil {
|
|
t.Fatalf("failed to create campaign B: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
for _, d := range resp.Discounts {
|
|
if d.Name == "Anniv A" {
|
|
t.Error("expected the already-applied anniversary campaign to be skipped")
|
|
}
|
|
}
|
|
foundB := false
|
|
for _, d := range resp.Discounts {
|
|
if d.Name == "Anniv B" {
|
|
foundB = true
|
|
break
|
|
}
|
|
}
|
|
if !foundB {
|
|
t.Errorf("expected the eligible anniversary campaign in the preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
// TestDiscountPreview_ManyCampaignsExercisesSharedEligibility creates several
|
|
// campaigns (time-based + milestone + global) and confirms the shared
|
|
// ComputeEligibleDiscounts helper aggregates them correctly in the preview.
|
|
func TestDiscountPreview_ManyCampaignsExercisesSharedEligibility(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil {
|
|
t.Fatalf("failed to complete past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create in-person payment: %v", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Time Sale', 'time_based', 5, 'active', $1, $2)
|
|
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)); err != nil {
|
|
t.Fatalf("failed to create time-based campaign: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
|
|
VALUES ('Global 1st', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0)
|
|
`); err != nil {
|
|
t.Fatalf("failed to create global campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if len(resp.Discounts) < 2 {
|
|
t.Errorf("expected time-based + global milestone discounts in preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verification token passthrough to Square
|
|
// =============================================================================
|
|
|
|
type recordingPaymentClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
lastReq square.CreatePaymentReq
|
|
}
|
|
|
|
func (c *recordingPaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
c.mu.Lock()
|
|
c.lastReq = req
|
|
c.mu.Unlock()
|
|
return c.SquareClient.CreatePayment(ctx, req)
|
|
}
|
|
|
|
func TestCreateBookingPayment_VerificationTokenPassthrough(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_booking_token_123"
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "vrf-booking-" + bookingID,
|
|
VerificationToken: &vrf,
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
if got != vrf {
|
|
t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got)
|
|
}
|
|
if rec.lastReq.BuyerEmail == "" {
|
|
t.Error("expected BuyerEmail to be populated for booking payment")
|
|
}
|
|
}
|
|
|
|
func TestCreateTipPayment_VerificationTokenPassthrough(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress")
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_tip_token_456"
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
VerificationToken: &vrf,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
if got != vrf {
|
|
t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_VerificationTokenTooLongRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
long := make([]byte, 600)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
big := string(long)
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "vrf-too-long-" + bookingID,
|
|
VerificationToken: &big,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for oversized verification token, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deposit-promotion SUM excludes tip rows
|
|
// =============================================================================
|
|
|
|
func TestBookingPayment_PromotionThreshold_ExcludesTipRows(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
// A tip on the booking must not count toward the 20% promotion threshold.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'tip', 'in_person_card', 1000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create tip payment: %v", err)
|
|
}
|
|
|
|
// 15% of the total via a real payment — below the 20% threshold even with
|
|
// the tip present.
|
|
var total float64
|
|
if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&total); err != nil {
|
|
t.Fatalf("failed to read booking total: %v", err)
|
|
}
|
|
amount := int64(total * 100 * 0.15)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: amount,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "promo-tip-" + 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 status: %v", err)
|
|
}
|
|
if status != "pending_release" {
|
|
t.Errorf("expected booking to remain pending_release (tip excluded from threshold), got %q", status)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ApplyEligibleDiscount — counter/used-flag survive a payments-INSERT failure
|
|
// =============================================================================
|
|
|
|
// failingExecQuerier wraps a db.Querier and fails any Exec whose SQL contains
|
|
// the target fragment, simulating a constraint/connection error on that one
|
|
// statement while delegating everything else to the wrapped querier.
|
|
type failingExecQuerier struct {
|
|
db.Querier
|
|
failSQLContains string
|
|
}
|
|
|
|
func (f failingExecQuerier) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
|
if strings.Contains(sql, f.failSQLContains) {
|
|
return pgconn.CommandTag{}, errors.New("simulated failure on " + f.failSQLContains)
|
|
}
|
|
return f.Querier.Exec(ctx, sql, args...)
|
|
}
|
|
|
|
// seedTestCampaign inserts an active time_based campaign and returns its id.
|
|
func seedTestCampaign(t *testing.T, ctx context.Context, q db.Querier) string {
|
|
t.Helper()
|
|
var campaignID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Test Campaign', 'time_based', 10.00, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 days')
|
|
RETURNING id
|
|
`).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed test campaign: %v", err)
|
|
}
|
|
return campaignID
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_CampaignPaymentInsertFailure_StillIncrementsCounter(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
campaignID := seedTestCampaign(t, ctx, tx)
|
|
|
|
// The payments INSERT fails AFTER the booking_discounts row was inserted,
|
|
// so the redemption happened — the counter MUST still increment.
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
|
|
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "campaign",
|
|
Name: "Test Campaign",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: campaignID,
|
|
CampaignType: "time_based",
|
|
})
|
|
|
|
var redeemed int
|
|
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
|
|
t.Fatalf("failed to read campaign counter: %v", err)
|
|
}
|
|
if redeemed != 1 {
|
|
t.Errorf("expected times_redeemed incremented to 1 despite the payment-record insert failure, got %d", redeemed)
|
|
}
|
|
|
|
var bdCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`, bookingID, campaignID).Scan(&bdCount); err != nil {
|
|
t.Fatalf("failed to count booking_discounts: %v", err)
|
|
}
|
|
if bdCount != 1 {
|
|
t.Errorf("expected 1 booking_discounts row, got %d", bdCount)
|
|
}
|
|
|
|
var discountPayments int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil {
|
|
t.Fatalf("failed to count discount payments: %v", err)
|
|
}
|
|
if discountPayments != 0 {
|
|
t.Errorf("expected NO discount payment row (the insert was simulated to fail), got %d", discountPayments)
|
|
}
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_ReferralPaymentInsertFailure_StillMarksUsed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
referredID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create referred user: %v", err)
|
|
}
|
|
var referralID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id
|
|
`, userID, referredID).Scan(&referralID); err != nil {
|
|
t.Fatalf("failed to seed user referral: %v", err)
|
|
}
|
|
var rdID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO referral_discounts (user_id, referral_id, discount_percent) VALUES ($1, $2, 10.00) RETURNING id
|
|
`, userID, referralID).Scan(&rdID); err != nil {
|
|
t.Fatalf("failed to seed referral discount: %v", err)
|
|
}
|
|
|
|
// The payments INSERT fails AFTER the referral's booking_discounts row was
|
|
// inserted, so the discount WAS redeemed — the used flag MUST still be set.
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
|
|
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "referral",
|
|
Name: "Referral Discount (10%)",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: rdID,
|
|
IsReferral: true,
|
|
})
|
|
|
|
var used bool
|
|
if err := tx.QueryRow(ctx, `SELECT used FROM referral_discounts WHERE id = $1`, rdID).Scan(&used); err != nil {
|
|
t.Fatalf("failed to read referral discount used flag: %v", err)
|
|
}
|
|
if !used {
|
|
t.Error("expected referral discount marked used despite the payment-record insert failure")
|
|
}
|
|
|
|
var bdCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&bdCount); err != nil {
|
|
t.Fatalf("failed to count booking_discounts: %v", err)
|
|
}
|
|
if bdCount != 1 {
|
|
t.Errorf("expected 1 referral booking_discounts row, got %d", bdCount)
|
|
}
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_BookingDiscountsInsertFailure_NoCounterIncrement(t *testing.T) {
|
|
// The booking_discounts-INSERT failure is the ONE early return that is
|
|
// correct: nothing was recorded, so the redemption never happened and the
|
|
// campaign counter must NOT increment.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
campaignID := seedTestCampaign(t, ctx, tx)
|
|
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO booking_discounts"}
|
|
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "campaign",
|
|
Name: "Test Campaign",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: campaignID,
|
|
CampaignType: "time_based",
|
|
})
|
|
|
|
var redeemed int
|
|
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
|
|
t.Fatalf("failed to read campaign counter: %v", err)
|
|
}
|
|
if redeemed != 0 {
|
|
t.Errorf("expected times_redeemed unchanged (0) when the booking_discounts insert fails, got %d", redeemed)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateTerminalPayment — orphaned live checkout cancelled on INSERT failure
|
|
// =============================================================================
|
|
|
|
// fixedCheckoutClient returns a predetermined checkout ID so a test can force
|
|
// the terminal_checkouts INSERT to collide (PK) while delegating everything
|
|
// else to the real mock.
|
|
type fixedCheckoutClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
mu sync.Mutex
|
|
cancelled []string
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) {
|
|
return &square.CheckoutResult{
|
|
ID: c.checkoutID,
|
|
Status: "PENDING",
|
|
AmountMoney: req.Amount,
|
|
Currency: "GBP",
|
|
ReferenceID: req.ReferenceID,
|
|
}, nil
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
|
c.mu.Lock()
|
|
c.cancelled = append(c.cancelled, checkoutID)
|
|
c.mu.Unlock()
|
|
return c.SquareClient.CancelCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) cancelCalls() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.cancelled...)
|
|
}
|
|
|
|
func TestCreateTerminalPayment_RecordInsertFailure_CancelsOrphanedCheckout(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// A COMPLETED terminal_checkouts row with the same checkout_id the client
|
|
// will return forces the handler's INSERT to collide on the PK while the
|
|
// active-checkout guard (PENDING/IN_PROGRESS only) does not fire.
|
|
const dupCheckoutID = "chk_dup_insert_01"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
|
VALUES ($1, $2, 'full', 'COMPLETED', 50.00)
|
|
`, dupCheckoutID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed duplicate checkout row: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
client := &fixedCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: dupCheckoutID}
|
|
SquareClient = client
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 for the failed terminal_checkouts INSERT, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The orphaned live checkout must have been cancelled at Square.
|
|
if calls := client.cancelCalls(); len(calls) != 1 || calls[0] != dupCheckoutID {
|
|
t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", dupCheckoutID, calls)
|
|
}
|
|
}
|