Implement full Square payment review fixes + frontend polish

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.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent fb21538532
commit 54a5b1024e
45 changed files with 6815 additions and 937 deletions
+324
View File
@@ -0,0 +1,324 @@
package payments
import (
"context"
"log"
"time"
"crussell/db"
)
// EligibleDiscount describes a single discount that is currently eligible for a
// booking, computed identically for the discount preview and the
// apply-at-payment path so the preview shows exactly what payment will apply.
// Amount is the discounted value in pounds.
type EligibleDiscount struct {
Source string // "campaign" or "referral"
Name string
Percent float64
Amount float64
SourceID string // discount_campaigns.id or referral_discounts.id
CampaignType string // "time_based", "milestone", or "" for referral
MilestoneType *string // "per_user_booking_count", "anniversary", "global_booking_count", or nil
IsReferral bool
}
// ComputeEligibleDiscounts returns every campaign/referral discount currently
// eligible for the booking, using the same queries the apply-at-payment path
// runs (including the global in-person milestone discount that was previously
// only computed at payment time). It is read-only: it never writes
// booking_discounts, payments, or campaign counters. Callers pass the querier
// that matches their context — db.Conn for the preview, the payment
// transaction for the apply path.
//
// Existing booking_discounts for the booking are collected in ONE query up
// front and checked in-memory, replacing the previous per-campaign
// "SELECT 1 FROM booking_discounts WHERE booking_id=$1 AND source_id=$2" that
// produced an N+1 inside the anniversary loop.
func ComputeEligibleDiscounts(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64) []EligibleDiscount {
// The apply path refuses to apply NEW discounts once a booking has 2+
// completed real payments (the customer has already paid) — mirror that
// here so the preview does not promise a discount apply will refuse.
var existingPayment int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).Scan(&existingPayment); err != nil {
log.Printf("Failed to scan existing payment count: %v", err)
}
if existingPayment >= 2 {
return nil
}
if bookingTotal <= 0 {
return nil
}
// Existing booking_discounts for THIS booking, keyed by source so a
// campaign id can never collide with a referral id. Single query replaces
// the N+1 per-campaign existence checks (both files).
existingSources := map[string]bool{}
{
rows, err := q.Query(ctx, `
SELECT COALESCE(discount_source, ''), COALESCE(source_id, '')
FROM booking_discounts WHERE booking_id = $1
`, bookingID)
if err == nil {
for rows.Next() {
var src, sid string
if rows.Scan(&src, &sid) == nil {
existingSources[src+"|"+sid] = true
}
}
rows.Close()
} else {
log.Printf("Failed to query existing booking discounts for booking %s: %v", bookingID, err)
}
}
var discounts []EligibleDiscount
// Time-based campaign: the highest-percent active time_based campaign.
var campaignID, campaignName string
var campaignPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" {
if !existingSources["campaign|"+campaignID] {
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: campaignName,
Percent: campaignPercent,
Amount: roundTo2(bookingTotal * campaignPercent / 100),
SourceID: campaignID,
CampaignType: "time_based",
})
}
}
// Per-user booking-count milestone: the campaign matching the user's
// completed-booking count that has not yet been used for this user.
var userBookingCount int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'
`, userID).Scan(&userBookingCount); err != nil {
log.Printf("Failed to scan user completed booking count: %v", err)
}
var milestoneCampaignID, milestoneName string
var milestonePercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, userID).Scan(&milestoneCampaignID, &milestonePercent, &milestoneName); err != nil {
log.Printf("Failed to query milestone campaign for user %s, count %d: %v", userID, userBookingCount, err)
}
if milestoneCampaignID != "" && !existingSources["campaign|"+milestoneCampaignID] {
mt := "per_user_booking_count"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: milestoneName,
Percent: milestonePercent,
Amount: roundTo2(bookingTotal * milestonePercent / 100),
SourceID: milestoneCampaignID,
CampaignType: "milestone",
MilestoneType: &mt,
})
}
// Anniversary milestone: the first qualifying campaign for the user's
// first visit, matched by elapsed time. Only the FIRST match is applied
// (the apply path historically broke after one anniversary discount).
var firstVisitDate time.Time
if err := q.QueryRow(ctx, `
SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'
`, userID).Scan(&firstVisitDate); err != nil {
log.Printf("Failed to scan first visit date: %v", err)
}
if !firstVisitDate.IsZero() {
type annCamp struct {
id string
pct float64
value int
unit string
name string
}
annRows, err := q.Query(ctx, `
SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, userID)
if err == nil {
var campaigns []annCamp
for annRows.Next() {
var c annCamp
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit, &c.name) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
for _, c := range campaigns {
if existingSources["campaign|"+c.id] {
continue
}
var matches bool
elapsed := time.Since(firstVisitDate)
switch c.unit {
case "months":
matches = int(elapsed.Hours()/(30*24)) >= c.value
case "years":
matches = int(elapsed.Hours()/(365.25*24)) >= c.value
}
if matches {
mt := "anniversary"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: c.name,
Percent: c.pct,
Amount: roundTo2(bookingTotal * c.pct / 100),
SourceID: c.id,
CampaignType: "milestone",
MilestoneType: &mt,
})
break
}
}
} else {
log.Printf("Failed to query anniversary campaigns: %v", err)
}
}
// Global booking-count milestone — only applies when the booking's first
// real payment was taken in person (in_person_card).
var firstPaymentMethod string
if err := q.QueryRow(ctx, `
SELECT payment_method FROM payments WHERE booking_id = $1 AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC LIMIT 1
`, bookingID).Scan(&firstPaymentMethod); err == nil && firstPaymentMethod == "in_person_card" {
var globalCount int
if err := q.QueryRow(ctx, `
SELECT COUNT(*) FROM bookings WHERE status = 'completed'
`).Scan(&globalCount); err != nil {
log.Printf("Failed to scan global completed booking count: %v", err)
}
var globalCampaignID, globalName string
var globalPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value <= $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE source_id = discount_campaigns.id AND booking_id = $2)
ORDER BY milestone_value DESC LIMIT 1
`, globalCount, bookingID).Scan(&globalCampaignID, &globalPercent, &globalName); err != nil {
log.Printf("Failed to query global milestone campaign: %v", err)
}
if globalCampaignID != "" && !existingSources["campaign|"+globalCampaignID] {
mt := "global_booking_count"
discounts = append(discounts, EligibleDiscount{
Source: "campaign",
Name: globalName,
Percent: globalPercent,
Amount: roundTo2(bookingTotal * globalPercent / 100),
SourceID: globalCampaignID,
CampaignType: "milestone",
MilestoneType: &mt,
})
}
}
// Referrer's unused referral discount.
var rdID string
var rdPercent float64
if err := q.QueryRow(ctx, `
SELECT id, discount_percent FROM referral_discounts
WHERE user_id = $1 AND used = FALSE
LIMIT 1
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
if !existingSources["referral|"+rdID] {
discounts = append(discounts, EligibleDiscount{
Source: "referral",
Name: "Referral Discount (10%)",
Percent: rdPercent,
Amount: roundTo2(bookingTotal * rdPercent / 100),
SourceID: rdID,
IsReferral: true,
})
}
}
return discounts
}
// ApplyEligibleDiscount persists a single eligible discount for the booking:
// the booking_discounts row, the discount payment record, and the campaign
// redemption counter (or the referral used flag). The caller holds the payment
// transaction so these writes commit atomically with the payment. It is
// idempotent per booking because ComputeEligibleDiscounts excludes discounts
// whose source_id is already recorded for the booking.
func ApplyEligibleDiscount(ctx context.Context, q db.Querier, bookingID, userID string, bookingTotal float64, d EligibleDiscount) {
if d.IsReferral {
if _, err := q.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, 'referral', $3, NULL, NULL, $4, $5, $6)
`, bookingID, userID, d.SourceID, d.Percent, bookingTotal, d.Amount); err != nil {
log.Printf("Failed to insert referral discount: %v", err)
return
}
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, d.Amount, userID); err != nil {
// The booking_discounts row was already inserted in this tx, so the
// referral discount WAS redeemed — the used flag must still be set
// below. Log ALERT and fall through to the UPDATE instead of
// returning early (a lost used-flag would let the same referral
// discount apply to a future booking).
log.Printf("ALERT: failed to insert discount payment record for referral %s, booking %s: %v", d.SourceID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1
`, d.SourceID); err != nil {
log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err)
}
return
}
var milestoneType any
if d.MilestoneType != nil {
milestoneType = *d.MilestoneType
}
if _, err := q.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, $4, $5, $6, $7, $8)
`, bookingID, userID, d.SourceID, d.CampaignType, milestoneType, d.Percent, bookingTotal, d.Amount); err != nil {
log.Printf("Failed to insert %s campaign discount: %v", d.CampaignType, err)
return
}
if _, err := q.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, d.Amount, userID); err != nil {
// The booking_discounts row was already inserted in this tx, so the
// campaign WAS redeemed — the times_redeemed counter must still be
// incremented below. Log ALERT and fall through to the UPDATE instead
// of returning early (a lost increment would let the campaign exceed
// its max_redemptions cap).
log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
if _, err := q.Exec(ctx, `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, d.SourceID); err != nil {
log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", d.SourceID, bookingID, err)
}
}
+41 -14
View File
@@ -75,13 +75,14 @@ type TransferGiftCardRequest struct {
}
type BuyGiftCardRequest struct {
Amount int64 `json:"amount"`
RecipientType string `json:"recipient_type"`
RecipientEmail string `json:"recipient_email,omitempty"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key"`
Amount int64 `json:"amount"`
RecipientType string `json:"recipient_type"`
RecipientEmail string `json:"recipient_email,omitempty"`
CardID *string `json:"card_id,omitempty"`
NewCardToken *string `json:"new_card_token,omitempty"`
SaveCard bool `json:"save_card"`
IdempotencyKey string `json:"idempotency_key"`
VerificationToken *string `json:"verification_token,omitempty"`
}
type RedeemGiftCardRequest struct {
@@ -895,6 +896,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
paymentService := NewPaymentService()
// Serialize gift-card purchase attempts on the idempotency key to prevent
@@ -971,7 +978,21 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
var savedCardID *string
if req.NewCardToken != nil && *req.NewCardToken != "" {
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken)
// P14: when the card is being SAVED, provision (or reuse) the user's
// Square customer profile BEFORE tokenizing so the new card is created
// against that customer. One-off non-save charges pass "" — a cnon:
// nonce charge needs no customer.
squareCustomerID := ""
if req.SaveCard {
var custErr error
squareCustomerID, custErr = paymentService.EnsureSquareCustomer(ctx, userID)
if custErr != nil {
log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
return
}
}
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID)
if err != nil {
log.Printf("Failed to create card on file: %v", err)
http.Error(w, "Failed to process card", http.StatusInternalServerError)
@@ -1087,13 +1108,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err)
}
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
paymentReq := square.CreatePaymentReq{
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card Purchase",
BuyerEmail: buyerEmail,
Amount: req.Amount,
Currency: "GBP",
SourceID: sourceID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card Purchase",
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
}
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,473 @@
//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"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/require"
)
// ---------------------------------------------------------------------------
// Test clients
// ---------------------------------------------------------------------------
// fixedCardClient returns the same Square card id for every CreateCardOnFile
// call, simulating a card token that tokenizes to the same Square card for two
// different users (the cross-user saved-card collision scenario).
type fixedCardClient struct {
square.SquareClient
fixedCardID string
}
func (c *fixedCardClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) {
return &square.CardOnFile{
ID: c.fixedCardID,
CardID: c.fixedCardID,
Brand: "VISA",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
Fingerprint: "sqfp_shared",
}, nil
}
// recordingCustomerClient counts CreateCustomer calls per email and returns a
// deterministic customer id, so tests can assert provisioning happens exactly
// once and the stored id is reused.
type recordingCustomerClient struct {
square.SquareClient
mu sync.Mutex
createCalls []string
customerSeq int
customerByID map[string]*square.CustomerResult
}
func (c *recordingCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.customerByID == nil {
c.customerByID = map[string]*square.CustomerResult{}
}
if existing, ok := c.customerByID[email]; ok {
return existing, nil
}
c.customerSeq++
res := &square.CustomerResult{ID: fmt.Sprintf("cus_mock_%d", c.customerSeq), Email: email}
c.customerByID[email] = res
c.createCalls = append(c.createCalls, email)
return res, nil
}
func (c *recordingCustomerClient) customerCalls() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.createCalls...)
}
// definitiveChargeClient simulates a Square charge rejection that can never
// succeed (declined) — a definitive failure.
type definitiveChargeClient struct {
square.SquareClient
}
func (c *definitiveChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("square: POST /v2/payments: [PAYMENT_ERROR/CARD_DECLINED] card declined")
}
// ambiguousChargeClient simulates a transport-level charge failure where Square
// may or may not have processed the payment — an ambiguous failure.
type ambiguousChargeClient struct {
square.SquareClient
}
func (c *ambiguousChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
return nil, fmt.Errorf("network error: connection reset by peer")
}
// ---------------------------------------------------------------------------
// Cross-user saved-card collision (schema + upsert fix)
// ---------------------------------------------------------------------------
func TestCreatePaymentMethodFromToken_CrossUserSameCard_DoesNotMutateOtherUserRow(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userA, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
userB, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
origClient := SquareClient
SquareClient = &fixedCardClient{SquareClient: square.NewDevClient(), fixedCardID: "ccof:shared_card"}
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
// User A saves the card, then deletes it (soft delete + retention).
cardA, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
require.NoError(t, err)
require.NoError(t, svc.DeletePaymentMethod(ctx, cardA.ID, userA))
var aDeletedAt string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&aDeletedAt))
require.NotEqual(t, "", aDeletedAt, "user A's card must be soft-deleted")
// User B tokenizes the SAME card. With the old global UNIQUE(square_card_id)
// this upsert targeted A's row — reviving A's deleted card, clearing its
// retention, and returning A's card id to B.
cardB, err := svc.CreatePaymentMethodFromToken(ctx, userB, "cnon:shared")
require.NoError(t, err)
require.NotEqual(t, cardA.ID, cardB.ID, "user B must get their own saved-card row, not user A's")
// Exactly two rows for the shared Square card (one per user).
var rows int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE square_card_id = 'ccof:shared_card'`).Scan(&rows))
require.Equal(t, 2, rows)
// A's row is still owned by A and still deleted — never mutated by B.
var ownerA, deletedA string
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&ownerA, &deletedA))
require.Equal(t, userA, ownerA)
require.NotEqual(t, "", deletedA, "user A's deleted card must not be revived by user B")
// B's row is active and owned by B.
var ownerB, deletedB string
require.NoError(t, tx.QueryRow(ctx, `SELECT user_id, COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardB.ID).Scan(&ownerB, &deletedB))
require.Equal(t, userB, ownerB)
require.Equal(t, "", deletedB)
// Same-user retry still revives the deleted row (N-8 preserved): user A
// re-tokenizes the same card → the existing row comes back, not a new one.
cardARetry, err := svc.CreatePaymentMethodFromToken(ctx, userA, "cnon:shared")
require.NoError(t, err)
require.Equal(t, cardA.ID, cardARetry.ID, "same-user re-tokenize must revive the existing row")
var revivedDeleted string
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardA.ID).Scan(&revivedDeleted))
require.Equal(t, "", revivedDeleted)
}
// ---------------------------------------------------------------------------
// Till sale gift-card clawback on definitive charge failure
// ---------------------------------------------------------------------------
func TestCreateTillSale_DefinitiveFailure_ClawsBackCreatedGiftCard(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-clawback-create",
}
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)
// Definitive rejection — the sale is marked failed immediately (not left
// pending for the sweep), so a same-key retry cannot re-complete against a
// gift card that no longer exists.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-create").Scan(&status))
require.Equal(t, "failed", status)
// The created gift card was clawed back (deleted).
var gcCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1
`, "till-clawback-create").Scan(&gcCount))
require.Equal(t, 0, gcCount)
// And its purchase transaction is gone too.
var txCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_card_transactions gct
JOIN till_sales ts ON gct.reference_id = ts.id
WHERE ts.idempotency_key = $1
`, "till-clawback-create").Scan(&txCount))
require.Equal(t, 0, txCount)
}
func TestCreateTillSale_DefinitiveFailure_ClawsBackTopUp(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
var gcID 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(&gcID))
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
GiftCardID: &gcID,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-clawback-topup",
}
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)
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-clawback-topup").Scan(&status))
require.Equal(t, "failed", status)
// The top-up was reversed: the card is back to its pre-sale £50.
var remaining float64
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, gcID).Scan(&remaining))
require.Equal(t, 50.00, remaining)
// This request's top-up transaction is gone (prior accounting untouched).
var txCount int
require.NoError(t, tx.QueryRow(ctx, `
SELECT COUNT(*) FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale'
`, gcID).Scan(&txCount))
require.Equal(t, 0, txCount)
}
func TestCreateTillSale_DefinitiveFailure_ClawsBackRedeemedCard(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
redeemUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &definitiveChargeClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
RedeemToUserID: &redeemUserID,
IdempotencyKey: "till-clawback-redeem",
}
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 redeemed-to-account credit was reversed.
var balance float64
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1`, redeemUserID).Scan(&balance))
require.Equal(t, 0.00, balance)
}
func TestCreateTillSale_AmbiguousFailure_LeavesCardFundedPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
SquareClient = &ambiguousChargeClient{SquareClient: square.NewDevClient()}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:test-card",
IdempotencyKey: "till-ambiguous",
}
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)
// Ambiguous failure — the sale stays pending for the sweep, NOT failed.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, "till-ambiguous").Scan(&status))
require.Equal(t, "pending", status)
// The gift card stays funded so a late retry can complete the sale.
var remaining float64
require.NoError(t, tx.QueryRow(ctx, `
SELECT amount_remaining FROM gift_cards gc
JOIN till_sales ts ON gc.id = ts.item_id
WHERE ts.idempotency_key = $1
`, "till-ambiguous").Scan(&remaining))
require.Equal(t, 50.00, remaining)
}
// ---------------------------------------------------------------------------
// Remaining balance excludes tips
// ---------------------------------------------------------------------------
func TestGetBookingRemainingBalanceCents_ExcludesTips(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
svc := NewPaymentService()
initial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
require.NoError(t, err)
require.Positive(t, initial)
// £20 partial payment reduces the remaining balance.
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "partial",
PaymentMethod: "cash",
Status: "completed",
Amount: 20.00,
}, nil)
require.NoError(t, err)
afterPartial, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, initial-2000, afterPartial)
// A £5 tip must NOT reduce the remaining balance — it is not payment toward
// the booking total.
_, err = svc.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "tip",
PaymentMethod: "online_square",
Status: "completed",
Amount: 5.00,
}, nil)
require.NoError(t, err)
afterTip, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, afterPartial, afterTip, "a tip must not count toward the paid balance")
}
// ---------------------------------------------------------------------------
// Customer provisioning on save (P14)
// ---------------------------------------------------------------------------
func TestCreatePaymentMethodFromToken_ProvisionsCustomerOnceAndReuses(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
origClient := SquareClient
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
svc := NewPaymentService()
card1, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:visa")
require.NoError(t, err)
card2, err := svc.CreatePaymentMethodFromToken(ctx, userID, "cnon:mastercard")
require.NoError(t, err)
require.Len(t, rec.customerCalls(), 1, "customer must be created exactly once and then reused from the stored id")
var cid1, cid2 string
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card1.ID).Scan(&cid1))
require.NoError(t, tx.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, card2.ID).Scan(&cid2))
require.NotEmpty(t, cid1)
require.Equal(t, cid1, cid2, "both saved cards must share the user's Square customer id")
}
func TestBuyGiftCard_NoSaveCard_NoCustomerProvisioned(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateUserToken(userID)
origClient := SquareClient
rec := &recordingCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
newToken := "cnon:test-card"
reqBody := BuyGiftCardRequest{
Amount: 1000,
RecipientType: "self",
NewCardToken: &newToken,
SaveCard: false,
IdempotencyKey: "buy-gc-nosave-key",
}
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", reqBody, token, ctx)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
require.Empty(t, rec.customerCalls(), "one-off buy must not provision a Square customer")
var cardCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount))
require.Zero(t, cardCount, "one-off buy must not persist a saved card")
}
@@ -0,0 +1,928 @@
//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)
}
}
@@ -0,0 +1,614 @@
//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")
}
+5 -5
View File
@@ -424,7 +424,7 @@ func TestOnlinePayment_SavedCard(t *testing.T) {
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_mock_card_123", "VISA", "4242")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
@@ -2360,7 +2360,7 @@ func TestTipPayment_WithSavedCard(t *testing.T) {
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof_mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved')
VALUES ($1, 'ccof:mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
@@ -2401,7 +2401,7 @@ func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) {
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
@@ -2458,7 +2458,7 @@ func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) {
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
@@ -2510,7 +2510,7 @@ func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) {
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
+96 -225
View File
@@ -98,10 +98,6 @@ func CalculateRefundForCancellation(
}
}
// ProcessCancellationRefund calculates and records refunds for a cancelled booking.
// It processes refunds against completed payments on the booking up to the
// calculated refundable amount, creating refund records in the database.
// Returns the refund calculation and whether any refunds were processed.
// lockCancellationPayments serializes a cancellation refund against the manual
// RefundPayment handler and the sweep. Both hold
// `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level)
@@ -211,6 +207,20 @@ func ProcessCancellationRefundTx(
// Prior refunds per payment record (completed + pending) — the loop must
// not re-refund money already returned. Sums by payment_id; pending counts
// because a Square call may already be in flight.
//
// This DB-side over-refund guard (completed + pending) is what prevents
// Square's REFUND_AMOUNT_INVALID in practice: a refund is never issued past
// the residual `amount - already`. Both this cancellation path and the
// manual RefundPayment handler compute residuals while holding the same
// advisory lock (`hashtext('crussell:refund:' || payment_id)` — see
// lockCancellationPayments), so a manual refund cannot slip past the guard
// and a cancellation refund cannot be recorded after the manual guard ran
// without the two serializing. Square no longer documents
// PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is
// REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined
// (definitive) — the charge-group/manual sweep handlers fail those rows and
// surface them via admin_notification rather than silently blocking the
// amount in the guard.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(ctx, `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
@@ -368,6 +378,14 @@ func ProcessCancellationRefundTx(
return &calc, nil
}
// ProcessCancellationRefund calculates and records refunds for a cancelled
// booking, processing refunds against the booking's completed payments up to
// the calculated refundable amount. The wrapper owns its own transaction and
// delegates the refund loop to ProcessCancellationRefundTx
// (forceFullRefund=false) so the standalone and in-transaction callers share
// one implementation; after a successful commit it runs the post-commit
// Square pass (ProcessPendingSquareRefunds). Returns the refund calculation
// and whether any refunds were processed.
func ProcessCancellationRefund(
ctx context.Context,
bookingID string,
@@ -395,215 +413,15 @@ func ProcessCancellationRefund(
}
}()
// Get the booking's user info for refund routing.
var bookingUserID string
var isGuest bool
if err := tx.QueryRow(ctx, `
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id = $1
`, bookingID).Scan(&bookingUserID, &isGuest); err != nil {
log.Printf("Failed to get booking user info for refund: %v", err)
// Non-fatal — we'll still process Square refunds but skip balance credits.
}
rows, err := tx.Query(ctx, `
SELECT id, amount, payment_method, square_payment_id, gift_card_id
FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for refund: %v", err)
return &calc, nil
}
defer rows.Close()
// Read all payments into a slice, then close rows immediately.
// This avoids "conn busy" errors when db.Conn.QueryRow/Exec are called
// inside the processing loop with a per-test transaction (pgx.Tx does not
// support concurrent queries on the same connection).
var payments []paymentRow
for rows.Next() {
var p paymentRow
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
payments = append(payments, p)
}
if err := rows.Err(); err != nil {
log.Printf("Payment row iteration error: %v", err)
}
// Serialize against the manual RefundPayment handler and the sweep. If any
// lock fails, log + return the error — the caller (bookings.go
// DeleteBookingHandler) aborts the cancellation so the user can retry;
// continuing without the lock reopens the over-refund race.
if err := lockCancellationPayments(ctx, tx, payments); err != nil {
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, err)
return &calc, err
}
refundRemaining := calc.RefundableAmount
// Prior refunds per payment record (completed + pending) — the loop must
// not re-refund money already returned. Sums by payment_id; pending counts
// because a Square call may already be in flight.
priorRefunds := make(map[string]float64)
prRows, prErr := tx.Query(ctx, `
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
WHERE booking_id = $1 AND status IN ('completed', 'pending')
GROUP BY payment_id`, bookingID)
if prErr != nil {
log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr)
} else {
for prRows.Next() {
var pid string
var amt float64
if err := prRows.Scan(&pid, &amt); err == nil {
priorRefunds[pid] = amt
}
}
prRows.Close()
}
for _, p := range payments {
if refundRemaining <= 0 {
break
}
paymentID := p.ID
paymentMethod := p.PaymentMethod
amount := p.Amount
giftCardID := p.GiftCardID
already := priorRefunds[paymentID]
residual := math.Round((amount-already)*100) / 100
if residual <= 0 {
// already fully refunded — don't consume refundRemaining
continue
}
refundThisPayment := math.Min(residual, refundRemaining)
var squareRefundID *string
switch paymentMethod {
case "online_square", "in_person_card":
// Square API refund is processed AFTER the transaction commits
// (see ProcessPendingSquareRefunds). Inside the tx we only record
// the refund record as "pending" for post-commit processing.
if isGuest || bookingUserID == "" {
log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment)
}
// squareRefundID stays nil — will be set by ProcessPendingSquareRefunds
case "giftcard":
if giftCardID == nil || *giftCardID == "" {
log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID)
break
}
var expired bool
if err := tx.QueryRow(ctx, `
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
FROM gift_cards WHERE id = $1
`, *giftCardID).Scan(&expired); err != nil {
log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err)
} else if expired {
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
break
}
if _, err := tx.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW()
WHERE id = $2
`, refundThisPayment, *giftCardID); err != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err)
break
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
`, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil {
log.Printf("Failed to create gift card transaction for refund: %v", err)
}
case "cash":
if isGuest || bookingUserID == "" {
log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment)
} else {
log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID)
if _, balErr := tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
balance = user_giftcard_balances.balance + EXCLUDED.balance,
updated_at = NOW()
`, bookingUserID, refundThisPayment); balErr != nil {
log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr)
}
}
default:
// discount, on_the_house — no real money to refund.
log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod)
}
recordStatus := "completed"
if paymentMethod == "online_square" || paymentMethod == "in_person_card" {
recordStatus = "pending"
}
record := RefundRecord{
PaymentID: paymentID,
BookingID: bookingID,
Amount: refundThisPayment,
SquareRefundID: squareRefundID,
Status: recordStatus,
Reason: reason,
Origin: "cancellation",
CreatedBy: actorID,
CreatedAt: clock.Now(),
}
// Deterministic idempotency key so a scheduler retry can never issue a
// second Square refund. Format never collides with the handler's
// "-refund-" keys.
refundKey := paymentID + "-square-" + strconv.FormatInt(int64(math.Round(refundThisPayment*100)), 10)
record.IdempotencyKey = &refundKey
tag, dbErr := tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (idempotency_key) DO NOTHING
`, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.IdempotencyKey, record.CreatedBy, record.CreatedAt, record.Origin)
if dbErr != nil {
log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr)
continue
}
// tag.RowsAffected() == 0 means the same idempotency_key already exists
// (a prior refund row for this payment+amount in 'failed' state — money
// never moved, but a row exists). Dedup — skip WITHOUT consuming
// refundRemaining so the loop can allocate to the next payment, exactly
// as the pre-ON-CONFLICT UNIQUE-violation path behaved.
if tag.RowsAffected() == 0 {
log.Printf("Refund for payment %s amount £%.2f already exists (idempotency dedup) — skipping without consuming refundRemaining", paymentID, refundThisPayment)
continue
}
refundRemaining -= refundThisPayment
}
if bookingUserID != "" {
var loyaltyUsed bool
if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil {
log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err)
} else if loyaltyUsed {
_, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if loyaltyErr != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr)
} else {
log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID)
}
}
// Delegate the whole refund loop to the transactional variant — the
// non-tx wrapper exists only to own the transaction lifecycle and fire the
// post-commit Square pass (ProcessPendingSquareRefunds) after a successful
// commit. The Tx variant returns an error ONLY on lock failure; every other
// failure logs internally and returns (calc, nil).
res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false)
if txErr != nil {
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr)
return &calc, txErr
}
if cErr := tx.Commit(ctx); cErr != nil {
@@ -615,7 +433,7 @@ func ProcessCancellationRefund(
// This ensures Square API calls only happen if the DB records persist.
ProcessPendingSquareRefunds(ctx, bookingID, reason)
return &calc, nil
return res, nil
}
// ProcessPendingSquareRefunds resolves a booking's pending cancellation card
@@ -1166,8 +984,13 @@ func idsOf(rows []pendingChargeRow) []string {
return ids
}
// manualPendingRow is one stale manual refund row (the handler's ambiguous-error
// path) eligible for retry by the sweep.
// manualPendingRow is one stale manual refund row eligible for the sweep's
// resolution. It covers BOTH pending shapes the RefundPayment handler can
// leave behind:
// - square_refund_id set: the handler's synchronous-PENDING response (Square
// already holds the refund, status='pending') — reconciled, never re-issued.
// - square_refund_id NULL: the handler's ambiguous-error path — re-issued
// with the row's OWN stored idempotency key.
type manualPendingRow struct {
ID string
PaymentID string
@@ -1175,23 +998,27 @@ type manualPendingRow struct {
IdempotencyKey string
Reason string
SquarePaymentID string
SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id
CreatedAt time.Time
}
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
// by the RefundPayment handler's ambiguous-error path. The cancellation passes
// filter origin='cancellation', so manual rows were never re-attempted: they
// by the RefundPayment handler. The cancellation passes filter
// origin='cancellation', so manual rows were never re-attempted: they
// permanently blocked the over-refund guard and depressed booking TotalPaid.
// Each row is retried with its OWN stored idempotency key (Square dedups
// same-key retries, so the retry is idempotent).
// Rows are split per-row by their stored square_refund_id: rows WITH one (the
// handler's synchronous-PENDING response) are reconciled at Square — never
// re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with
// their OWN stored idempotency key (Square dedups same-key retries, so the
// retry is idempotent).
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
rows, err := db.Conn.Query(ctx, `
SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason,
p.square_payment_id, r.created_at
p.square_payment_id, r.square_refund_id, r.created_at
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.status = 'pending' AND r.origin = 'manual'
AND r.refund_attempts < 3 AND r.square_refund_id IS NULL
AND r.refund_attempts < 3
AND p.square_payment_id IS NOT NULL
ORDER BY r.payment_id, r.id
`)
@@ -1203,13 +1030,17 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
for rows.Next() {
var pr manualPendingRow
var key *string
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedAt); err != nil {
var sqRefundID *string
if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil {
log.Printf("Failed to scan manual pending refund: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
rows.Close()
@@ -1273,7 +1104,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
// cap are eligible (a concurrent manual refund may have resolved some).
prRows, err := db.Conn.Query(ctx, `
SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at,
r.payment_id, p.square_payment_id
r.payment_id, p.square_payment_id, r.square_refund_id
FROM refunds r
JOIN payments p ON p.id = r.payment_id
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3
@@ -1286,13 +1117,17 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
for prRows.Next() {
var pr manualPendingRow
var key *string
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID); err != nil {
var sqRefundID *string
if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil {
log.Printf("Failed to scan manual pending refund under lock: %v", err)
continue
}
if key != nil {
pr.IdempotencyKey = *key
}
if sqRefundID != nil {
pr.SquareRefundID = *sqRefundID
}
pending = append(pending, pr)
}
prRows.Close()
@@ -1350,6 +1185,42 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
for i := range pending {
pr := &pending[i]
amountCents := int64(math.Round(pr.Amount * 100))
// Row WITH a stored square_refund_id — the RefundPayment handler's
// synchronous-PENDING response. Square already holds the refund, so a
// re-issue would risk a SECOND refund (Square's key dedup does not
// protect a fresh key). Reconcile instead: an exact COMPLETED refund at
// Square resolves the row; a genuine no-match means Square never
// recorded it → failed + admin notification; a reconcile error is an
// UNKNOWN state → leave pending (never mark failed on an unknown state,
// that would let the over-refund guard exclude money that may have
// moved). Mirrors the 23h age-guard branch above.
if pr.SquareRefundID != "" {
sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt)
switch {
case rcErr != nil:
log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr)
case sqRefundID != nil:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'completed', square_refund_id = $1
WHERE id = $2 AND status = 'pending'
`, *sqRefundID, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr)
}
processed++
default:
if _, upErr := db.Conn.Exec(ctx, `
UPDATE refunds SET status = 'failed'
WHERE id = $1 AND status = 'pending'
`, pr.ID); upErr != nil {
log.Printf("Failed to mark manual refund %s failed after Square reconcile showed no refund: %v", pr.ID, upErr)
}
insertRefundFailedNotifications(ctx, []string{pr.ID})
log.Printf("Manual refund %s (square_refund_id %s) has no COMPLETED refund at Square — marked 'failed' and admin notified; TODO email user+admin to VERIFY the Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, pr.SquareRefundID)
}
continue
}
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.SquarePaymentID,
Amount: amountCents,
+280
View File
@@ -1943,6 +1943,17 @@ func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *test
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
@@ -2028,6 +2039,17 @@ func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T)
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
@@ -2568,6 +2590,17 @@ func TestSweepPendingSquareRefunds_RetriesStaleManualRefund(t *testing.T) {
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
@@ -2665,6 +2698,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund(t *testi
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
@@ -2751,6 +2795,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending(t *te
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()}
counting := &countingRefundClient{SquareClient: recErr}
@@ -2836,6 +2891,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T)
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
// The MockClient records no refunds for this charge → reconcile returns a
// genuine no-match (nil, nil).
@@ -2872,3 +2938,217 @@ func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T)
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// =============================================================================
// F1 — manual refunds with a stored square_refund_id are reconciled, not dropped
// =============================================================================
// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled locks the
// F1 fix: a manual refund the RefundPayment handler left 'pending' WITH a
// stored square_refund_id (Square's synchronous-PENDING response) is no longer
// filtered out of the sweep — it is reconciled against Square instead. When
// Square reports the exact COMPLETED refund, the row resolves to 'completed'
// and NO new refund is issued (re-issuing would risk a second refund).
func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
chargeID := "sqp_manual_reconcile_completed"
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_completed', NOW() - INTERVAL '5 minutes')
RETURNING id
`, paymentID, bookingID, paymentID+"-refund-5000").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
// amount, same payment. Simulates the handler's PENDING refund that has
// since completed at Square.
seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{
PaymentID: chargeID,
Amount: 5000,
IdempotencyKey: "seed-manual-reconcile-completed",
Reason: "customer request",
})
if err != nil {
t.Fatalf("failed to seed completed Square refund: %v", err)
}
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
// The sweep processes the whole shared test database — clear pending rows
// left by earlier sequential tests so the call count is deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
t.Fatalf("failed to clean leftover pending refunds: %v", err)
}
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
var status string
var squareRefundID *string
err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected manual refund with square_refund_id resolved to 'completed' via Square reconcile, got %q", status)
}
if squareRefundID == nil || *squareRefundID != seeded.ID {
t.Errorf("expected square_refund_id %q (the refund Square recorded), got %v", seeded.ID, squareRefundID)
}
// The reconcile must NOT have re-issued a new Square refund.
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO new Square refund call for a reconcilable row, got %d", len(calls))
}
}
// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed locks
// the F1 no-match branch: a manual refund row WITH a stored square_refund_id
// whose Square reconcile finds no exact COMPLETED refund (Square never
// recorded it) is marked 'failed' and surfaced via admin_notification — it no
// longer blocks the over-refund guard forever.
func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
chargeID := "sqp_manual_reconcile_nomatch"
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_nomatch', NOW() - INTERVAL '5 minutes')
RETURNING id
`, paymentID, bookingID, paymentID+"-refund-5001").Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool — clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = 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)
})
origClient := SquareClient
// The MockClient has no refund for this charge → reconcile is a genuine
// no-match (nil, nil).
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
freshCtx := context.Background()
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil {
t.Fatalf("failed to clean leftover pending refunds: %v", err)
}
if _, err := SweepPendingSquareRefunds(freshCtx); err != nil {
t.Fatalf("SweepPendingSquareRefunds failed: %v", err)
}
var status string
err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "failed" {
t.Errorf("expected manual refund with no COMPLETED refund at Square marked 'failed', got %q", status)
}
// The no-match reconcile must NOT re-issue a new Square refund.
if calls := counting.refundCalls(); len(calls) != 0 {
t.Errorf("expected NO Square refund call on a no-match reconcile, got %d", len(calls))
}
// The terminal failure must surface an admin_notifications row.
var notifCount int
err = db.Conn.QueryRow(freshCtx,
`SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin_notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
+111 -34
View File
@@ -17,14 +17,20 @@ import (
)
type SavedCard struct {
ID string `json:"id"`
SquareCardID string `json:"square_card_id"`
Brand string `json:"brand"`
Last4 string `json:"last_4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Fingerprint string `json:"fingerprint"`
IsDefault bool `json:"is_default"`
ID string `json:"id"`
// SquareCustomerID is the user's provisioned Square customer profile id
// (P14), persisted on the row the first time the user saves a card. It is
// forwarded to CreatePayment as CustomerID on saved-card (ccof:) charges,
// which Square requires for card-on-file payments. Empty for rows created
// before provisioning was introduced.
SquareCustomerID string `json:"square_customer_id,omitempty"`
SquareCardID string `json:"square_card_id"`
Brand string `json:"brand"`
Last4 string `json:"last_4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Fingerprint string `json:"fingerprint"`
IsDefault bool `json:"is_default"`
}
type PaymentService struct{}
@@ -472,6 +478,9 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
SELECT COALESCE(SUM(amount), 0) AS paid_pounds
FROM payments
WHERE booking_id = $1 AND status = 'completed'
-- A tip is money paid beyond the booking total — it does not
-- reduce the balance owed, so it must not count as "paid".
AND payment_type <> 'tip'
)
SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint
FROM booking_total bt, paid_total pt
@@ -484,7 +493,7 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
rows, err := db.Conn.Query(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '')
FROM user_saved_cards
WHERE user_id = $1 AND deleted_at IS NULL
ORDER BY is_default DESC, created_at DESC
@@ -498,7 +507,7 @@ func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID strin
var cards []SavedCard
for rows.Next() {
var c SavedCard
err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault, &c.SquareCustomerID)
if err != nil {
return nil, err
}
@@ -546,55 +555,123 @@ func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userI
// PCI-DSS: raw PANs are never accepted. The client must supply a Square
// Web Payments nonce (cnon:xxx), which the backend tokenizes via the
// Cards API — the full PAN exists only inside Square's vault.
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken)
//
// P14: this endpoint saves a card, so lazily ensure the user has a Square
// customer profile BEFORE the card is tokenized — if provisioning fails the
// card cannot be saved, so abort with a clear error instead of creating an
// orphan card at Square. One-off (non-save) payments never call this.
squareCustomerID, err := s.ensureSquareCustomer(ctx, userID)
if err != nil {
return nil, err
}
cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken, squareCustomerID)
if err != nil {
return nil, fmt.Errorf("failed to tokenize card: %w", err)
}
var savedCardID string
var isDefault bool
// ON CONFLICT (square_card_id): a response-lost retry re-tokenizes the same
// card (CreateCardOnFile's deterministic key returns the same ccof: id), so
// the UNIQUE constraint would otherwise 500 on the duplicate. Upsert instead
// so the retry returns the existing saved card (N-8).
// ON CONFLICT (user_id, square_card_id): a response-lost retry re-tokenizes
// the same card for the SAME user (CreateCardOnFile's deterministic key
// returns the same ccof: id), so the per-user UNIQUE constraint would
// otherwise 500 on the duplicate. Upsert instead so the retry returns the
// existing saved card (N-8). The conflict target is scoped per user — a
// card tokenized by user B that user A already saved is a brand-new row for
// B, never a mutation of A's row.
err = db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
SELECT $1, $2, $3, $4, $5, $6, $7,
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, square_customer_id, is_default)
SELECT $1, $2, $3, $4, $5, $6, $7, $8,
NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL)
ON CONFLICT (square_card_id) DO UPDATE SET
ON CONFLICT (user_id, square_card_id) DO UPDATE SET
brand = EXCLUDED.brand,
last_4 = EXCLUDED.last_4,
exp_month = EXCLUDED.exp_month,
exp_year = EXCLUDED.exp_year,
fingerprint = EXCLUDED.fingerprint,
square_customer_id = EXCLUDED.square_customer_id,
deleted_at = NULL,
retained_until = NULL
WHERE user_saved_cards.user_id = EXCLUDED.user_id
RETURNING id, is_default
`, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault)
`, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint, squareCustomerID).Scan(&savedCardID, &isDefault)
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
return &SavedCard{
ID: savedCardID,
SquareCardID: cardOnFile.CardID,
Brand: cardOnFile.Brand,
Last4: cardOnFile.Last4,
ExpMonth: cardOnFile.ExpMonth,
ExpYear: cardOnFile.ExpYear,
Fingerprint: cardOnFile.Fingerprint,
IsDefault: isDefault,
ID: savedCardID,
SquareCustomerID: squareCustomerID,
SquareCardID: cardOnFile.CardID,
Brand: cardOnFile.Brand,
Last4: cardOnFile.Last4,
ExpMonth: cardOnFile.ExpMonth,
ExpYear: cardOnFile.ExpYear,
Fingerprint: cardOnFile.Fingerprint,
IsDefault: isDefault,
}, nil
}
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
var id string
// ensureSquareCustomer lazily provisions a Square customer profile for the
// user (P14). A customer is only ever minted when a card is being SAVED — the
// saved-card row is the persistence point, and the id is reused for every
// subsequent card save by the same user. Square dedups on a deterministic
// idempotency key derived from the email, so a response-lost retry returns the
// same customer instead of minting a duplicate.
func (s *PaymentService) ensureSquareCustomer(ctx context.Context, userID string) (string, error) {
var customerID sql.NullString
err := db.Conn.QueryRow(ctx, `
SELECT square_customer_id FROM user_saved_cards
WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> ''
ORDER BY created_at DESC
LIMIT 1
`, userID).Scan(&customerID)
if err == nil && customerID.Valid {
return customerID.String, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("failed to look up Square customer id: %w", err)
}
var name, email string
if err := db.Conn.QueryRow(ctx, `
SELECT fn, email FROM users WHERE id = $1
`, userID).Scan(&name, &email); err != nil {
return "", fmt.Errorf("failed to load user for Square customer provisioning: %w", err)
}
customer, err := SquareClient.CreateCustomer(ctx, name, email)
if err != nil {
return "", fmt.Errorf("failed to create Square customer for card save: %w", err)
}
return customer.ID, nil
}
// EnsureSquareCustomer lazily provisions (or reuses) the user's Square customer
// profile, persisting its id on the saved-card row for reuse. Exported for
// handlers that must pass the customer id to CreateCardOnFile in save-card
// flows (P14): Square creates the card against that customer, and subsequent
// saved-card (ccof:) charges carry it as CreatePaymentReq.CustomerID.
func (s *PaymentService) EnsureSquareCustomer(ctx context.Context, userID string) (string, error) {
return s.ensureSquareCustomer(ctx, userID)
}
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
// P14: SaveCardForUser is only ever called in save-card flows, so lazily
// ensure the Square customer exists and persist its id on the saved-card
// row for reuse by subsequent card saves from the same user.
squareCustomerID, err := s.ensureSquareCustomer(ctx, userID)
if err != nil {
return "", err
}
var id string
err = db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (
user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, false, NOW())
user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NOW())
RETURNING id
`, userID, squareCardID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
`, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
if err != nil {
return "", err
@@ -612,10 +689,10 @@ func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string)
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
var c SavedCard
err := q.QueryRow(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '')
FROM user_saved_cards
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
`, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault)
`, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault, &c.SquareCustomerID)
if err != nil {
return nil, err
+379 -28
View File
@@ -2,44 +2,46 @@ package payments
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
)
// SweepStalePendingPayments marks pending payment records that are older than
// Square's idempotency-key retention window (~24h) as 'failed'. A pending
// record means the DB committed but the Square charge outcome is unknown; it
// SweepStalePendingPayments resolves pending payment records that are older
// than Square's idempotency-key retention window (~24h). A pending record
// means the DB committed but the Square charge outcome is unknown; it
// normally resolves on a same-key client retry. But if the client abandoned
// the attempt, the record stays pending forever — and retrying it after the
// key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing
// stale pendings closes that double-charge window: a late retry finds a
// 'failed' record and stops instead of charging again.
//
// Before failing a row, the sweep reconciles it against Square: a
// genuinely-charged row (Square success, DB post-charge failure) with a
// square_payment_id is rescued to 'completed' instead of being swept to
// 'failed' with no automatic resolution — the money would otherwise be lost
// in limbo (MINOR-R3). Reconciliation is deliberately minimal: status +
// updated_at only, no split/VAT recomputation (that is the handler's job; the
// row is >24h stale and this is a reconciliation rescue).
//
// Only online/till card payments can be pending — cash/giftcard/on_the_house
// are committed synchronously and never enter this state. Both the payments
// table and till_sales carry pending card-sale rows and are swept here.
//
// A swept row may have been genuinely charged at Square with a lost response —
// it is flagged with a CRITICAL manual-reconciliation log (like the refund
// sweep) so the money is not silently lost in limbo (MINOR-R3).
const stalePendingPaymentAge = 24 * time.Hour
func SweepStalePendingPayments(ctx context.Context) (int, error) {
cutoff := clock.Now().Add(-stalePendingPaymentAge)
tag, err := db.Conn.Exec(ctx, `
UPDATE payments
SET status = 'failed', updated_at = NOW()
WHERE status = 'pending'
AND created_at < $1
`, cutoff)
payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff)
if err != nil {
return 0, err
}
payCount := int(tag.RowsAffected())
// till_sales rows for card payments (stored as 'online_square' or
// 'in_person_card' in the payment_method enum — saved_card/online_square/
@@ -48,27 +50,376 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) {
// and a retry after key retention would reuse the stored key → Square sees
// an expired key → second charge (R3). Cash / on_the_house are committed
// synchronously and never pending.
tillTag, err := db.Conn.Exec(ctx, `
UPDATE till_sales
SET status = 'failed', updated_at = NOW()
WHERE status = 'pending'
tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff)
if err != nil {
return 0, err
}
total := payCount + tillCount
if total > 0 {
log.Printf("[SWEEP] Resolved %d stale pending payments (%d payments, %d till sales) older than %s — late retries will be rejected, preventing a second Square charge; %d reconciled to completed against Square (%d payments, %d till sales)", total, payCount, tillCount, stalePendingPaymentAge, payCompleted+tillCompleted, payCompleted, tillCompleted)
}
if payCount > 0 {
log.Printf("CRITICAL: %d pending payments swept to failed may have been charged at Square with a lost response — manual reconciliation required before refunding/charging", payCount-payCompleted)
}
if tillCount > 0 {
log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount-tillCompleted)
}
return total, nil
}
// staleRow is one stale pending row read by the sweep so it can reconcile
// rows that carry a Square reference BEFORE failing them.
type staleRow struct {
ID string
SquarePaymentID string
}
// sweepStaleRows resolves the stale pending rows of one table. Rows with a
// square_payment_id are reconciled at Square first (COMPLETED → 'completed',
// anything else → 'failed' exactly as the legacy bulk UPDATE did); rows
// without one cannot be reconciled and are failed directly. Returns the total
// rows resolved and how many were rescued to 'completed'.
func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolved int, completed int, err error) {
switch table {
case "payments", "till_sales":
default:
return 0, 0, fmt.Errorf("sweep: unknown stale table %q", table)
}
// The legacy till_sales sweep only touched card methods — cash and
// on_the_house are committed synchronously and never pending, but keep the
// predicate so behaviour is byte-identical for any unexpected row.
methodFilter := ""
if table == "till_sales" {
methodFilter = ` AND payment_method IN ('online_square', 'in_person_card')`
}
rows, err := db.Conn.Query(ctx, `
SELECT id, COALESCE(square_payment_id, '')
FROM `+table+`
WHERE status = 'pending' AND created_at < $1`+methodFilter+`
`, cutoff)
if err != nil {
return 0, 0, err
}
var stale []staleRow
for rows.Next() {
var r staleRow
if err := rows.Scan(&r.ID, &r.SquarePaymentID); err != nil {
log.Printf("Failed to scan stale pending row from %s: %v", table, err)
continue
}
stale = append(stale, r)
}
rows.Close()
for _, r := range stale {
if r.SquarePaymentID != "" {
switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) {
case staleReconcileCompleted:
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'completed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, r.ID); upErr != nil {
log.Printf("Failed to rescue stale pending row %s to completed: %v", r.ID, upErr)
} else if n := int(tag.RowsAffected()); n > 0 {
resolved++
completed++
}
continue
case staleReconcileLeavePending:
// Square's answer was ambiguous (transport/5xx) — the charge
// may still be in flight at Square. Do NOT touch the row: the
// next sweep run reconciles it again, and a same-key retry
// must still be able to reuse the pending row if the charge
// actually completed.
log.Printf("Stale pending %s row %s left pending (Square reconcile ambiguous) — will retry next sweep", table, r.ID)
continue
}
// staleReconcileDefinitivelyFailed falls through to the fail path.
}
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, r.ID); upErr != nil {
log.Printf("Failed to mark stale pending row %s failed: %v", r.ID, upErr)
} else if n := int(tag.RowsAffected()); n > 0 {
resolved++
}
}
return resolved, completed, nil
}
// staleReconcileResult is the tri-state outcome of reconciling one stale
// pending row against Square. Only a definitively-resolved outcome touches the
// row: an ambiguous answer (transport error / 5xx) leaves it pending so a
// same-key retry can still reuse it if the charge actually completed.
type staleReconcileResult int
const (
// staleReconcileLeavePending — Square's answer was ambiguous; the row stays
// pending for the next sweep run.
staleReconcileLeavePending staleReconcileResult = iota
// staleReconcileCompleted — Square confirms the charge completed; rescue
// the row to 'completed'.
staleReconcileCompleted
// staleReconcileDefinitivelyFailed — Square proves the charge never
// completed (payment not found / non-completed status); mark the row
// 'failed' exactly as the legacy bulk sweep did.
staleReconcileDefinitivelyFailed
)
// reconcileStalePaymentAtSquare asks Square for the authoritative status of a
// stale pending charge and returns the tri-state result. A COMPLETED payment
// rescues the row to 'completed'; a NOT_FOUND error or any non-COMPLETED status
// proves the charge never completed and fails the row as the legacy bulk sweep
// did. Any OTHER error (transport / 5xx / ambiguous) is NOT treated as a
// definitive failure — the charge may still have completed at Square, and
// marking the row failed would close the double-charge window (blocking a
// same-key retry with a 409) even though the money moved. Such rows stay
// pending for a later run.
func reconcileStalePaymentAtSquare(ctx context.Context, table, squarePaymentID string) staleReconcileResult {
pr, err := SquareClient.GetPayment(ctx, squarePaymentID)
if err != nil {
if squarePaymentErrorIsNotFound(err) {
log.Printf("Stale pending %s reconcile: Square payment %s not found (%v) — marking failed as the legacy sweep would", table, squarePaymentID, err)
return staleReconcileDefinitivelyFailed
}
log.Printf("Stale pending %s reconcile for Square payment %s hit an ambiguous error (%v) — leaving pending for a later sweep run", table, squarePaymentID, err)
return staleReconcileLeavePending
}
if pr.Status != "COMPLETED" {
log.Printf("Stale pending %s is %q at Square — marking failed", table, pr.Status)
return staleReconcileDefinitivelyFailed
}
return staleReconcileCompleted
}
// squarePaymentErrorIsNotFound reports whether a GetPayment error proves the
// payment does not exist at Square. The structured Square error code is the
// primary check (square.ErrorCode); the message fallback also covers the dev
// mock (a plain "payment not found" error) and a non-JSON 404 response.
func squarePaymentErrorIsNotFound(err error) bool {
if err == nil {
return false
}
if square.ErrorCode(err) == "NOT_FOUND" {
return true
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "NOT_FOUND") ||
strings.Contains(msg, "NOT FOUND") ||
strings.Contains(msg, "HTTP 404")
}
// staleTerminalCheckoutAge is how old a still-pending terminal checkout must
// be before the sweep cancels it. Terminal checkouts normally complete within
// minutes; an hour is far past any legitimate card-reader interaction while
// still short enough that a completed charge can never be misread as stale.
const staleTerminalCheckoutAge = 1 * time.Hour
// SweepStaleTerminalCheckouts cancels terminal (card-machine) checkouts that
// are still PENDING/IN_PROGRESS long after they were created, so the terminal
// stops waiting on a customer who walked away. A checkout created by
// CreateTerminalPayment / CreateTillSale that is never polled would otherwise
// sit live at Square indefinitely; if it later completes it is an invisible,
// untracked charge.
//
// Two tables track live checkout IDs and are both swept:
// - terminal_checkouts: booking terminal checkouts created by
// CreateTerminalPayment. PENDING/IN_PROGRESS rows older than the cutoff
// are resolved at Square first: a checkout still waiting at Square is
// cancelled and re-checked once — if it completed during the cancel window
// the row is marked 'completed' (the poll handler records the payment),
// otherwise 'failed'; a COMPLETED checkout releases the in-flight guard
// (the poll handler records the payment); a definitively
// cancelled/expired checkout is marked 'failed'; an ambiguous status is
// left for a later run.
// - till_sales.square_checkout_id: card-machine till sales. A checkout still
// waiting at Square is cancelled and the sale marked 'failed' (the
// payment_status enum has no 'cancelled' value, and 'failed' is the same
// terminal state the stale-pending sweep uses, blocking the till
// pending-retry path); a checkout that completed during the cancel window
// leaves the sale pending for the poll handler to record.
//
// Each row is checked at Square FIRST and only cancelled when the checkout is
// provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never
// cancelled, and a checkout whose status is unknown (transport error) is left
// alone for a later run. After a cancel the checkout is re-checked once — the
// customer may have completed the payment in the cancel window, in which case
// the row is resolved to 'completed' rather than 'failed'.
func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) {
cutoff := clock.Now().Add(-staleTerminalCheckoutAge)
var pending []staleTerminalCheckoutRow
// Booking terminal checkouts (CreateTerminalPayment) live in the
// terminal_checkouts table. A stale PENDING/IN_PROGRESS row means the
// checkout is still live at Square (or was left after a crash / lost poll).
rows, err := db.Conn.Query(ctx, `
SELECT 'terminal_checkout', checkout_id, checkout_id
FROM terminal_checkouts
WHERE status IN ('PENDING', 'IN_PROGRESS')
AND created_at < $1
AND payment_method IN ('online_square', 'in_person_card')
`, cutoff)
if err != nil {
return 0, err
}
tillCount := int(tillTag.RowsAffected())
for rows.Next() {
var r staleTerminalCheckoutRow
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
log.Printf("Failed to scan stale terminal checkout row: %v", err)
continue
}
pending = append(pending, r)
}
rows.Close()
total := payCount + tillCount
if total > 0 {
log.Printf("[SWEEP] Marked %d stale pending payments (%d payments, %d till sales) as failed (older than %s) — late retries will be rejected, preventing a second Square charge", total, payCount, tillCount, stalePendingPaymentAge)
// Till-sale card-machine checkouts are tracked on the till_sales row.
rows, err = db.Conn.Query(ctx, `
SELECT 'till_sale', id, square_checkout_id
FROM till_sales
WHERE status = 'pending' AND square_checkout_id IS NOT NULL
AND created_at < $1
`, cutoff)
if err != nil {
return 0, err
}
if payCount > 0 {
log.Printf("CRITICAL: %d pending payments swept to failed may have been charged at Square with a lost response — manual reconciliation required before refunding/charging", payCount)
for rows.Next() {
var r staleTerminalCheckoutRow
if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil {
log.Printf("Failed to scan stale terminal checkout row: %v", err)
continue
}
pending = append(pending, r)
}
if tillCount > 0 {
log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount)
rows.Close()
resolved := 0
for _, r := range pending {
// Conservative status check: only cancel a checkout that is provably
// still waiting at Square. A COMPLETED checkout must never be
// cancelled, and an ambiguous status (network error) is left alone for
// the next run.
pr, gErr := SquareClient.GetCheckout(ctx, r.CheckoutID)
switch {
case errors.Is(gErr, square.ErrCheckoutPending):
// Still live at the terminal — cancel it. A customer can complete
// the payment in the small window between the GetCheckout above and
// the cancel, so re-check once before marking the row failed: a
// COMPLETED charge must never be recorded as failed.
if cErr := SquareClient.CancelCheckout(ctx, r.CheckoutID); cErr != nil {
log.Printf("Failed to cancel stale terminal checkout %s (%s %s): %v", r.CheckoutID, r.Kind, r.RowID, cErr)
continue
}
recheck, rErr := SquareClient.GetCheckout(ctx, r.CheckoutID)
switch {
case rErr == nil && recheck.Status == "COMPLETED":
// The customer completed the payment during the cancel window.
// The poll handler records it — mark the row COMPLETED (or
// leave the till sale pending) instead of failed.
if r.Kind == "terminal_checkout" {
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, r.RowID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s completed after cancel re-check: %v", r.RowID, upErr)
} else if int(tag.RowsAffected()) > 0 {
resolved++
}
} else {
log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
}
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — recorded as completed, payment handled by the poll handler", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending):
// The cancel landed (CANCELED / cancel-requested / expired /
// still-reporting-pending-but-now-cancelled) — it can never
// complete, so resolve the row to the terminal 'failed' state.
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) — marked failed", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge)
default:
// The cancel succeeded but the re-check itself is ambiguous —
// leave the row for a later run.
log.Printf("Terminal checkout %s was cancelled but its re-check is ambiguous (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, rErr, r.Kind, r.RowID)
}
case gErr == nil && pr.Status == "COMPLETED":
if r.Kind == "terminal_checkout" {
// The payment is recorded by the poll handler; release the
// in-flight guard so a fresh charge can be created.
if tag, upErr := db.Conn.Exec(ctx, `
UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW()
WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
`, r.RowID); upErr != nil {
log.Printf("Failed to mark terminal checkout %s completed: %v", r.RowID, upErr)
} else if int(tag.RowsAffected()) > 0 {
resolved++
}
} else {
// The till poll handler records it — leave the sale pending.
log.Printf("Terminal checkout %s already COMPLETED at Square — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID)
}
case isTerminalCheckoutError(gErr):
// Definitely cancelled / cancel-requested / expired — the checkout
// can never complete, so resolve it to the terminal 'failed' state.
if markTerminalCheckoutRowFailed(ctx, r) {
resolved++
}
log.Printf("Terminal checkout %s is definitively terminal (%v) — marked %s %s failed", r.CheckoutID, gErr, r.Kind, r.RowID)
default:
// Ambiguous transport/unknown status — leave for a later run.
log.Printf("Terminal checkout %s status unknown (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, gErr, r.Kind, r.RowID)
}
}
return total, nil
return resolved, nil
}
// staleTerminalCheckoutRow is one live-checkout row the sweep reads from
// either table so it can resolve the checkout at Square before touching the
// row. Kind is "terminal_checkout" (booking, terminal_checkouts table) or
// "till_sale" (till_sales.square_checkout_id).
type staleTerminalCheckoutRow struct {
Kind string
RowID string
CheckoutID string
}
// markTerminalCheckoutRowFailed moves one tracked row to the terminal 'failed'
// state after its checkout is cancelled or proven terminal at Square. Returns
// true when the row was updated (status was still active).
func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutRow) bool {
var table, where string
if r.Kind == "till_sale" {
table = "till_sales"
where = "id = $1 AND status = 'pending'"
} else {
table = "terminal_checkouts"
where = "checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')"
}
tag, err := db.Conn.Exec(ctx, `
UPDATE `+table+` SET status = 'failed', updated_at = NOW()
WHERE `+where, r.RowID)
if err != nil {
log.Printf("Failed to mark %s %s failed: %v", r.Kind, r.RowID, err)
return false
}
return int(tag.RowsAffected()) > 0
}
// isTerminalCheckoutError reports whether a GetCheckout error proves the
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
// for a still-live checkout and surfaces a definitively CANCELED status as a
// "square: checkout <id> is CANCELED (not COMPLETED)" error; an expired
// checkout returns a NOT_FOUND API error (the mock uses "checkout not found").
// Any other error (timeout, 5xx) leaves the money state ambiguous, so the
// checkout must stay in flight.
func isTerminalCheckoutError(err error) bool {
if err == nil || errors.Is(err, square.ErrCheckoutPending) {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "CANCELED") ||
strings.Contains(msg, "CANCEL_REQUESTED") ||
strings.Contains(msg, "NOT_FOUND") ||
strings.Contains(msg, "NOT FOUND")
}
+565
View File
@@ -0,0 +1,565 @@
//go:build test && dev
package payments
import (
"context"
"errors"
"fmt"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/testutils"
"crussell/testutils/fixtures"
)
// TestSweepStalePendingPayments_ReconcileCompleted locks the F3 fix: a stale
// pending payment whose square_payment_id resolves to a COMPLETED charge at
// Square (the DB row was genuinely charged, the post-charge DB write failed)
// is rescued to 'completed' instead of being swept to 'failed' with no
// automatic resolution.
func TestSweepStalePendingPayments_ReconcileCompleted(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
// Seed the completed charge at Square with the same idempotency semantics
// the charge would have used in production.
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 200000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-stale-completed",
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, staleID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
// The committed rows live in the SHARED test pool, so clean them up or
// parallel tests that count whole tables see them (test isolation).
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = 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()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "completed" {
t.Errorf("expected genuinely-charged stale pending payment rescued to 'completed', got %q", status)
}
}
// TestSweepStalePendingPayments_ReconcileNotFound_Fails locks the F3 fallback:
// a stale pending payment whose square_payment_id does NOT resolve to a
// COMPLETED charge at Square (payment not found / not completed) is marked
// failed exactly as the legacy bulk sweep did — the double-charge window must
// stay closed.
func TestSweepStalePendingPayments_ReconcileNotFound_Fails(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_not_in_mock' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
// The default mock has no payment under 'sqp_not_in_mock' → GetPayment
// returns not-found → the row must be failed, not left pending.
origClient := SquareClient
SquareClient = square.NewDevClient()
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = 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()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != "failed" {
t.Errorf("expected stale pending payment with no COMPLETED charge at Square marked 'failed', got %q", status)
}
}
// TestSweepStalePendingPayments_ReconcileTillSale_Completed locks the F3 fix
// for till_sales: a stale pending till sale whose square_payment_id resolves
// to a COMPLETED charge at Square is rescued to 'completed' like payments.
func TestSweepStalePendingPayments_ReconcileTillSale_Completed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "seed-stale-till-completed",
})
if err != nil {
t.Fatalf("failed to seed completed Square payment: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW() - INTERVAL '25 hours', NOW())
RETURNING id
`, pay.SquarePayID, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale pending till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "completed" {
t.Errorf("expected genuinely-charged stale till sale rescued to 'completed', got %q", status)
}
}
// completedTerminalClient makes one checkout look COMPLETED at Square while
// delegating everything else to the real mock — used to prove the terminal
// sweep never cancels a checkout that may have completed.
type completedTerminalClient struct {
square.SquareClient
checkoutID string
}
func (c *completedTerminalClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_terminal_completed"}, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
// TestSweepStaleTerminalCheckouts_CancelsStalePending locks the F4 fix: a
// terminal checkout still PENDING at Square after an hour is cancelled and its
// till_sales row moved to the terminal 'failed' state (the payment_status enum
// has no 'cancelled' value).
func TestSweepStaleTerminalCheckouts_CancelsStalePending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
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",
})
if err != nil {
t.Fatalf("failed to create pending Square checkout: %v", err)
}
SquareClient = mock
defer func() { SquareClient = origClient }()
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', $1, $2, NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, checkout.ID, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale terminal sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
// Drop any other stale terminal rows left by parallel tests so the count is
// deterministic.
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL AND id <> $1`, saleID); err != nil {
t.Fatalf("failed to clean leftover stale terminal sales: %v", err)
}
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS')`); err != nil {
t.Fatalf("failed to clean leftover stale booking terminal checkouts: %v", err)
}
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected exactly 1 cancelled stale terminal checkout, got %d", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query sale: %v", err)
}
if status != "failed" {
t.Errorf("expected cancelled stale terminal sale marked 'failed', got %q", 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)
}
}
// TestSweepStaleTerminalCheckouts_LeavesCompletedAlone locks the conservative
// F4 rule: a checkout that has COMPLETED at Square is never cancelled — the
// poll handler records it; cancelling a completed checkout would orphan the
// charge.
func TestSweepStaleTerminalCheckouts_LeavesCompletedAlone(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending', 'chk_completed_terminal', $1, NOW() - INTERVAL '2 hours', NOW())
RETURNING id
`, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed stale terminal sale: %v", err)
}
origClient := SquareClient
SquareClient = &completedTerminalClient{SquareClient: square.NewDevClient(), checkoutID: "chk_completed_terminal"}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID)
})
freshCtx := context.Background()
n, err := SweepStaleTerminalCheckouts(freshCtx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 0 {
t.Errorf("expected a completed terminal checkout to be left alone, got %d cancellations", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM till_sales WHERE id = $1", saleID).Scan(&status); err != nil {
t.Fatalf("failed to query sale: %v", err)
}
if status != "pending" {
t.Errorf("expected completed terminal checkout's sale left 'pending' (poll handler records it), got %q", status)
}
}
// =============================================================================
// SweepStalePendingPayments — tri-state reconcile (LOW money-integrity)
// =============================================================================
// staleGetPaymentClient forces GetPayment to return a fixed result/error so the
// reconcile tri-state branches can be exercised deterministically.
type staleGetPaymentClient struct {
square.SquareClient
result *square.PaymentResult
err error
}
func (c *staleGetPaymentClient) GetPayment(ctx context.Context, paymentID string) (*square.PaymentResult, error) {
if c.err != nil {
return nil, c.err
}
if c.result != nil {
return c.result, nil
}
return c.SquareClient.GetPayment(ctx, paymentID)
}
func TestSweepStalePendingPayments_ReconcileTriState(t *testing.T) {
cases := []struct {
name string
result *square.PaymentResult
getErr error
wantFinal string // "completed", "failed", or "pending"
}{
{
name: "completed_rescues_row",
result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_tri_completed"},
wantFinal: "completed",
},
{
name: "not_found_marks_failed",
getErr: fmt.Errorf("square: GET /v2/payments/sqp_x: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist"),
wantFinal: "failed",
},
{
name: "ambiguous_error_leaves_pending",
getErr: fmt.Errorf("network error: connection reset by peer"),
wantFinal: "pending",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_tri_state' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: tc.result, err: tc.getErr}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID)
_, _ = 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()
if _, err := SweepStalePendingPayments(freshCtx); err != nil {
t.Fatalf("sweep failed: %v", err)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&status); err != nil {
t.Fatalf("failed to query payment: %v", err)
}
if status != tc.wantFinal {
t.Errorf("expected stale pending payment %q after reconcile, got %q", tc.wantFinal, status)
}
})
}
}
// =============================================================================
// SweepStaleTerminalCheckouts — completed-during-cancel re-check (TOCTOU)
// =============================================================================
// completingDuringCancelClient reports ErrCheckoutPending on the FIRST
// GetCheckout for the target checkout and COMPLETED on later calls — simulating
// a customer completing the payment between the sweep's status check and its
// CancelCheckout.
type completingDuringCancelClient struct {
square.SquareClient
checkoutID string
calls int
}
func (c *completingDuringCancelClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
if checkoutID == c.checkoutID {
c.calls++
if c.calls == 1 {
return nil, square.ErrCheckoutPending
}
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_completed_during_cancel"}, nil
}
return c.SquareClient.GetCheckout(ctx, checkoutID)
}
func TestSweepStaleTerminalCheckouts_CompletedDuringCancel_MarkedCompleted(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
const checkoutID = "chk_completes_during_cancel"
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')
`, checkoutID, bookingID); err != nil {
t.Fatalf("failed to seed stale terminal checkout row: %v", err)
}
origClient := SquareClient
SquareClient = &completingDuringCancelClient{SquareClient: square.NewDevClient(), checkoutID: checkoutID}
defer func() { SquareClient = origClient }()
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, checkoutID)
_, _ = 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 any other stale terminal rows left by parallel 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`, checkoutID); 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)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
if n != 1 {
t.Errorf("expected exactly 1 resolved terminal checkout (completed during cancel), got %d", n)
}
var status string
if err := db.Conn.QueryRow(freshCtx, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", checkoutID).Scan(&status); err != nil {
t.Fatalf("failed to query terminal checkout: %v", err)
}
if status != "COMPLETED" {
t.Errorf("expected a checkout that completed during the cancel window marked 'COMPLETED', got %q", status)
}
}
+175 -21
View File
@@ -11,6 +11,7 @@ import (
"log/slog"
"math"
"net/http"
"strings"
"crussell/db"
"crussell/internal/square"
@@ -22,16 +23,17 @@ import (
)
type TillSaleRequest struct {
ItemType string `json:"item_type" validate:"required"`
Action string `json:"action" validate:"required"`
Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CardToken string `json:"card_token,omitempty"`
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
ItemType string `json:"item_type" validate:"required"`
Action string `json:"action" validate:"required"`
Amount float64 `json:"amount" validate:"required,gt=0"`
GiftCardID *string `json:"gift_card_id,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required"`
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CardToken string `json:"card_token,omitempty"`
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
VerificationToken *string `json:"verification_token,omitempty"`
}
type TillSaleResponse struct {
@@ -54,6 +56,126 @@ func uniqueTillKey() string {
return "till-" + rand.Text()
}
// definitivePaymentDeclineCodes are Square payment error codes meaning the
// card charge can never succeed (declined / expired / not supported). They are
// matched against the formatted Square API error so a DEFINITIVE rejection can
// claw back a gift card funded earlier in the same till-sale request. Anything
// else (transport errors, 5xx, unknown) is treated as ambiguous: the sale is
// left pending for the stale-pending sweep, which may still resolve it.
var definitivePaymentDeclineCodes = []string{
"CARD_DECLINED",
"CARD_EXPIRED",
"INVALID_EXPIRATION",
"INVALID_EXPIRATION_DATE",
"CARD_NOT_SUPPORTED",
"VERIFY_CVV_FAILURE",
"AVS_FAILURE",
"PAYMENT_CARD_DECLINED",
"GENERIC_DECLINE",
"INSUFFICIENT_FUNDS",
"ADDRESS_VERIFICATION_FAILURE",
"TRANSACTION_LIMIT",
}
// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a
// definitive business rejection (declined/expired) rather than an ambiguous
// transport/server error. The real HTTP client formats declines as
// "square: POST /v2/payments: [CATEGORY/CODE] ...", so the code is matched
// against the uppercased error message.
func isDefinitiveChargeFailure(err error) bool {
if err == nil {
return false
}
msg := strings.ToUpper(err.Error())
for _, code := range definitivePaymentDeclineCodes {
if strings.Contains(msg, code) {
return true
}
}
return false
}
// revertGiftCardFunding undoes the gift-card funding performed earlier in the
// SAME till-sale request after a definitive Square charge rejection, matching
// the gift_card_transactions accounting: a created card is deleted (with its
// purchase transaction) and any immediate redeem-to-account credit reversed; a
// topped-up card has the amount subtracted back out and its top-up transaction
// removed. The till sale is marked 'failed' in the same compensating
// transaction so a late same-key retry cannot re-complete a sale whose gift
// card no longer exists.
func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin clawback transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback gift-card clawback transaction", "err", err)
}
}()
if action == "create" {
// A newly created card has exactly one funding transaction (this
// request's purchase) — remove it, then the card itself.
if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card transaction: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil {
return fmt.Errorf("failed to delete gift card: %w", err)
}
// If the card was immediately redeemed to a user balance in this
// request, reverse that credit (guarded so it can never go negative).
if redeemToUserID != nil && *redeemToUserID != "" {
if _, err := tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = user_giftcard_balances.balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
`, amount, *redeemToUserID); err != nil {
return fmt.Errorf("failed to reverse redeemed gift card balance: %w", err)
}
}
} else {
// Top-up: subtract the amount back out of the card. The guard keeps
// amount_remaining from ever going negative in the pathological case
// where some of the top-up was already spent before the charge failed.
tag, err := tx.Exec(ctx, `
UPDATE gift_cards
SET total_funds_added = total_funds_added - $1,
amount_remaining = amount_remaining - $1
WHERE id = $2 AND amount_remaining >= $1
`, amount, giftCardID)
if err != nil {
return fmt.Errorf("failed to reverse gift card top-up: %w", err)
}
if tag.RowsAffected() == 0 {
// The guard blocked the reversal because some of the top-up was
// already spent. The sale is still marked failed below — do not
// fail the whole clawback tx — but the unreversed money must be
// flagged for manual reconciliation.
log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID)
}
// Remove only this request's top-up transaction (reference_id = till
// sale) so prior sales' accounting on the same card is untouched.
if _, err := tx.Exec(ctx, `
DELETE FROM gift_card_transactions
WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2
`, giftCardID, tillSaleID); err != nil {
return fmt.Errorf("failed to delete gift card top-up transaction: %w", err)
}
}
if _, err := tx.Exec(ctx, `
UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending'
`, tillSaleID); err != nil {
return fmt.Errorf("failed to mark till sale failed after clawback: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit clawback transaction: %w", err)
}
return nil
}
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Defense-in-depth admin check (S-1) — a till sale moves money (charges a
@@ -113,6 +235,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
return
}
if err := ValidateVerificationToken(req.VerificationToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Serialize till-sale attempts on the idempotency key to prevent concurrent
// same-key requests from both passing the idempotency check, both funding
// the gift card, and one dying on the till_sales idempotency_key UNIQUE
@@ -351,6 +479,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// leaves a Square charge with no DB record.
var needsSquarePayment bool
var savedCardSqCardID string
var savedCardCustomerID string
// Pending-retry for card_machine: the original Square checkout may still be
// live at the terminal. If the pending till_sales row already recorded a
@@ -403,10 +532,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
err = tx.QueryRow(ctx, `
SELECT square_card_id
SELECT square_card_id, COALESCE(square_customer_id, '')
FROM user_saved_cards
WHERE id = $1 AND deleted_at IS NULL
`, *req.UserSavedCardID).Scan(&savedCardSqCardID)
`, *req.UserSavedCardID).Scan(&savedCardSqCardID, &savedCardCustomerID)
if err != nil {
log.Printf("Failed to get saved card details: %v", err)
http.Error(w, "Card not found", http.StatusNotFound)
@@ -438,7 +567,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
Currency: "GBP",
IdempotencyKey: req.IdempotencyKey,
ReferenceID: giftCardID,
TipEnabled: false,
AllowTipping: false,
}
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
@@ -550,11 +679,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
var paymentResult *square.PaymentResult
var squareErr error
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
if req.PaymentMethod == "saved_card" {
paymentReq := square.CreatePaymentReq{
Amount: penceAmount,
Currency: "GBP",
SourceID: savedCardSqCardID,
CustomerID: savedCardCustomerID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
@@ -572,8 +707,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// "till-<giftCardID>" namespace, NOT a real user — this card is
// ephemeral (used once for this charge) and is never stored in
// user_saved_cards or re-listed. The prefix can't collide with a
// real CHAR(12)-hex user ID.
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken)
// real CHAR(12)-hex user ID. No Square customer is provisioned for
// it ("" as the customerID): a cnon: nonce charge needs none.
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken, "")
if cardErr != nil {
log.Printf("Failed to tokenize card: %v", cardErr)
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
@@ -581,18 +717,36 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
paymentReq := square.CreatePaymentReq{
Amount: penceAmount,
Currency: "GBP",
SourceID: cardOnFile.CardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
Amount: penceAmount,
Currency: "GBP",
SourceID: cardOnFile.CardID,
IdempotencyKey: req.IdempotencyKey,
Note: "Gift Card " + req.Action,
BuyerEmail: buyerEmail,
VerificationToken: verificationToken,
}
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
}
if squareErr != nil {
log.Printf("Failed to process payment: %v", squareErr)
// The gift card was already created/top-upped in the committed DB
// transaction before this Square charge. On a DEFINITIVE rejection
// (declined/expired) the customer was never charged, so the funded
// card must be clawed back — otherwise it stays funded forever (the
// stale-pending sweep only marks the sale failed, it never reverts
// the card). Ambiguous failures (network/5xx) leave the sale pending
// so a late retry can still complete it — the card must stay funded.
// The clawback also runs on a pending-retry: the card was funded by
// a PRIOR request of this same sale (same idempotency key), and the
// retry's definitive failure proves this sale's charge can never
// complete — reverting the funding is required, not "the prior
// attempt's responsibility".
if isDefinitiveChargeFailure(squareErr) {
if revErr := revertGiftCardFunding(ctx, req.Action, giftCardID, req.Amount, req.RedeemToUserID, tillSaleID); revErr != nil {
log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID)
}
}
http.Error(w, "Payment failed", http.StatusPaymentRequired)
return
}
+76 -3
View File
@@ -360,7 +360,7 @@ func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) {
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
@@ -885,7 +885,7 @@ func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) {
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
@@ -1068,7 +1068,7 @@ func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
@@ -1568,3 +1568,76 @@ func TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected(t *testing.T) {
t.Errorf("expected pending sale to remain pending after rejected switch, got %s", saleStatus)
}
}
// =============================================================================
// revertGiftCardFunding — top-up guard-blocked reversal still fails the sale
// =============================================================================
func TestRevertGiftCardFunding_TopupPartiallySpent_LogsCritical_StillFailsSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 10.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
`, cardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
// amount_remaining (10.00) < top-up (50.00) — the guarded UPDATE matches 0
// rows. The clawback must NOT fail (the sale still has to be marked failed)
// and must log CRITICAL for manual reconciliation.
err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID)
if err != nil {
t.Fatalf("revertGiftCardFunding must not fail when the guard blocks the reversal, got: %v", err)
}
var remaining, totalAdded float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if remaining != 10.00 || totalAdded != 50.00 {
t.Errorf("guard-blocked reversal must leave the card amounts untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded)
}
// The sale must still be marked failed — the whole point of the clawback.
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "failed" {
t.Errorf("expected till sale marked failed despite the guard-blocked reversal, got %q", saleStatus)
}
// This request's top-up transaction must be removed even though the card
// amount could not be reversed.
var txCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, cardID, saleID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift card transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected this request's top-up transaction removed, got %d", txCount)
}
}
+14
View File
@@ -66,3 +66,17 @@ func ValidateCardInfo(cardID, newCardToken *string) error {
}
return nil
}
// ValidateVerificationToken checks that a Square 3DS/SCA verification token is
// non-empty when present and within a sane length. Square's tokens are short
// opaque strings; the bound guards against absurd/malformed payloads before
// the token is forwarded to Square's API.
func ValidateVerificationToken(token *string) error {
if token == nil || *token == "" {
return nil
}
if len(*token) > 512 {
return errors.New("verification_token is too long")
}
return nil
}