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:
@@ -110,10 +110,40 @@ ALTER TYPE admin_notification_reason ADD VALUE IF NOT EXISTS 'refund_failed';
|
|||||||
|
|
||||||
-- Refunds may now reference non-booking payments (gift-card purchase refunds)
|
-- Refunds may now reference non-booking payments (gift-card purchase refunds)
|
||||||
ALTER TABLE refunds ALTER COLUMN booking_id DROP NOT NULL;
|
ALTER TABLE refunds ALTER COLUMN booking_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Terminal checkout in-flight guard + payment_type passthrough (terminal_checkouts table)
|
||||||
|
-- NOTE: CREATE TABLE is a fresh addition, not a column change. Apply before deploying
|
||||||
|
-- the terminal-payment changes or CreateTerminalPayment/GetCheckoutStatus fail at runtime.
|
||||||
|
CREATE TABLE IF NOT EXISTS terminal_checkouts (
|
||||||
|
checkout_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
|
||||||
|
payment_type payment_type NOT NULL DEFAULT 'full',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
amount NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_booking ON terminal_checkouts(booking_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_status ON terminal_checkouts(status);
|
||||||
```
|
```
|
||||||
|
|
||||||
The sweep job (`internal/jobs/cleanup.go`) and `refunds.go` cast `'refund_failed'::admin_notification_reason`, so an un-migrated DB fails at runtime — apply these before deploying the payment changes.
|
The sweep job (`internal/jobs/cleanup.go`) and `refunds.go` cast `'refund_failed'::admin_notification_reason`, so an un-migrated DB fails at runtime — apply these before deploying the payment changes.
|
||||||
|
|
||||||
|
#### Saved-card per-user uniqueness + Square customer provisioning (P14)
|
||||||
|
|
||||||
|
The `user_saved_cards.square_card_id` UNIQUE constraint is now scoped **per user** (`UNIQUE (user_id, square_card_id)`), so the same physical card saved by two users produces two independent rows instead of user B mutating user A's saved-card row. Existing deployments must swap the constraint (the auto-generated constraint name is `user_saved_cards_square_card_id_key`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_saved_cards DROP CONSTRAINT user_saved_cards_square_card_id_key;
|
||||||
|
ALTER TABLE user_saved_cards ADD CONSTRAINT user_saved_cards_user_id_square_card_id_key UNIQUE (user_id, square_card_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
`square_customer_id TEXT` (nullable) was also added to `user_saved_cards` — populated the first time a user saves a card (Square customer provisioning, P14) and reused thereafter:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE user_saved_cards ADD COLUMN IF NOT EXISTS square_customer_id TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
## Full Documentation
|
## Full Documentation
|
||||||
|
|
||||||
Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/).
|
Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/).
|
||||||
|
|||||||
@@ -848,13 +848,17 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now seed EXCEPTIONAL hours making today CLOSED.
|
// Now seed EXCEPTIONAL hours making today CLOSED.
|
||||||
// Need: group → hours → application with week_start = Monday of this week
|
// Need: group → hours → application with week_start = Monday of this week.
|
||||||
weekday := now.Weekday()
|
// The Monday must be computed in LONDON time (like the handler's isDayOpen),
|
||||||
|
// not UTC — around the 23:00-00:00 UTC boundary UTC and London are on
|
||||||
|
// different days, and a UTC-derived week_start would not overlap the
|
||||||
|
// handler's London date, silently leaving today "open".
|
||||||
|
weekday := londonNow.Weekday()
|
||||||
daysSinceMonday := int(weekday) - 1
|
daysSinceMonday := int(weekday) - 1
|
||||||
if daysSinceMonday < 0 {
|
if daysSinceMonday < 0 {
|
||||||
daysSinceMonday = 6
|
daysSinceMonday = 6
|
||||||
}
|
}
|
||||||
monday := now.AddDate(0, 0, -daysSinceMonday)
|
monday := londonNow.AddDate(0, 0, -daysSinceMonday)
|
||||||
mondayStr := monday.Format("2006-01-02")
|
mondayStr := monday.Format("2006-01-02")
|
||||||
|
|
||||||
var groupID int
|
var groupID int
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,13 +75,14 @@ type TransferGiftCardRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BuyGiftCardRequest struct {
|
type BuyGiftCardRequest struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
RecipientType string `json:"recipient_type"`
|
RecipientType string `json:"recipient_type"`
|
||||||
RecipientEmail string `json:"recipient_email,omitempty"`
|
RecipientEmail string `json:"recipient_email,omitempty"`
|
||||||
CardID *string `json:"card_id,omitempty"`
|
CardID *string `json:"card_id,omitempty"`
|
||||||
NewCardToken *string `json:"new_card_token,omitempty"`
|
NewCardToken *string `json:"new_card_token,omitempty"`
|
||||||
SaveCard bool `json:"save_card"`
|
SaveCard bool `json:"save_card"`
|
||||||
IdempotencyKey string `json:"idempotency_key"`
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
VerificationToken *string `json:"verification_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedeemGiftCardRequest struct {
|
type RedeemGiftCardRequest struct {
|
||||||
@@ -895,6 +896,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
paymentService := NewPaymentService()
|
||||||
|
|
||||||
// Serialize gift-card purchase attempts on the idempotency key to prevent
|
// 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
|
var savedCardID *string
|
||||||
|
|
||||||
if req.NewCardToken != nil && *req.NewCardToken != "" {
|
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 {
|
if err != nil {
|
||||||
log.Printf("Failed to create card on file: %v", err)
|
log.Printf("Failed to create card on file: %v", err)
|
||||||
http.Error(w, "Failed to process card", http.StatusInternalServerError)
|
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)
|
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{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card Purchase",
|
Note: "Gift Card Purchase",
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
|
VerificationToken: verificationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
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")
|
||||||
|
}
|
||||||
@@ -424,7 +424,7 @@ func TestOnlinePayment_SavedCard(t *testing.T) {
|
|||||||
|
|
||||||
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create payment method: %v", err)
|
t.Fatalf("failed to create payment method: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2360,7 +2360,7 @@ func TestTipPayment_WithSavedCard(t *testing.T) {
|
|||||||
var savedCardID string
|
var savedCardID string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
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
|
RETURNING id
|
||||||
`, userID).Scan(&savedCardID)
|
`, userID).Scan(&savedCardID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -2401,7 +2401,7 @@ func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) {
|
|||||||
var savedCardID string
|
var savedCardID string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
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
|
RETURNING id
|
||||||
`, userID).Scan(&savedCardID)
|
`, userID).Scan(&savedCardID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -2458,7 +2458,7 @@ func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) {
|
|||||||
var savedCardID string
|
var savedCardID string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
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
|
RETURNING id
|
||||||
`, userID).Scan(&savedCardID)
|
`, userID).Scan(&savedCardID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -2510,7 +2510,7 @@ func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) {
|
|||||||
var savedCardID string
|
var savedCardID string
|
||||||
err = tx.QueryRow(ctx, `
|
err = tx.QueryRow(ctx, `
|
||||||
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
|
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
|
RETURNING id
|
||||||
`, userID).Scan(&savedCardID)
|
`, userID).Scan(&savedCardID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -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
|
// lockCancellationPayments serializes a cancellation refund against the manual
|
||||||
// RefundPayment handler and the sweep. Both hold
|
// RefundPayment handler and the sweep. Both hold
|
||||||
// `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level)
|
// `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
|
// Prior refunds per payment record (completed + pending) — the loop must
|
||||||
// not re-refund money already returned. Sums by payment_id; pending counts
|
// not re-refund money already returned. Sums by payment_id; pending counts
|
||||||
// because a Square call may already be in flight.
|
// 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)
|
priorRefunds := make(map[string]float64)
|
||||||
prRows, prErr := tx.Query(ctx, `
|
prRows, prErr := tx.Query(ctx, `
|
||||||
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
|
SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds
|
||||||
@@ -368,6 +378,14 @@ func ProcessCancellationRefundTx(
|
|||||||
return &calc, nil
|
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(
|
func ProcessCancellationRefund(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
bookingID string,
|
bookingID string,
|
||||||
@@ -395,215 +413,15 @@ func ProcessCancellationRefund(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Get the booking's user info for refund routing.
|
// Delegate the whole refund loop to the transactional variant — the
|
||||||
var bookingUserID string
|
// non-tx wrapper exists only to own the transaction lifecycle and fire the
|
||||||
var isGuest bool
|
// post-commit Square pass (ProcessPendingSquareRefunds) after a successful
|
||||||
if err := tx.QueryRow(ctx, `
|
// commit. The Tx variant returns an error ONLY on lock failure; every other
|
||||||
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
|
// failure logs internally and returns (calc, nil).
|
||||||
FROM bookings b
|
res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false)
|
||||||
LEFT JOIN users u ON b.user_id = u.id
|
if txErr != nil {
|
||||||
WHERE b.id = $1
|
log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr)
|
||||||
`, bookingID).Scan(&bookingUserID, &isGuest); err != nil {
|
return &calc, txErr
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cErr := tx.Commit(ctx); cErr != nil {
|
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.
|
// This ensures Square API calls only happen if the DB records persist.
|
||||||
ProcessPendingSquareRefunds(ctx, bookingID, reason)
|
ProcessPendingSquareRefunds(ctx, bookingID, reason)
|
||||||
|
|
||||||
return &calc, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessPendingSquareRefunds resolves a booking's pending cancellation card
|
// ProcessPendingSquareRefunds resolves a booking's pending cancellation card
|
||||||
@@ -1166,8 +984,13 @@ func idsOf(rows []pendingChargeRow) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// manualPendingRow is one stale manual refund row (the handler's ambiguous-error
|
// manualPendingRow is one stale manual refund row eligible for the sweep's
|
||||||
// path) eligible for retry by the sweep.
|
// 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 {
|
type manualPendingRow struct {
|
||||||
ID string
|
ID string
|
||||||
PaymentID string
|
PaymentID string
|
||||||
@@ -1175,23 +998,27 @@ type manualPendingRow struct {
|
|||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
Reason string
|
Reason string
|
||||||
SquarePaymentID string
|
SquarePaymentID string
|
||||||
|
SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
|
// sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending'
|
||||||
// by the RefundPayment handler's ambiguous-error path. The cancellation passes
|
// by the RefundPayment handler. The cancellation passes filter
|
||||||
// filter origin='cancellation', so manual rows were never re-attempted: they
|
// origin='cancellation', so manual rows were never re-attempted: they
|
||||||
// permanently blocked the over-refund guard and depressed booking TotalPaid.
|
// permanently blocked the over-refund guard and depressed booking TotalPaid.
|
||||||
// Each row is retried with its OWN stored idempotency key (Square dedups
|
// Rows are split per-row by their stored square_refund_id: rows WITH one (the
|
||||||
// same-key retries, so the retry is idempotent).
|
// 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) {
|
func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
|
||||||
rows, err := db.Conn.Query(ctx, `
|
rows, err := db.Conn.Query(ctx, `
|
||||||
SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason,
|
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
|
FROM refunds r
|
||||||
JOIN payments p ON p.id = r.payment_id
|
JOIN payments p ON p.id = r.payment_id
|
||||||
WHERE r.status = 'pending' AND r.origin = 'manual'
|
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
|
AND p.square_payment_id IS NOT NULL
|
||||||
ORDER BY r.payment_id, r.id
|
ORDER BY r.payment_id, r.id
|
||||||
`)
|
`)
|
||||||
@@ -1203,13 +1030,17 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pr manualPendingRow
|
var pr manualPendingRow
|
||||||
var key *string
|
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)
|
log.Printf("Failed to scan manual pending refund: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if key != nil {
|
if key != nil {
|
||||||
pr.IdempotencyKey = *key
|
pr.IdempotencyKey = *key
|
||||||
}
|
}
|
||||||
|
if sqRefundID != nil {
|
||||||
|
pr.SquareRefundID = *sqRefundID
|
||||||
|
}
|
||||||
pending = append(pending, pr)
|
pending = append(pending, pr)
|
||||||
}
|
}
|
||||||
rows.Close()
|
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).
|
// cap are eligible (a concurrent manual refund may have resolved some).
|
||||||
prRows, err := db.Conn.Query(ctx, `
|
prRows, err := db.Conn.Query(ctx, `
|
||||||
SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at,
|
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
|
FROM refunds r
|
||||||
JOIN payments p ON p.id = r.payment_id
|
JOIN payments p ON p.id = r.payment_id
|
||||||
WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3
|
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() {
|
for prRows.Next() {
|
||||||
var pr manualPendingRow
|
var pr manualPendingRow
|
||||||
var key *string
|
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)
|
log.Printf("Failed to scan manual pending refund under lock: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if key != nil {
|
if key != nil {
|
||||||
pr.IdempotencyKey = *key
|
pr.IdempotencyKey = *key
|
||||||
}
|
}
|
||||||
|
if sqRefundID != nil {
|
||||||
|
pr.SquareRefundID = *sqRefundID
|
||||||
|
}
|
||||||
pending = append(pending, pr)
|
pending = append(pending, pr)
|
||||||
}
|
}
|
||||||
prRows.Close()
|
prRows.Close()
|
||||||
@@ -1350,6 +1185,42 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
|
|||||||
for i := range pending {
|
for i := range pending {
|
||||||
pr := &pending[i]
|
pr := &pending[i]
|
||||||
amountCents := int64(math.Round(pr.Amount * 100))
|
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{
|
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
|
||||||
PaymentID: pr.SquarePaymentID,
|
PaymentID: pr.SquarePaymentID,
|
||||||
Amount: amountCents,
|
Amount: amountCents,
|
||||||
|
|||||||
@@ -1943,6 +1943,17 @@ func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *test
|
|||||||
t.Fatalf("failed to commit test tx: %v", err)
|
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
|
origClient := SquareClient
|
||||||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||||||
SquareClient = counting
|
SquareClient = counting
|
||||||
@@ -2028,6 +2039,17 @@ func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T)
|
|||||||
t.Fatalf("failed to commit test tx: %v", err)
|
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
|
origClient := SquareClient
|
||||||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||||||
SquareClient = counting
|
SquareClient = counting
|
||||||
@@ -2568,6 +2590,17 @@ func TestSweepPendingSquareRefunds_RetriesStaleManualRefund(t *testing.T) {
|
|||||||
t.Fatalf("failed to commit test tx: %v", err)
|
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
|
origClient := SquareClient
|
||||||
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
|
||||||
SquareClient = counting
|
SquareClient = counting
|
||||||
@@ -2665,6 +2698,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund(t *testi
|
|||||||
t.Fatalf("failed to commit test tx: %v", err)
|
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
|
origClient := SquareClient
|
||||||
mock := square.NewDevClient().(*square.MockClient)
|
mock := square.NewDevClient().(*square.MockClient)
|
||||||
// Pre-seed the COMPLETED refund Square recorded for this charge — the exact
|
// 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)
|
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
|
origClient := SquareClient
|
||||||
recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()}
|
recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()}
|
||||||
counting := &countingRefundClient{SquareClient: recErr}
|
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)
|
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
|
origClient := SquareClient
|
||||||
// The MockClient records no refunds for this charge → reconcile returns a
|
// The MockClient records no refunds for this charge → reconcile returns a
|
||||||
// genuine no-match (nil, nil).
|
// 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)
|
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(¬ifCount)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,14 +17,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SavedCard struct {
|
type SavedCard struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SquareCardID string `json:"square_card_id"`
|
// SquareCustomerID is the user's provisioned Square customer profile id
|
||||||
Brand string `json:"brand"`
|
// (P14), persisted on the row the first time the user saves a card. It is
|
||||||
Last4 string `json:"last_4"`
|
// forwarded to CreatePayment as CustomerID on saved-card (ccof:) charges,
|
||||||
ExpMonth int `json:"exp_month"`
|
// which Square requires for card-on-file payments. Empty for rows created
|
||||||
ExpYear int `json:"exp_year"`
|
// before provisioning was introduced.
|
||||||
Fingerprint string `json:"fingerprint"`
|
SquareCustomerID string `json:"square_customer_id,omitempty"`
|
||||||
IsDefault bool `json:"is_default"`
|
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{}
|
type PaymentService struct{}
|
||||||
@@ -472,6 +478,9 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
|
|||||||
SELECT COALESCE(SUM(amount), 0) AS paid_pounds
|
SELECT COALESCE(SUM(amount), 0) AS paid_pounds
|
||||||
FROM payments
|
FROM payments
|
||||||
WHERE booking_id = $1 AND status = 'completed'
|
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
|
SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint
|
||||||
FROM booking_total bt, paid_total pt
|
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) {
|
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
|
||||||
rows, err := db.Conn.Query(ctx, `
|
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
|
FROM user_saved_cards
|
||||||
WHERE user_id = $1 AND deleted_at IS NULL
|
WHERE user_id = $1 AND deleted_at IS NULL
|
||||||
ORDER BY is_default DESC, created_at DESC
|
ORDER BY is_default DESC, created_at DESC
|
||||||
@@ -498,7 +507,7 @@ func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID strin
|
|||||||
var cards []SavedCard
|
var cards []SavedCard
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c SavedCard
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// PCI-DSS: raw PANs are never accepted. The client must supply a Square
|
||||||
// Web Payments nonce (cnon:xxx), which the backend tokenizes via the
|
// Web Payments nonce (cnon:xxx), which the backend tokenizes via the
|
||||||
// Cards API — the full PAN exists only inside Square's vault.
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to tokenize card: %w", err)
|
return nil, fmt.Errorf("failed to tokenize card: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var savedCardID string
|
var savedCardID string
|
||||||
var isDefault bool
|
var isDefault bool
|
||||||
// ON CONFLICT (square_card_id): a response-lost retry re-tokenizes the same
|
// ON CONFLICT (user_id, square_card_id): a response-lost retry re-tokenizes
|
||||||
// card (CreateCardOnFile's deterministic key returns the same ccof: id), so
|
// the same card for the SAME user (CreateCardOnFile's deterministic key
|
||||||
// the UNIQUE constraint would otherwise 500 on the duplicate. Upsert instead
|
// returns the same ccof: id), so the per-user UNIQUE constraint would
|
||||||
// so the retry returns the existing saved card (N-8).
|
// 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, `
|
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)
|
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,
|
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)
|
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,
|
brand = EXCLUDED.brand,
|
||||||
last_4 = EXCLUDED.last_4,
|
last_4 = EXCLUDED.last_4,
|
||||||
exp_month = EXCLUDED.exp_month,
|
exp_month = EXCLUDED.exp_month,
|
||||||
exp_year = EXCLUDED.exp_year,
|
exp_year = EXCLUDED.exp_year,
|
||||||
fingerprint = EXCLUDED.fingerprint,
|
fingerprint = EXCLUDED.fingerprint,
|
||||||
|
square_customer_id = EXCLUDED.square_customer_id,
|
||||||
deleted_at = NULL,
|
deleted_at = NULL,
|
||||||
retained_until = NULL
|
retained_until = NULL
|
||||||
|
WHERE user_saved_cards.user_id = EXCLUDED.user_id
|
||||||
RETURNING id, is_default
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to save card: %w", err)
|
return nil, fmt.Errorf("failed to save card: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SavedCard{
|
return &SavedCard{
|
||||||
ID: savedCardID,
|
ID: savedCardID,
|
||||||
SquareCardID: cardOnFile.CardID,
|
SquareCustomerID: squareCustomerID,
|
||||||
Brand: cardOnFile.Brand,
|
SquareCardID: cardOnFile.CardID,
|
||||||
Last4: cardOnFile.Last4,
|
Brand: cardOnFile.Brand,
|
||||||
ExpMonth: cardOnFile.ExpMonth,
|
Last4: cardOnFile.Last4,
|
||||||
ExpYear: cardOnFile.ExpYear,
|
ExpMonth: cardOnFile.ExpMonth,
|
||||||
Fingerprint: cardOnFile.Fingerprint,
|
ExpYear: cardOnFile.ExpYear,
|
||||||
IsDefault: isDefault,
|
Fingerprint: cardOnFile.Fingerprint,
|
||||||
|
IsDefault: isDefault,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
|
// ensureSquareCustomer lazily provisions a Square customer profile for the
|
||||||
var id string
|
// 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, `
|
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 (
|
INSERT INTO user_saved_cards (
|
||||||
user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
|
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, false, NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NOW())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, userID, squareCardID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
|
`, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
|
||||||
var c SavedCard
|
var c SavedCard
|
||||||
err := q.QueryRow(ctx, `
|
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
|
FROM user_saved_cards
|
||||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -2,44 +2,46 @@ package payments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/internal/square"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SweepStalePendingPayments marks pending payment records that are older than
|
// SweepStalePendingPayments resolves pending payment records that are older
|
||||||
// Square's idempotency-key retention window (~24h) as 'failed'. A pending
|
// than Square's idempotency-key retention window (~24h). A pending record
|
||||||
// record means the DB committed but the Square charge outcome is unknown; it
|
// 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
|
// 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
|
// the attempt, the record stays pending forever — and retrying it after the
|
||||||
// key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing
|
// key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing
|
||||||
// stale pendings closes that double-charge window: a late retry finds a
|
// stale pendings closes that double-charge window: a late retry finds a
|
||||||
// 'failed' record and stops instead of charging again.
|
// '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
|
// Only online/till card payments can be pending — cash/giftcard/on_the_house
|
||||||
// are committed synchronously and never enter this state. Both the payments
|
// are committed synchronously and never enter this state. Both the payments
|
||||||
// table and till_sales carry pending card-sale rows and are swept here.
|
// 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
|
const stalePendingPaymentAge = 24 * time.Hour
|
||||||
|
|
||||||
func SweepStalePendingPayments(ctx context.Context) (int, error) {
|
func SweepStalePendingPayments(ctx context.Context) (int, error) {
|
||||||
cutoff := clock.Now().Add(-stalePendingPaymentAge)
|
cutoff := clock.Now().Add(-stalePendingPaymentAge)
|
||||||
|
|
||||||
tag, err := db.Conn.Exec(ctx, `
|
payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff)
|
||||||
UPDATE payments
|
|
||||||
SET status = 'failed', updated_at = NOW()
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND created_at < $1
|
|
||||||
`, cutoff)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
payCount := int(tag.RowsAffected())
|
|
||||||
|
|
||||||
// till_sales rows for card payments (stored as 'online_square' or
|
// till_sales rows for card payments (stored as 'online_square' or
|
||||||
// 'in_person_card' in the payment_method enum — saved_card/online_square/
|
// '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
|
// 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
|
// an expired key → second charge (R3). Cash / on_the_house are committed
|
||||||
// synchronously and never pending.
|
// synchronously and never pending.
|
||||||
tillTag, err := db.Conn.Exec(ctx, `
|
tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff)
|
||||||
UPDATE till_sales
|
if err != nil {
|
||||||
SET status = 'failed', updated_at = NOW()
|
return 0, err
|
||||||
WHERE status = 'pending'
|
}
|
||||||
|
|
||||||
|
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 created_at < $1
|
||||||
AND payment_method IN ('online_square', 'in_person_card')
|
|
||||||
`, cutoff)
|
`, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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
|
// Till-sale card-machine checkouts are tracked on the till_sales row.
|
||||||
if total > 0 {
|
rows, err = db.Conn.Query(ctx, `
|
||||||
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)
|
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 {
|
for rows.Next() {
|
||||||
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)
|
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 {
|
rows.Close()
|
||||||
log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount)
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"crussell/internal/square"
|
"crussell/internal/square"
|
||||||
@@ -22,16 +23,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TillSaleRequest struct {
|
type TillSaleRequest struct {
|
||||||
ItemType string `json:"item_type" validate:"required"`
|
ItemType string `json:"item_type" validate:"required"`
|
||||||
Action string `json:"action" validate:"required"`
|
Action string `json:"action" validate:"required"`
|
||||||
Amount float64 `json:"amount" validate:"required,gt=0"`
|
Amount float64 `json:"amount" validate:"required,gt=0"`
|
||||||
GiftCardID *string `json:"gift_card_id,omitempty"`
|
GiftCardID *string `json:"gift_card_id,omitempty"`
|
||||||
PaymentMethod string `json:"payment_method" validate:"required"`
|
PaymentMethod string `json:"payment_method" validate:"required"`
|
||||||
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
|
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
|
||||||
UserID *string `json:"user_id,omitempty"`
|
UserID *string `json:"user_id,omitempty"`
|
||||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
CardToken string `json:"card_token,omitempty"`
|
CardToken string `json:"card_token,omitempty"`
|
||||||
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
|
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
|
||||||
|
VerificationToken *string `json:"verification_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TillSaleResponse struct {
|
type TillSaleResponse struct {
|
||||||
@@ -54,6 +56,126 @@ func uniqueTillKey() string {
|
|||||||
return "till-" + rand.Text()
|
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) {
|
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
// Defense-in-depth admin check (S-1) — a till sale moves money (charges a
|
// 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
|
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
|
// Serialize till-sale attempts on the idempotency key to prevent concurrent
|
||||||
// same-key requests from both passing the idempotency check, both funding
|
// same-key requests from both passing the idempotency check, both funding
|
||||||
// the gift card, and one dying on the till_sales idempotency_key UNIQUE
|
// 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.
|
// leaves a Square charge with no DB record.
|
||||||
var needsSquarePayment bool
|
var needsSquarePayment bool
|
||||||
var savedCardSqCardID string
|
var savedCardSqCardID string
|
||||||
|
var savedCardCustomerID string
|
||||||
|
|
||||||
// Pending-retry for card_machine: the original Square checkout may still be
|
// 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
|
// 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, `
|
err = tx.QueryRow(ctx, `
|
||||||
SELECT square_card_id
|
SELECT square_card_id, COALESCE(square_customer_id, '')
|
||||||
FROM user_saved_cards
|
FROM user_saved_cards
|
||||||
WHERE id = $1 AND deleted_at IS NULL
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
`, *req.UserSavedCardID).Scan(&savedCardSqCardID)
|
`, *req.UserSavedCardID).Scan(&savedCardSqCardID, &savedCardCustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get saved card details: %v", err)
|
log.Printf("Failed to get saved card details: %v", err)
|
||||||
http.Error(w, "Card not found", http.StatusNotFound)
|
http.Error(w, "Card not found", http.StatusNotFound)
|
||||||
@@ -438,7 +567,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
ReferenceID: giftCardID,
|
ReferenceID: giftCardID,
|
||||||
TipEnabled: false,
|
AllowTipping: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
|
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
|
||||||
@@ -550,11 +679,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
var paymentResult *square.PaymentResult
|
var paymentResult *square.PaymentResult
|
||||||
var squareErr error
|
var squareErr error
|
||||||
|
|
||||||
|
var verificationToken string
|
||||||
|
if req.VerificationToken != nil {
|
||||||
|
verificationToken = *req.VerificationToken
|
||||||
|
}
|
||||||
|
|
||||||
if req.PaymentMethod == "saved_card" {
|
if req.PaymentMethod == "saved_card" {
|
||||||
paymentReq := square.CreatePaymentReq{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: penceAmount,
|
Amount: penceAmount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: savedCardSqCardID,
|
SourceID: savedCardSqCardID,
|
||||||
|
CustomerID: savedCardCustomerID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
@@ -572,8 +707,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
// "till-<giftCardID>" namespace, NOT a real user — this card is
|
// "till-<giftCardID>" namespace, NOT a real user — this card is
|
||||||
// ephemeral (used once for this charge) and is never stored in
|
// ephemeral (used once for this charge) and is never stored in
|
||||||
// user_saved_cards or re-listed. The prefix can't collide with a
|
// user_saved_cards or re-listed. The prefix can't collide with a
|
||||||
// real CHAR(12)-hex user ID.
|
// real CHAR(12)-hex user ID. No Square customer is provisioned for
|
||||||
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken)
|
// it ("" as the customerID): a cnon: nonce charge needs none.
|
||||||
|
cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken, "")
|
||||||
if cardErr != nil {
|
if cardErr != nil {
|
||||||
log.Printf("Failed to tokenize card: %v", cardErr)
|
log.Printf("Failed to tokenize card: %v", cardErr)
|
||||||
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
|
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
|
||||||
@@ -581,18 +717,36 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
paymentReq := square.CreatePaymentReq{
|
paymentReq := square.CreatePaymentReq{
|
||||||
Amount: penceAmount,
|
Amount: penceAmount,
|
||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
SourceID: cardOnFile.CardID,
|
SourceID: cardOnFile.CardID,
|
||||||
IdempotencyKey: req.IdempotencyKey,
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
Note: "Gift Card " + req.Action,
|
Note: "Gift Card " + req.Action,
|
||||||
BuyerEmail: buyerEmail,
|
BuyerEmail: buyerEmail,
|
||||||
|
VerificationToken: verificationToken,
|
||||||
}
|
}
|
||||||
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
if squareErr != nil {
|
if squareErr != nil {
|
||||||
log.Printf("Failed to process payment: %v", squareErr)
|
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)
|
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) {
|
|||||||
|
|
||||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create saved card: %v", err)
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create saved card: %v", err)
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("failed to create saved card: %v", err)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ func ValidateCardInfo(cardID, newCardToken *string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SquareWebhookEvent struct {
|
type SquareWebhookEvent struct {
|
||||||
@@ -19,6 +20,48 @@ type SquareWebhookEvent struct {
|
|||||||
LocationID string `json:"location_id"`
|
LocationID string `json:"location_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// squareWebhookDedup is a bounded, mutex-guarded set of recently handled
|
||||||
|
// event IDs. Square redelivers signed webhooks on retries (or a replay); once
|
||||||
|
// the handlers mutate state a duplicate delivery would double-apply, so drop
|
||||||
|
// replays while keeping the set bounded.
|
||||||
|
type squareWebhookDedup struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]struct{}
|
||||||
|
order []string
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSquareWebhookDedup(max int) *squareWebhookDedup {
|
||||||
|
return &squareWebhookDedup{
|
||||||
|
seen: make(map[string]struct{}),
|
||||||
|
order: make([]string, 0, max),
|
||||||
|
max: max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register reports whether id was already handled: false on first occurrence
|
||||||
|
// (recording id, evicting the oldest once the cap is reached), true on a
|
||||||
|
// replay (set untouched, preserving insertion order). Mutex-guarded — the
|
||||||
|
// handler may be hit concurrently.
|
||||||
|
func (d *squareWebhookDedup) register(id string) bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if _, ok := d.seen[id]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
d.seen[id] = struct{}{}
|
||||||
|
d.order = append(d.order, id)
|
||||||
|
if len(d.order) > d.max {
|
||||||
|
oldest := d.order[0]
|
||||||
|
d.order = d.order[1:]
|
||||||
|
delete(d.seen, oldest)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1000 IDs far exceeds Square's redelivery window while capping memory.
|
||||||
|
var squareWebhookEventsSeen = newSquareWebhookDedup(1000)
|
||||||
|
|
||||||
func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
|
func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 512*1024)
|
r.Body = http.MaxBytesReader(w, r.Body, 512*1024)
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
@@ -67,6 +110,16 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedup BEFORE dispatch: a correctly signed replay of a handled event
|
||||||
|
// must not re-enter the handlers (which will mutate state once wired).
|
||||||
|
// Returns 200 to acknowledge delivery without processing.
|
||||||
|
if event.EventID != "" && squareWebhookEventsSeen.register(event.EventID) {
|
||||||
|
log.Printf("[SQUARE-WEBHOOK] Duplicate event_id %s; skipping (already processed)", event.EventID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[SQUARE-WEBHOOK] Received event: %s", event.Type)
|
log.Printf("[SQUARE-WEBHOOK] Received event: %s", event.Type)
|
||||||
|
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -262,3 +263,83 @@ func TestHandleSquareWebhook_RejectedWhenKeyEmpty(t *testing.T) {
|
|||||||
t.Errorf("expected 503 when no key configured (fail-closed), got %d. body: %s", w.Code, w.Body.String())
|
t.Errorf("expected 503 when no key configured (fail-closed), got %d. body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Dedup — event_id replay protection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestHandleSquareWebhook_DuplicateEventID(t *testing.T) {
|
||||||
|
event := SquareWebhookEvent{
|
||||||
|
Type: "payment.updated",
|
||||||
|
EventID: "evt_http_dup_1",
|
||||||
|
CreatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
Data: json.RawMessage(`{"id":"payment_dup_1"}`),
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(event)
|
||||||
|
sig := webhookTestEnv(t, body)
|
||||||
|
|
||||||
|
// First delivery processes the event.
|
||||||
|
w := makeWebhookRequest(body, sig, context.Background())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected first delivery 200, got %d. body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay of the same signed event returns 200 but skips dispatch.
|
||||||
|
w2 := makeWebhookRequest(body, sig, context.Background())
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected replay 200, got %d. body: %s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
if w2.Body.String() != "ok" {
|
||||||
|
t.Errorf("expected replay body 'ok', got %q", w2.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSquareWebhook_DistinctEventIDs(t *testing.T) {
|
||||||
|
for _, id := range []string{"evt_distinct_1", "evt_distinct_2"} {
|
||||||
|
event := SquareWebhookEvent{
|
||||||
|
Type: "payment.updated",
|
||||||
|
EventID: id,
|
||||||
|
CreatedAt: "2025-01-01T00:00:00Z",
|
||||||
|
Data: json.RawMessage(`{"id":"p"}`),
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(event)
|
||||||
|
sig := webhookTestEnv(t, body)
|
||||||
|
w := makeWebhookRequest(body, sig, context.Background())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for %s, got %d. body: %s", id, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSquareWebhookDedup_FirstThenDuplicate(t *testing.T) {
|
||||||
|
d := newSquareWebhookDedup(1000)
|
||||||
|
if d.register("evt_dedup_1") {
|
||||||
|
t.Error("expected first occurrence to register as new")
|
||||||
|
}
|
||||||
|
if !d.register("evt_dedup_1") {
|
||||||
|
t.Error("expected second occurrence to register as duplicate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSquareWebhookDedup_CapEvictsOldest(t *testing.T) {
|
||||||
|
d := newSquareWebhookDedup(3)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if d.register(fmt.Sprintf("evt_cap_%d", i)) {
|
||||||
|
t.Errorf("expected evt_cap_%d to register as new", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The 4th distinct ID pushes out the oldest (evt_cap_0).
|
||||||
|
if d.register("evt_cap_3") {
|
||||||
|
t.Errorf("expected evt_cap_3 to register as new")
|
||||||
|
}
|
||||||
|
// Survivors still dedupe (register returns true without mutating the set).
|
||||||
|
for _, id := range []string{"evt_cap_1", "evt_cap_2", "evt_cap_3"} {
|
||||||
|
if !d.register(id) {
|
||||||
|
t.Errorf("expected %s to still be a duplicate", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The evicted ID is treated as new again.
|
||||||
|
if d.register("evt_cap_0") {
|
||||||
|
t.Errorf("expected evt_cap_0 to be evicted and treated as new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ func RegisterAll(s *Scheduler) {
|
|||||||
Handler: payments.SweepStalePendingPayments,
|
Handler: payments.SweepStalePendingPayments,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cancels terminal (card-machine) checkouts still pending at Square after
|
||||||
|
// an hour — a never-polled checkout would otherwise sit live indefinitely
|
||||||
|
// and complete into an invisible, untracked charge.
|
||||||
|
s.Register(Job{
|
||||||
|
Name: "sweep-stale-terminal-checkouts",
|
||||||
|
Schedule: "*/15 * * * *",
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Concurrency: 1,
|
||||||
|
Handler: payments.SweepStaleTerminalCheckouts,
|
||||||
|
})
|
||||||
|
|
||||||
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
|
// === MID FREQUENCY — every minute (progressive rate limiter was on 30s) ===
|
||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
@@ -156,7 +167,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "apply-default-hours",
|
Name: "apply-default-hours",
|
||||||
Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race
|
Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.ApplyScheduledDefaultHours,
|
Handler: scheduling.ApplyScheduledDefaultHours,
|
||||||
@@ -166,7 +177,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "notify-unpaid-1-week",
|
Name: "notify-unpaid-1-week",
|
||||||
Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days
|
Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days
|
||||||
Timeout: 2 * time.Minute,
|
Timeout: 2 * time.Minute,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.NotifyUnpaidOneWeek,
|
Handler: scheduling.NotifyUnpaidOneWeek,
|
||||||
@@ -174,7 +185,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "notify-unpaid-1-month",
|
Name: "notify-unpaid-1-month",
|
||||||
Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week)
|
Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week)
|
||||||
Timeout: 2 * time.Minute,
|
Timeout: 2 * time.Minute,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.NotifyUnpaidOneMonth,
|
Handler: scheduling.NotifyUnpaidOneMonth,
|
||||||
@@ -182,7 +193,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "transition-discount-campaigns",
|
Name: "transition-discount-campaigns",
|
||||||
Schedule: "0 * * * *", // Hourly
|
Schedule: "0 * * * *", // Hourly
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.TransitionDiscountCampaigns,
|
Handler: scheduling.TransitionDiscountCampaigns,
|
||||||
@@ -190,7 +201,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "cleanup-verification-codes",
|
Name: "cleanup-verification-codes",
|
||||||
Schedule: "0 2 * * *", // Daily at 2am
|
Schedule: "0 2 * * *", // Daily at 2am
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.CleanupExpiredVerificationCodes,
|
Handler: scheduling.CleanupExpiredVerificationCodes,
|
||||||
@@ -198,7 +209,7 @@ func RegisterAll(s *Scheduler) {
|
|||||||
|
|
||||||
s.Register(Job{
|
s.Register(Job{
|
||||||
Name: "cleanup-refresh-tokens",
|
Name: "cleanup-refresh-tokens",
|
||||||
Schedule: "0 2 * * *", // Daily at 2am
|
Schedule: "0 2 * * *", // Daily at 2am
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
Handler: scheduling.CleanupExpiredRefreshTokens,
|
Handler: scheduling.CleanupExpiredRefreshTokens,
|
||||||
|
|||||||
@@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) {
|
|||||||
s := New()
|
s := New()
|
||||||
RegisterAll(s)
|
RegisterAll(s)
|
||||||
|
|
||||||
if got := len(s.registry); got != 22 {
|
if got := len(s.registry); got != 23 {
|
||||||
t.Fatalf("RegisterAll() registered %d jobs, want 22", got)
|
t.Fatalf("RegisterAll() registered %d jobs, want 23", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
registered := make(map[string]Job, len(s.registry))
|
registered := make(map[string]Job, len(s.registry))
|
||||||
@@ -474,6 +474,7 @@ func expectedJobNames() map[string]bool {
|
|||||||
"cleanup-gdpr-export-cache": true,
|
"cleanup-gdpr-export-cache": true,
|
||||||
"sweep-pending-square-refunds": true,
|
"sweep-pending-square-refunds": true,
|
||||||
"sweep-stale-pending-payments": true,
|
"sweep-stale-pending-payments": true,
|
||||||
|
"sweep-stale-terminal-checkouts": true,
|
||||||
"cleanup-progressive-rate-limiter": true,
|
"cleanup-progressive-rate-limiter": true,
|
||||||
"cleanup-expired-loyalty-redemptions": true,
|
"cleanup-expired-loyalty-redemptions": true,
|
||||||
"cleanup-old-idempotency-keys": true,
|
"cleanup-old-idempotency-keys": true,
|
||||||
@@ -511,7 +512,7 @@ func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) {
|
|||||||
// disjoint tables (no contention risk).
|
// disjoint tables (no contention risk).
|
||||||
knownGroupings := map[int]bool{
|
knownGroupings := map[int]bool{
|
||||||
5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains
|
5: true, // */5 * * * * — 5 cleanup jobs (incl. sweep-pending-square-refunds), different domains
|
||||||
// 0 * * * * — 5 hourly cleanup jobs, different tables
|
// 0 * * * * — 5 hourly cleanup jobs, different tables
|
||||||
2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables
|
2: true, // 0 2 * * * — 2 daily cleanup jobs, different tables
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,24 @@ func (p *ProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
|
|||||||
return getCheckoutHTTP(ctx, checkoutID)
|
return getCheckoutHTTP(ctx, checkoutID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||||
|
return getPaymentHTTP(ctx, paymentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||||
|
return createCustomerHTTP(ctx, name, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProdClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||||
|
return cancelCheckoutHTTP(ctx, checkoutID)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
func (p *ProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||||
return refundPaymentHTTP(ctx, req)
|
return refundPaymentHTTP(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package square
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crussell/clock"
|
"crussell/clock"
|
||||||
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -31,6 +32,7 @@ type MockClient struct {
|
|||||||
paymentByKey map[string]*PaymentResult
|
paymentByKey map[string]*PaymentResult
|
||||||
refunds map[string]*RefundResult
|
refunds map[string]*RefundResult
|
||||||
refundByKey map[string]*RefundResult
|
refundByKey map[string]*RefundResult
|
||||||
|
customers map[string]*CustomerResult
|
||||||
completed map[string]*PaymentResult
|
completed map[string]*PaymentResult
|
||||||
HoldCheckouts bool
|
HoldCheckouts bool
|
||||||
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
ShouldFail bool // if true, CreatePayment/RefundPayment return errors for testing error paths
|
||||||
@@ -38,6 +40,10 @@ type MockClient struct {
|
|||||||
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
|
// = normal success; when set (e.g. "PAYMENT_ALREADY_REFUNDED"),
|
||||||
// RefundPayment returns the sentinel-wrapped error for that code.
|
// RefundPayment returns the sentinel-wrapped error for that code.
|
||||||
FailRefundCode string
|
FailRefundCode string
|
||||||
|
// ForceRefundPending makes RefundPayment return a PENDING refund so the
|
||||||
|
// prod-only pending-refund branch (normally only reachable against the
|
||||||
|
// real Square API) can be exercised in dev/tests.
|
||||||
|
ForceRefundPending bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type devProdClient struct{}
|
type devProdClient struct{}
|
||||||
@@ -51,11 +57,20 @@ func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutRe
|
|||||||
func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) {
|
||||||
return getCheckoutHTTP(ctx, checkoutID)
|
return getCheckoutHTTP(ctx, checkoutID)
|
||||||
}
|
}
|
||||||
|
func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||||
|
return getPaymentHTTP(ctx, paymentID)
|
||||||
|
}
|
||||||
|
func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||||
|
return createCustomerHTTP(ctx, name, email)
|
||||||
|
}
|
||||||
|
func (d *devProdClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||||
|
return cancelCheckoutHTTP(ctx, checkoutID)
|
||||||
|
}
|
||||||
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
func (d *devProdClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||||
return refundPaymentHTTP(ctx, req)
|
return refundPaymentHTTP(ctx, req)
|
||||||
}
|
}
|
||||||
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||||
return createCardOnFileHTTP(ctx, userID, cardToken)
|
return createCardOnFileHTTP(ctx, userID, cardToken, customerID)
|
||||||
}
|
}
|
||||||
func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) {
|
||||||
return getCardsOnFileHTTP(ctx, userID)
|
return getCardsOnFileHTTP(ctx, userID)
|
||||||
@@ -85,6 +100,7 @@ func NewDevClient() SquareClient {
|
|||||||
paymentByKey: make(map[string]*PaymentResult),
|
paymentByKey: make(map[string]*PaymentResult),
|
||||||
refunds: make(map[string]*RefundResult),
|
refunds: make(map[string]*RefundResult),
|
||||||
refundByKey: make(map[string]*RefundResult),
|
refundByKey: make(map[string]*RefundResult),
|
||||||
|
customers: make(map[string]*CustomerResult),
|
||||||
completed: make(map[string]*PaymentResult),
|
completed: make(map[string]*PaymentResult),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +124,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
if m.ShouldFail {
|
if m.ShouldFail {
|
||||||
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
|
||||||
}
|
}
|
||||||
|
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||||
|
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||||
|
// mock behaves identically to production (PCI-DSS parity).
|
||||||
|
if !isTokenLike(req.SourceID) {
|
||||||
|
return nil, fmt.Errorf("invalid source_id: %q — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", req.SourceID)
|
||||||
|
}
|
||||||
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
|
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
|
||||||
// card reference (ccof:) that could be replayed. Log only its prefix and
|
// card reference (ccof:) that could be replayed. Log only its prefix and
|
||||||
// length for debugging (S-2).
|
// length for debugging (S-2).
|
||||||
@@ -164,6 +186,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
locationID = "L_MOCK"
|
locationID = "L_MOCK"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expMonth := 12
|
||||||
|
expYear := 2030
|
||||||
|
|
||||||
result := &PaymentResult{
|
result := &PaymentResult{
|
||||||
ID: paymentID,
|
ID: paymentID,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -171,8 +196,8 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
CardBrand: cardBrand,
|
CardBrand: cardBrand,
|
||||||
CardLast4: cardLast4,
|
CardLast4: cardLast4,
|
||||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()),
|
||||||
ExpMonth: 12,
|
ExpMonth: &expMonth,
|
||||||
ExpYear: 2030,
|
ExpYear: &expYear,
|
||||||
EntryMethod: entryMethod,
|
EntryMethod: entryMethod,
|
||||||
CVVStatus: "CVV_ACCEPTED",
|
CVVStatus: "CVV_ACCEPTED",
|
||||||
AVSStatus: "AVS_ACCEPTED",
|
AVSStatus: "AVS_ACCEPTED",
|
||||||
@@ -198,7 +223,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, tipEnabled=%v, reference=%s", req.Amount, req.TipEnabled, req.ReferenceID)
|
log.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, allowTipping=%v, reference=%s", req.Amount, req.AllowTipping, req.ReferenceID)
|
||||||
|
|
||||||
now := clock.Now().UTC()
|
now := clock.Now().UTC()
|
||||||
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
|
checkoutID := fmt.Sprintf("chk_mock_%d", now.UnixNano())
|
||||||
@@ -239,12 +264,15 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
|||||||
paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano())
|
paymentID := fmt.Sprintf("pay_mock_%d", payNow.UnixNano())
|
||||||
amount := req.Amount
|
amount := req.Amount
|
||||||
tipAmount := int64(0)
|
tipAmount := int64(0)
|
||||||
if req.TipEnabled {
|
if req.AllowTipping {
|
||||||
tipAmount = 500
|
tipAmount = 500
|
||||||
amount += tipAmount
|
amount += tipAmount
|
||||||
}
|
}
|
||||||
fees := amount * 175 / 10000 // in-person rate: 1.75%
|
fees := amount * 175 / 10000 // in-person rate: 1.75%
|
||||||
|
|
||||||
|
expMonth := 12
|
||||||
|
expYear := 2030
|
||||||
|
|
||||||
paymentResult := &PaymentResult{
|
paymentResult := &PaymentResult{
|
||||||
ID: paymentID,
|
ID: paymentID,
|
||||||
Status: "COMPLETED",
|
Status: "COMPLETED",
|
||||||
@@ -252,8 +280,8 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
|
|||||||
CardBrand: "VISA",
|
CardBrand: "VISA",
|
||||||
CardLast4: "4242",
|
CardLast4: "4242",
|
||||||
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()),
|
CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()),
|
||||||
ExpMonth: 12,
|
ExpMonth: &expMonth,
|
||||||
ExpYear: 2030,
|
ExpYear: &expYear,
|
||||||
EntryMethod: "EMV",
|
EntryMethod: "EMV",
|
||||||
CVVStatus: "CVV_ACCEPTED",
|
CVVStatus: "CVV_ACCEPTED",
|
||||||
AVSStatus: "AVS_ACCEPTED",
|
AVSStatus: "AVS_ACCEPTED",
|
||||||
@@ -302,6 +330,19 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||||
|
log.Printf("[SQUARE-MOCK] GetPayment: id=%s", paymentID)
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
payment, ok := m.payments[paymentID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("payment not found: %s", paymentID)
|
||||||
|
}
|
||||||
|
return payment, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
|
||||||
if m.ShouldFail {
|
if m.ShouldFail {
|
||||||
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
|
return nil, fmt.Errorf("%w: refund declined (simulated failure)", ErrRefundDeclined)
|
||||||
@@ -336,6 +377,13 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
|||||||
|
|
||||||
payment, ok := m.payments[req.PaymentID]
|
payment, ok := m.payments[req.PaymentID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if req.Amount == 0 {
|
||||||
|
// A £0 refund resolves to a full refund only when the payment is
|
||||||
|
// known; against an unknown payment there is nothing to size it
|
||||||
|
// from. The real DB has a CHECK (amount > 0), so an empty refund
|
||||||
|
// must fail rather than silently record £0.
|
||||||
|
return nil, fmt.Errorf("square: refund amount must be positive (payment %s not found, cannot resolve full refund)", req.PaymentID)
|
||||||
|
}
|
||||||
// Payment not in mock map — this happens when integration tests
|
// Payment not in mock map — this happens when integration tests
|
||||||
// create payments via DB fixture with a square_payment_id, bypassing
|
// create payments via DB fixture with a square_payment_id, bypassing
|
||||||
// the mock. Process the refund without full payment data.
|
// the mock. Process the refund without full payment data.
|
||||||
@@ -352,9 +400,14 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
|||||||
locationID = "L_MOCK"
|
locationID = "L_MOCK"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
status := "COMPLETED"
|
||||||
|
if m.ForceRefundPending {
|
||||||
|
status = "PENDING"
|
||||||
|
}
|
||||||
|
|
||||||
result := &RefundResult{
|
result := &RefundResult{
|
||||||
ID: refundID,
|
ID: refundID,
|
||||||
Status: "COMPLETED",
|
Status: status,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
PaymentID: req.PaymentID,
|
PaymentID: req.PaymentID,
|
||||||
LocationID: locationID,
|
LocationID: locationID,
|
||||||
@@ -369,7 +422,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||||
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)
|
||||||
|
|
||||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||||
@@ -450,8 +503,8 @@ func (m *MockClient) DeleteCardOnFile(ctx context.Context, cardID string) error
|
|||||||
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
func (m *MockClient) ListPaymentRefunds(ctx context.Context, paymentID string, beginTime time.Time) ([]RefundResult, error) {
|
||||||
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
|
log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.RLock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
out := []RefundResult{}
|
out := []RefundResult{}
|
||||||
for _, r := range m.refunds {
|
for _, r := range m.refunds {
|
||||||
@@ -473,6 +526,63 @@ func isTokenLike(s string) bool {
|
|||||||
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
return strings.HasPrefix(s, "cnon:") || strings.HasPrefix(s, "ccof:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// redactedEmail masks a customer email for dev logs (PII, S-2 convention):
|
||||||
|
// only the first two characters of the local part plus the domain are shown,
|
||||||
|
// e.g. "ja***@example.com". Malformed addresses fall back to "[redacted]".
|
||||||
|
func redactedEmail(email string) string {
|
||||||
|
at := strings.Index(email, "@")
|
||||||
|
if at < 2 || at+1 >= len(email) {
|
||||||
|
return "[redacted]"
|
||||||
|
}
|
||||||
|
return email[:2] + "***@" + email[at+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||||
|
log.Printf("[SQUARE-MOCK] CreateCustomer: name=%s, email=%s", name, redactedEmail(email))
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
return nil, fmt.Errorf("mock: customer email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Real Square dedups on the idempotency key (derived from the email);
|
||||||
|
// the mock mirrors this by deduping on email so a retry returns the
|
||||||
|
// original customer rather than creating a duplicate.
|
||||||
|
if existing, ok := m.customers[email]; ok {
|
||||||
|
log.Printf("[SQUARE-MOCK] CreateCustomer dedup hit: email=%s → id=%s", redactedEmail(email), existing.ID)
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(email))
|
||||||
|
customer := &CustomerResult{
|
||||||
|
ID: "cus_mock_" + fmt.Sprintf("%x", sum)[:12],
|
||||||
|
Email: email,
|
||||||
|
CreatedAt: clock.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
m.customers[email] = customer
|
||||||
|
log.Printf("[SQUARE-MOCK] Customer created: id=%s, email=%s", customer.ID, redactedEmail(email))
|
||||||
|
return customer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
||||||
|
log.Printf("[SQUARE-MOCK] CancelCheckout: id=%s", checkoutID)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Real Square cancels only pending/in-progress checkouts; a completed or
|
||||||
|
// missing checkout is a no-op (Square returns 404/NOT_FOUND in prod).
|
||||||
|
if checkout, ok := m.checkouts[checkoutID]; ok {
|
||||||
|
if checkout.Status == "PENDING" || checkout.Status == "IN_PROGRESS" {
|
||||||
|
checkout.Status = "CANCELED"
|
||||||
|
checkout.UpdatedAt = clock.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func realBaseURL(env string) string {
|
func realBaseURL(env string) string {
|
||||||
if env == "production" {
|
if env == "production" {
|
||||||
return squareProductionURL
|
return squareProductionURL
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
package square
|
package square
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -36,8 +40,10 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
|
|||||||
assert.NotZero(t, result.Fees)
|
assert.NotZero(t, result.Fees)
|
||||||
|
|
||||||
assert.NotEmpty(t, result.CardFingerprint)
|
assert.NotEmpty(t, result.CardFingerprint)
|
||||||
assert.Equal(t, 12, result.ExpMonth)
|
require.NotNil(t, result.ExpMonth)
|
||||||
assert.Equal(t, 2030, result.ExpYear)
|
assert.Equal(t, 12, *result.ExpMonth)
|
||||||
|
require.NotNil(t, result.ExpYear)
|
||||||
|
assert.Equal(t, 2030, *result.ExpYear)
|
||||||
assert.Equal(t, "KEYED", result.EntryMethod)
|
assert.Equal(t, "KEYED", result.EntryMethod)
|
||||||
assert.Equal(t, "CVV_ACCEPTED", result.CVVStatus)
|
assert.Equal(t, "CVV_ACCEPTED", result.CVVStatus)
|
||||||
assert.Equal(t, "AVS_ACCEPTED", result.AVSStatus)
|
assert.Equal(t, "AVS_ACCEPTED", result.AVSStatus)
|
||||||
@@ -55,7 +61,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
|
|||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
IdempotencyKey: "checkout-key-1",
|
IdempotencyKey: "checkout-key-1",
|
||||||
ReferenceID: "booking-456",
|
ReferenceID: "booking-456",
|
||||||
TipEnabled: true,
|
AllowTipping: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := client.CreateCheckout(ctx, req)
|
result, err := client.CreateCheckout(ctx, req)
|
||||||
@@ -91,7 +97,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
|
|||||||
Currency: "GBP",
|
Currency: "GBP",
|
||||||
IdempotencyKey: "checkout-key-notip",
|
IdempotencyKey: "checkout-key-notip",
|
||||||
ReferenceID: "booking-789",
|
ReferenceID: "booking-789",
|
||||||
TipEnabled: false,
|
AllowTipping: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := client.CreateCheckout(ctx, req)
|
result, err := client.CreateCheckout(ctx, req)
|
||||||
@@ -157,7 +163,7 @@ func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userID := "user-test-123"
|
userID := "user-test-123"
|
||||||
|
|
||||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEmpty(t, card.ID)
|
assert.NotEmpty(t, card.ID)
|
||||||
@@ -182,10 +188,10 @@ func TestDevClient_CardOnFile_MultipleCards(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userID := "user-test-multiple"
|
userID := "user-test-multiple"
|
||||||
|
|
||||||
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1")
|
card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2")
|
card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, card1.Enabled)
|
assert.True(t, card1.Enabled)
|
||||||
@@ -207,7 +213,7 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userID := "user-test-delete"
|
userID := "user-test-delete"
|
||||||
|
|
||||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete")
|
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||||
@@ -257,7 +263,7 @@ func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber)
|
card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber, "")
|
||||||
require.Error(t, err, "raw PAN must be rejected for production parity")
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
assert.Nil(t, card)
|
assert.Nil(t, card)
|
||||||
assert.Contains(t, err.Error(), "invalid source_id")
|
assert.Contains(t, err.Error(), "invalid source_id")
|
||||||
@@ -535,14 +541,14 @@ func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userID := "user-new-fields"
|
userID := "user-new-fields"
|
||||||
|
|
||||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token")
|
card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, card.Enabled)
|
assert.True(t, card.Enabled)
|
||||||
assert.NotEmpty(t, card.CardholderName)
|
assert.NotEmpty(t, card.CardholderName)
|
||||||
// Local linkage goes in reference_id, NOT customer_id — the app has no
|
// Local linkage goes in reference_id; the mock does not store the
|
||||||
// Square customer provisioning, and a local ID in customer_id would be
|
// customer_id (prod sends it on card creation when the app has provisioned
|
||||||
// rejected by the real Cards API.
|
// a Square customer for the user).
|
||||||
assert.Equal(t, userID, card.ReferenceID)
|
assert.Equal(t, userID, card.ReferenceID)
|
||||||
assert.Empty(t, card.CustomerID)
|
assert.Empty(t, card.CustomerID)
|
||||||
assert.Greater(t, card.Version, int64(0))
|
assert.Greater(t, card.Version, int64(0))
|
||||||
@@ -554,7 +560,7 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userID := "user-soft-delete"
|
userID := "user-soft-delete"
|
||||||
|
|
||||||
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft")
|
card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = client.DeleteCardOnFile(ctx, card.ID)
|
err = client.DeleteCardOnFile(ctx, card.ID)
|
||||||
@@ -703,3 +709,290 @@ func TestDetectCardInfo_Variants(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDevClient_GetPayment_Found(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "payment-for-get",
|
||||||
|
ReferenceID: "booking-get",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := client.GetPayment(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.ID, got.ID)
|
||||||
|
assert.Equal(t, int64(5000), got.Amount)
|
||||||
|
assert.Equal(t, "COMPLETED", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_GetPayment_NotFound(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := client.GetPayment(ctx, "pay_does_not_exist")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CreateCustomer_Dedup(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, first.ID)
|
||||||
|
assert.Equal(t, "jane@example.com", first.Email)
|
||||||
|
assert.NotEmpty(t, first.CreatedAt)
|
||||||
|
assert.True(t, strings.HasPrefix(first.ID, "cus_mock_"))
|
||||||
|
|
||||||
|
// Same email → same deterministic customer (Square dedups on the
|
||||||
|
// email-derived idempotency key; the mock dedups on email).
|
||||||
|
second, err := client.CreateCustomer(ctx, "Jane Doe", "jane@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, first.ID, second.ID, "same-email retry must return the original customer")
|
||||||
|
|
||||||
|
other, err := client.CreateCustomer(ctx, "John Doe", "john@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first.ID, other.ID)
|
||||||
|
|
||||||
|
client.mu.RLock()
|
||||||
|
defer client.mu.RUnlock()
|
||||||
|
assert.Len(t, client.customers, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CreateCustomer_EmptyEmail(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := client.CreateCustomer(ctx, "Jane Doe", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "email")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CancelCheckout_CancelsPending(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
client.HoldCheckouts = true
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||||
|
Amount: 2500,
|
||||||
|
Currency: "GBP",
|
||||||
|
IdempotencyKey: "cancel-checkout",
|
||||||
|
ReferenceID: "cancel-ref",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PENDING", result.Status)
|
||||||
|
|
||||||
|
err = client.CancelCheckout(ctx, result.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.mu.RLock()
|
||||||
|
checkout := client.checkouts[result.ID]
|
||||||
|
client.mu.RUnlock()
|
||||||
|
require.NotNil(t, checkout)
|
||||||
|
assert.Equal(t, "CANCELED", checkout.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CancelCheckout_UnknownIsNoOp(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := client.CancelCheckout(ctx, "chk_does_not_exist")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CancelCheckout_CompletedIsNoOp(t *testing.T) {
|
||||||
|
// Square documents that disabling an already-completed/cancelled checkout
|
||||||
|
// has no effect, so the mock must return nil and leave the status alone.
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := client.CreateCheckout(ctx, CreateCheckoutReq{
|
||||||
|
Amount: 2500,
|
||||||
|
Currency: "GBP",
|
||||||
|
IdempotencyKey: "cancel-completed",
|
||||||
|
ReferenceID: "cancel-comp-ref",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
_, err := client.GetCheckout(ctx, result.ID)
|
||||||
|
return err == nil
|
||||||
|
}, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
|
||||||
|
|
||||||
|
err = client.CancelCheckout(ctx, result.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client.mu.RLock()
|
||||||
|
checkout := client.checkouts[result.ID]
|
||||||
|
client.mu.RUnlock()
|
||||||
|
require.NotNil(t, checkout)
|
||||||
|
assert.Equal(t, "COMPLETED", checkout.Status, "cancelling an already-completed checkout must be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CreateCustomer_RedactsEmailInLogs(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
defer log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
email := "pii.marker@example.com"
|
||||||
|
cust, err := client.CreateCustomer(ctx, "PII Marker", email)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, email, cust.Email, "return value must keep the full email")
|
||||||
|
|
||||||
|
logs := buf.String()
|
||||||
|
if strings.Contains(logs, email) {
|
||||||
|
t.Errorf("full email %q leaked into mock logs: %q", email, logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logs, "pi***@example.com") {
|
||||||
|
t.Errorf("expected redacted email 'pi***@example.com' in logs, got %q", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_CreatePayment_RejectsRawPAN(t *testing.T) {
|
||||||
|
// PCI-DSS parity: CreatePayment accepts only token-like source_ids
|
||||||
|
// (cnon:xxx / ccof:xxx). Raw PANs are rejected exactly like real Square.
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pan string
|
||||||
|
}{
|
||||||
|
{"visa", "4111111111111111"},
|
||||||
|
{"mastercard", "5555555555554444"},
|
||||||
|
{"amex", "378282246310005"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 5000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: tt.pan,
|
||||||
|
IdempotencyKey: "raw-pan-" + tt.name,
|
||||||
|
ReferenceID: "booking-raw",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "raw PAN must be rejected for production parity")
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "invalid source_id")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_RefundPayment_ForcePending(t *testing.T) {
|
||||||
|
// ForceRefundPending exercises the prod-only PENDING refund branch that
|
||||||
|
// is otherwise only reachable against the real Square API.
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
client.ForceRefundPending = true
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 10000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "payment-for-pending-refund",
|
||||||
|
ReferenceID: "booking-pending-refund",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||||
|
PaymentID: paymentResult.ID,
|
||||||
|
Amount: 5000,
|
||||||
|
IdempotencyKey: "pending-refund-key",
|
||||||
|
Reason: "customer request",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "PENDING", refundResult.Status)
|
||||||
|
assert.Equal(t, int64(5000), refundResult.Amount)
|
||||||
|
assert.Equal(t, paymentResult.ID, refundResult.PaymentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_RefundPayment_ZeroAmountUnknownPayment(t *testing.T) {
|
||||||
|
// A £0 refund resolves to a full refund only when the payment is known.
|
||||||
|
// Against an unknown payment it must fail (the real DB has a CHECK
|
||||||
|
// amount > 0) rather than silently record a £0 refund.
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||||
|
PaymentID: "pay_unknown_zero",
|
||||||
|
Amount: 0,
|
||||||
|
IdempotencyKey: "zero-refund-unknown",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), "amount must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_RefundPayment_ZeroAmountFullRefundWhenPaymentExists(t *testing.T) {
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 10000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "payment-for-zero-refund",
|
||||||
|
ReferenceID: "booking-zero-refund",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
refundResult, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||||
|
PaymentID: paymentResult.ID,
|
||||||
|
Amount: 0,
|
||||||
|
IdempotencyKey: "zero-refund-known",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(10000), refundResult.Amount, "amount 0 = full refund when the payment exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevClient_ListPaymentRefunds_ConcurrentReads(t *testing.T) {
|
||||||
|
// Exercises the RLock read path concurrently with writes (Lock) — would
|
||||||
|
// deadlock or panic under -race if ListPaymentRefunds wrongly used a
|
||||||
|
// write lock.
|
||||||
|
client := NewDevClient().(*MockClient)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
paymentResult, err := client.CreatePayment(ctx, CreatePaymentReq{
|
||||||
|
Amount: 10000,
|
||||||
|
Currency: "GBP",
|
||||||
|
SourceID: "cnon:test-card",
|
||||||
|
IdempotencyKey: "payment-for-concurrent-list",
|
||||||
|
ReferenceID: "booking-concurrent-list",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := client.RefundPayment(ctx, RefundPaymentReq{
|
||||||
|
PaymentID: paymentResult.ID,
|
||||||
|
Amount: 100,
|
||||||
|
IdempotencyKey: fmt.Sprintf("refund-concurrent-%d", idx),
|
||||||
|
Reason: "concurrent",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}(i)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results, err := client.ListPaymentRefunds(ctx, paymentResult.ID, time.Now().Add(-time.Hour))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, results, 8)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,15 +194,27 @@ type sqTerminalCheckoutRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sqTerminalCheckoutPayload struct {
|
type sqTerminalCheckoutPayload struct {
|
||||||
AmountMoney sqMoney `json:"amount_money"`
|
AmountMoney sqMoney `json:"amount_money"`
|
||||||
ReferenceID string `json:"reference_id,omitempty"`
|
ReferenceID string `json:"reference_id,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
CustomerID string `json:"customer_id,omitempty"`
|
CustomerID string `json:"customer_id,omitempty"`
|
||||||
DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"`
|
DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sqTipSettings maps to Square's DeviceCheckoutOptions.tip_settings object
|
||||||
|
// (nested INSIDE device_options — a top-level tip_settings is silently ignored
|
||||||
|
// by Square's TerminalCheckout API, losing terminal tip revenue). Only
|
||||||
|
// allow_tipping is emitted — Square's wire field for enabling terminal tips.
|
||||||
|
type sqTipSettings struct {
|
||||||
|
AllowTipping bool `json:"allow_tipping"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqDeviceOptions maps to Square's DeviceCheckoutOptions object inside the
|
||||||
|
// TerminalCheckout payload. device_id is REQUIRED; tip_settings lives here
|
||||||
|
// (not at the checkout top level) so terminal tips are actually collected.
|
||||||
type sqDeviceOptions struct {
|
type sqDeviceOptions struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
|
TipSettings *sqTipSettings `json:"tip_settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqTerminalCheckoutResponse struct {
|
type sqTerminalCheckoutResponse struct {
|
||||||
@@ -214,9 +228,11 @@ type sqTerminalCheckout struct {
|
|||||||
ReferenceID string `json:"reference_id,omitempty"`
|
ReferenceID string `json:"reference_id,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
PaymentIDs []string `json:"payment_ids,omitempty"`
|
PaymentIDs []string `json:"payment_ids,omitempty"`
|
||||||
Deadline string `json:"deadline_duration,omitempty"`
|
// Deadline (deadline_duration) is deprecated in the TerminalCheckout API —
|
||||||
CreatedAt string `json:"created_at"`
|
// retained read-only for informational purposes; harmless when set.
|
||||||
UpdatedAt string `json:"updated_at"`
|
Deadline string `json:"deadline_duration,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqGetPaymentResponse struct {
|
type sqGetPaymentResponse struct {
|
||||||
@@ -280,11 +296,49 @@ type sqDisableCardResponse struct {
|
|||||||
Card sqCard `json:"card"`
|
Card sqCard `json:"card"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Customer types ---
|
||||||
|
|
||||||
|
type sqCreateCustomerRequest struct {
|
||||||
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
GivenName string `json:"given_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sqCreateCustomerResponse struct {
|
||||||
|
Customer sqCustomer `json:"customer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqCustomer maps to Square's Customer object. Only fields this application
|
||||||
|
// consumes are included.
|
||||||
|
type sqCustomer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
GivenName string `json:"given_name"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
// Package-level HTTP functions — shared by ProdClient and devProdClient.
|
||||||
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
// Each builds a fresh httpClient from env vars and makes the Square API call.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// validSquareID reports whether id is safe to embed in a Square REST URL path
|
||||||
|
// segment. Square IDs are alphanumeric plus '_' and '-' and well under 64
|
||||||
|
// characters; anything else could produce a malformed URL or enable path
|
||||||
|
// traversal in a future caller.
|
||||||
|
func validSquareID(id string) bool {
|
||||||
|
if len(id) == 0 || len(id) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(id); i++ {
|
||||||
|
c := id[i]
|
||||||
|
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
||||||
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
||||||
}
|
}
|
||||||
@@ -336,6 +390,12 @@ func createCheckoutHTTPWithClient(ctx context.Context, req CreateCheckoutReq, hc
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// AllowTipping must reach Square as device_options.tip_settings.allow_tipping
|
||||||
|
// — without it the terminal never prompts for a tip and tip revenue is
|
||||||
|
// silently lost. A top-level tip_settings would be ignored by Square.
|
||||||
|
if req.AllowTipping {
|
||||||
|
body.Checkout.DeviceOptions.TipSettings = &sqTipSettings{AllowTipping: true}
|
||||||
|
}
|
||||||
var resp sqTerminalCheckoutResponse
|
var resp sqTerminalCheckoutResponse
|
||||||
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -348,6 +408,9 @@ func getCheckoutHTTP(ctx context.Context, checkoutID string) (*PaymentResult, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) (*PaymentResult, error) {
|
||||||
|
if !validSquareID(checkoutID) {
|
||||||
|
return nil, fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||||
|
}
|
||||||
var tcResp sqTerminalCheckoutResponse
|
var tcResp sqTerminalCheckoutResponse
|
||||||
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -373,6 +436,21 @@ func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpC
|
|||||||
return paymentFromSquare(&payResp.Payment), nil
|
return paymentFromSquare(&payResp.Payment), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPaymentHTTP(ctx context.Context, paymentID string) (*PaymentResult, error) {
|
||||||
|
return getPaymentHTTPWithClient(ctx, paymentID, newHTTPClient())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpClient) (*PaymentResult, error) {
|
||||||
|
if !validSquareID(paymentID) {
|
||||||
|
return nil, fmt.Errorf("square: invalid payment id %q", paymentID)
|
||||||
|
}
|
||||||
|
var resp sqGetPaymentResponse
|
||||||
|
if err := hc.doJSON(ctx, http.MethodGet, "/v2/payments/"+paymentID, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return paymentFromSquare(&resp.Payment), nil
|
||||||
|
}
|
||||||
|
|
||||||
// squareAPIError wraps a formatted Square API error while exposing the
|
// squareAPIError wraps a formatted Square API error while exposing the
|
||||||
// structured Square error code so callers can classify definitive business
|
// structured Square error code so callers can classify definitive business
|
||||||
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
|
// rejections (e.g. ErrRefundDeclined) vs ambiguous transport/server errors.
|
||||||
@@ -385,6 +463,29 @@ type squareAPIError struct {
|
|||||||
func (e *squareAPIError) Error() string { return e.err.Error() }
|
func (e *squareAPIError) Error() string { return e.err.Error() }
|
||||||
func (e *squareAPIError) Unwrap() error { return e.err }
|
func (e *squareAPIError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// ErrorCode returns the Square error Code carried by err when err (or any
|
||||||
|
// error it wraps) is a *squareAPIError — i.e. a structured error parsed from
|
||||||
|
// Square's error response body. It returns "" for non-Square errors so callers
|
||||||
|
// can classify charge failures structurally instead of substring-matching the
|
||||||
|
// message.
|
||||||
|
func ErrorCode(err error) string {
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.Code
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail returns the Square error Detail carried by err when err (or any
|
||||||
|
// error it wraps) is a *squareAPIError, and "" otherwise.
|
||||||
|
func ErrorDetail(err error) string {
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) {
|
||||||
|
return sqErr.Detail
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Definitive Square refund rejection codes — the refund was declined and can
|
// Definitive Square refund rejection codes — the refund was declined and can
|
||||||
// never succeed, so retrying is pointless and the refund record should be
|
// never succeed, so retrying is pointless and the refund record should be
|
||||||
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
// marked 'failed'. Anything else (transport errors, 5xx) is left ambiguous so
|
||||||
@@ -448,14 +549,18 @@ func listRefundsHTTPWithClient(ctx context.Context, paymentID string, beginTime
|
|||||||
}
|
}
|
||||||
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
path = base + "&cursor=" + url.QueryEscape(resp.Cursor)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("square: list refunds exceeded 20 pages (infinite loop guard)")
|
// 20 pages fetched and a cursor is still present — return what we
|
||||||
|
// collected rather than discarding partial results (the previous
|
||||||
|
// infinite-loop guard dropped everything and returned an error).
|
||||||
|
log.Printf("[SQUARE] list refunds exceeded 20 pages (infinite-loop guard) — returning partial results: %d refunds for %s", len(results), paymentID)
|
||||||
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCardOnFileHTTP(ctx context.Context, userID, cardToken string) (*CardOnFile, error) {
|
func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
|
||||||
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient())
|
return createCardOnFileHTTPWithClient(ctx, userID, cardToken, customerID, newHTTPClient())
|
||||||
}
|
}
|
||||||
|
|
||||||
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken string, hc *httpClient) (*CardOnFile, error) {
|
func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, customerID string, hc *httpClient) (*CardOnFile, error) {
|
||||||
|
|
||||||
// Deterministic idempotency key derived from user + card (not time-based)
|
// Deterministic idempotency key derived from user + card (not time-based)
|
||||||
// so that retries with the same details don't create duplicate cards.
|
// so that retries with the same details don't create duplicate cards.
|
||||||
@@ -467,11 +572,13 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken strin
|
|||||||
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
||||||
SourceID: cardToken,
|
SourceID: cardToken,
|
||||||
Card: sqCardPayload{
|
Card: sqCardPayload{
|
||||||
// The app does not provision Square customers, so the local user
|
|
||||||
// ID must NOT be sent as customer_id (Square would reject it).
|
|
||||||
// reference_id is Square's free-form client reference, used to link
|
// reference_id is Square's free-form client reference, used to link
|
||||||
// the card to the local user for client-side filtering.
|
// the card to the local user for client-side filtering. customer_id
|
||||||
|
// is sent when the app has provisioned a Square customer for the
|
||||||
|
// user (Square marks customer_id Required on the Card object for
|
||||||
|
// saved-card flows) and omitted otherwise.
|
||||||
ReferenceID: userID,
|
ReferenceID: userID,
|
||||||
|
CustomerID: customerID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var resp sqCreateCardResponse
|
var resp sqCreateCardResponse
|
||||||
@@ -488,9 +595,10 @@ func getCardsOnFileHTTP(ctx context.Context, userID string) ([]CardOnFile, error
|
|||||||
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpClient) ([]CardOnFile, error) {
|
||||||
// Filter by reference_id natively: Square's List Cards API supports the
|
// Filter by reference_id natively: Square's List Cards API supports the
|
||||||
// reference_id query param, and cards are created with reference_id = the
|
// reference_id query param, and cards are created with reference_id = the
|
||||||
// local user ID (the app has no Square customers, so customer_id cannot be
|
// local user ID. customer_id is not used for the filter because a user may
|
||||||
// used). List Cards pages at 25 cards, so loop on the cursor to avoid
|
// have no provisioned Square customer. List Cards pages at 25 cards, so
|
||||||
// silently truncating a large saved-card list (N-10).
|
// loop on the cursor to avoid silently truncating a large saved-card list
|
||||||
|
// (N-10).
|
||||||
var cards []CardOnFile
|
var cards []CardOnFile
|
||||||
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
path := "/v2/cards?reference_id=" + url.QueryEscape(userID)
|
||||||
for page := 0; page < 20; page++ {
|
for page := 0; page < 20; page++ {
|
||||||
@@ -521,6 +629,56 @@ func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResult, error) {
|
||||||
|
return createCustomerHTTPWithClient(ctx, name, email, newHTTPClient())
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *httpClient) (*CustomerResult, error) {
|
||||||
|
// Deterministic idempotency key derived from the email (not time-based)
|
||||||
|
// so retries with the same email don't create duplicate customers. SHA-256
|
||||||
|
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
||||||
|
// Square's documented idempotency-key limit.
|
||||||
|
ikHash := sha256.Sum256([]byte(email))
|
||||||
|
body := sqCreateCustomerRequest{
|
||||||
|
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
||||||
|
EmailAddress: email,
|
||||||
|
GivenName: name,
|
||||||
|
}
|
||||||
|
var resp sqCreateCustomerResponse
|
||||||
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/customers", body, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CustomerResult{
|
||||||
|
ID: resp.Customer.ID,
|
||||||
|
Email: resp.Customer.EmailAddress,
|
||||||
|
CreatedAt: resp.Customer.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCheckoutHTTP(ctx context.Context, checkoutID string) error {
|
||||||
|
return cancelCheckoutHTTPWithClient(ctx, checkoutID, newHTTPClient())
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpClient) error {
|
||||||
|
if !validSquareID(checkoutID) {
|
||||||
|
return fmt.Errorf("square: invalid checkout id %q", checkoutID)
|
||||||
|
}
|
||||||
|
var resp sqTerminalCheckoutResponse
|
||||||
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts/"+checkoutID+"/cancel", nil, &resp); err != nil {
|
||||||
|
// Square returns 404 / NOT_FOUND when the checkout is already
|
||||||
|
// completed or canceled — that is a no-op, not a failure.
|
||||||
|
var sqErr *squareAPIError
|
||||||
|
if errors.As(err, &sqErr) && sqErr.Code == "NOT_FOUND" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "HTTP 404") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Conversion helpers — Square JSON → domain types.
|
// Conversion helpers — Square JSON → domain types.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -552,16 +710,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
|||||||
r.EntryMethod = cd.EntryMethod
|
r.EntryMethod = cd.EntryMethod
|
||||||
r.CVVStatus = cd.CVVStatus
|
r.CVVStatus = cd.CVVStatus
|
||||||
r.AVSStatus = cd.AVSStatus
|
r.AVSStatus = cd.AVSStatus
|
||||||
|
r.CardBrand = cd.Card.CardBrand
|
||||||
|
r.CardLast4 = cd.Card.Last4
|
||||||
|
// exp_month/exp_year ride on the card object — pointer set when present.
|
||||||
|
expMonth := cd.Card.ExpMonth
|
||||||
|
expYear := cd.Card.ExpYear
|
||||||
|
r.ExpMonth = &expMonth
|
||||||
|
r.ExpYear = &expYear
|
||||||
if cd.Card.ID != "" {
|
if cd.Card.ID != "" {
|
||||||
r.CardBrand = cd.Card.CardBrand
|
|
||||||
r.CardLast4 = cd.Card.Last4
|
|
||||||
r.CardFingerprint = cd.Card.Fingerprint
|
r.CardFingerprint = cd.Card.Fingerprint
|
||||||
r.ExpMonth = cd.Card.ExpMonth
|
|
||||||
r.ExpYear = cd.Card.ExpYear
|
|
||||||
} else {
|
|
||||||
// Card details present but no card ID — still surface the brand/last4.
|
|
||||||
r.CardBrand = cd.Card.CardBrand
|
|
||||||
r.CardLast4 = cd.Card.Last4
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -506,23 +506,29 @@ func TestListRefundsHTTP_Pagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("page_guard_triggers_after_20_pages", func(t *testing.T) {
|
t.Run("page_guard_returns_partial_results", func(t *testing.T) {
|
||||||
|
// The 20-page guard must not discard what was already collected: it
|
||||||
|
// logs a truncation warning and returns the partial results instead
|
||||||
|
// of failing the reconcile with an error.
|
||||||
calls := 0
|
calls := 0
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
calls++
|
calls++
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"refunds":[],"cursor":"next"}`))
|
_, _ = w.Write([]byte(`{"refunds":[{"id":"ref_x","status":"COMPLETED","amount_money":{"amount":100,"currency":"GBP"},"payment_id":"pay_partial","created_at":"2026-07-31T00:00:00Z"}],"cursor":"next"}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
_, err := listRefundsHTTPWithClient(context.Background(), "pay_x", time.Now(), hc)
|
refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc)
|
||||||
if err == nil || !strings.Contains(err.Error(), "exceeded 20 pages") {
|
if err != nil {
|
||||||
t.Fatalf("expected 20-page guard error, got %v", err)
|
t.Fatalf("expected partial results (nil error), got %v", err)
|
||||||
}
|
}
|
||||||
if calls != 20 {
|
if calls != 20 {
|
||||||
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
|
t.Errorf("expected exactly 20 HTTP calls before guard, got %d", calls)
|
||||||
}
|
}
|
||||||
|
if len(refunds) != 20 {
|
||||||
|
t.Errorf("expected 20 refunds collected across pages (one per page), got %d", len(refunds))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +554,7 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
||||||
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", hc)
|
res, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "", hc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -570,14 +576,14 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected card object, got %v", captured["card"])
|
t.Fatalf("expected card object, got %v", captured["card"])
|
||||||
}
|
}
|
||||||
// The local user ID goes in reference_id (free-form), NOT customer_id —
|
// The local user ID goes in reference_id (free-form); customer_id is
|
||||||
// the app has no Square customer provisioning, and customer_id would be
|
// emitted only when the app has provisioned a Square customer for the user
|
||||||
// rejected by the real Cards API (P1 regression guard).
|
// (empty customerID → omitted via omitempty).
|
||||||
if card["reference_id"] != "user_1" {
|
if card["reference_id"] != "user_1" {
|
||||||
t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"])
|
t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"])
|
||||||
}
|
}
|
||||||
if _, present := card["customer_id"]; present {
|
if _, present := card["customer_id"]; present {
|
||||||
t.Errorf("expected card.customer_id to be ABSENT (local IDs must not go in customer_id), got %v", card["customer_id"])
|
t.Errorf("expected card.customer_id to be ABSENT when customerID is empty, got %v", card["customer_id"])
|
||||||
}
|
}
|
||||||
if gotAuth != "Bearer secret" {
|
if gotAuth != "Bearer secret" {
|
||||||
t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth)
|
t.Errorf("expected Authorization 'Bearer secret', got %q", gotAuth)
|
||||||
@@ -587,6 +593,48 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateCardOnFileHTTP_CustomerIDEmitted verifies card.customer_id is sent
|
||||||
|
// when the app has provisioned a Square customer for the user (Square marks
|
||||||
|
// customer_id Required on the Card object for saved-card flows).
|
||||||
|
func TestCreateCardOnFileHTTP_CustomerIDEmitted(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/cards" {
|
||||||
|
t.Errorf("expected /v2/cards, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||||
|
t.Errorf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242","exp_month":12,"exp_year":2030,"fingerprint":"fp1","customer_id":"cus_1","reference_id":"user_1","enabled":true,"version":1,"created_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "secret", http: srv.Client()}
|
||||||
|
_, err := createCardOnFileHTTPWithClient(context.Background(), "user_1", "cnon:test-card", "cus_1", hc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createCardOnFileHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
card, ok := captured["card"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected card object, got %v", captured["card"])
|
||||||
|
}
|
||||||
|
if card["customer_id"] != "cus_1" {
|
||||||
|
t.Errorf("expected card.customer_id cus_1, got %v", card["customer_id"])
|
||||||
|
}
|
||||||
|
// The idempotency key is derived solely from user|card, so it is identical
|
||||||
|
// whether or not a customer_id accompanies the request.
|
||||||
|
sum := sha256.Sum256([]byte("user_1|cnon:test-card"))
|
||||||
|
wantIK := "card-" + fmt.Sprintf("%x", sum)[:38]
|
||||||
|
if captured["idempotency_key"] != wantIK {
|
||||||
|
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses
|
// TestGetCardsOnFileHTTP_ReferenceIDFilter verifies the List Cards request uses
|
||||||
// the native reference_id filter (the local user ID) — not the invalid
|
// the native reference_id filter (the local user ID) — not the invalid
|
||||||
// customer_id — and that cards are returned unfiltered server-side.
|
// customer_id — and that cards are returned unfiltered server-side.
|
||||||
@@ -630,3 +678,378 @@ func TestGetCardsOnFileHTTP_ReferenceIDFilter(t *testing.T) {
|
|||||||
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
t.Errorf("unexpected cards: %+v %+v", cards[0], cards[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateCheckoutHTTP_TipSettings verifies AllowTipping is emitted as
|
||||||
|
// checkout.device_options.tip_settings.allow_tipping (Square's wire shape for
|
||||||
|
// enabling terminal tips) and omitted entirely when not set.
|
||||||
|
func TestCreateCheckoutHTTP_TipSettings(t *testing.T) {
|
||||||
|
t.Run("allow_tipping_true_emits_tip_settings", func(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||||
|
t.Errorf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_tip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
||||||
|
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-tip", DeviceID: "dvc_1", AllowTipping: true,
|
||||||
|
}, hc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
checkout, ok := captured["checkout"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkout object, got %v", captured)
|
||||||
|
}
|
||||||
|
// tip_settings must NOT be at the checkout top level — a top-level
|
||||||
|
// tip_settings is silently ignored by Square (terminal tip loss).
|
||||||
|
if _, hasTopLevel := checkout["tip_settings"]; hasTopLevel {
|
||||||
|
t.Errorf("tip_settings must not be top-level in terminal checkout request: %v", checkout)
|
||||||
|
}
|
||||||
|
// tip_settings must live under checkout.device_options
|
||||||
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
||||||
|
}
|
||||||
|
tipSettings, ok := devOpts["tip_settings"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected device_options.tip_settings when AllowTipping is true, got %v", devOpts)
|
||||||
|
}
|
||||||
|
if tipSettings["allow_tipping"] != true {
|
||||||
|
t.Errorf("expected tip_settings.allow_tipping=true, got %v", tipSettings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allow_tipping_false_omits_tip_settings", func(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||||
|
t.Errorf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_notip","status":"PENDING","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
_, err := createCheckoutHTTPWithClient(context.Background(), CreateCheckoutReq{
|
||||||
|
Amount: 5000, Currency: "GBP", IdempotencyKey: "ik-notip", DeviceID: "dvc_1", AllowTipping: false,
|
||||||
|
}, hc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createCheckoutHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
checkout, ok := captured["checkout"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkout object, got %v", captured)
|
||||||
|
}
|
||||||
|
// device_options is always present (device_id is required); only the
|
||||||
|
// tip_settings sub-object must be absent.
|
||||||
|
devOpts, ok := checkout["device_options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkout.device_options in body, got %v", checkout)
|
||||||
|
}
|
||||||
|
if _, present := devOpts["tip_settings"]; present {
|
||||||
|
t.Errorf("expected device_options.tip_settings ABSENT when AllowTipping is false, got %v", devOpts["tip_settings"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetPaymentHTTP verifies GET /v2/payments/{id} maps via paymentFromSquare
|
||||||
|
// and that an empty payment ID errors before any HTTP call.
|
||||||
|
func TestGetPaymentHTTP_WireShape(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("expected GET, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/payments/pay_1" {
|
||||||
|
t.Errorf("expected /v2/payments/pay_1, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"COMPLETED","total_money":{"amount":5000,"currency":"GBP"},"source_type":"CARD","card_details":{"card":{"id":"ccof_x","card_brand":"VISA","last_4":"4242"},"entry_method":"EMV"},"location_id":"loc","created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
res, err := getPaymentHTTPWithClient(context.Background(), "pay_1", hc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPaymentHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
if res.ID != "pay_1" || res.Amount != 5000 || res.EntryMethod != "EMV" {
|
||||||
|
t.Errorf("unexpected payment result: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = getPaymentHTTPWithClient(context.Background(), "", hc)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
||||||
|
t.Fatalf("expected empty-ID error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPaymentHTTP_NotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Payment not found"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
_, err := getPaymentHTTPWithClient(context.Background(), "pay_missing", hc)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for not-found payment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateCustomerHTTP_WireShape verifies the CreateCustomer request body:
|
||||||
|
// deterministic "customer-" + sha256(email) idempotency key (≤45 chars),
|
||||||
|
// email_address, and given_name.
|
||||||
|
func TestCreateCustomerHTTP_WireShape(t *testing.T) {
|
||||||
|
var captured map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/customers" {
|
||||||
|
t.Errorf("expected /v2/customers, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||||
|
t.Errorf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"customer":{"id":"cus_1","email_address":"jane@example.com","given_name":"Jane","created_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
res, err := createCustomerHTTPWithClient(context.Background(), "Jane", "jane@example.com", hc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createCustomerHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte("jane@example.com"))
|
||||||
|
wantIK := "customer-" + fmt.Sprintf("%x", sum)[:35]
|
||||||
|
if captured["idempotency_key"] != wantIK {
|
||||||
|
t.Errorf("expected idempotency_key %q, got %v", wantIK, captured["idempotency_key"])
|
||||||
|
}
|
||||||
|
// Square's documented idempotency-key limit is 45 chars — the truncated
|
||||||
|
// key must never exceed it.
|
||||||
|
if len(wantIK) > 45 {
|
||||||
|
t.Errorf("idempotency_key %q is %d chars, exceeds Square's 45-char limit", wantIK, len(wantIK))
|
||||||
|
}
|
||||||
|
if captured["email_address"] != "jane@example.com" {
|
||||||
|
t.Errorf("expected email_address jane@example.com, got %v", captured["email_address"])
|
||||||
|
}
|
||||||
|
if captured["given_name"] != "Jane" {
|
||||||
|
t.Errorf("expected given_name Jane, got %v", captured["given_name"])
|
||||||
|
}
|
||||||
|
if res.ID != "cus_1" || res.Email != "jane@example.com" || res.CreatedAt == "" {
|
||||||
|
t.Errorf("unexpected customer result: %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCancelCheckoutHTTP_NonFatalErrors verifies CancelCheckout treats
|
||||||
|
// already-completed/unknown checkouts as a no-op: structured NOT_FOUND, plain
|
||||||
|
// HTTP 404, and success all return nil. Genuine failures propagate.
|
||||||
|
func TestCancelCheckoutHTTP_NonFatalErrors(t *testing.T) {
|
||||||
|
t.Run("success_is_nil", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/terminals/checkouts/chk_1/cancel" {
|
||||||
|
t.Errorf("expected cancel path, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"checkout":{"id":"chk_1","status":"CANCELED","amount_money":{"amount":5000,"currency":"GBP"},"created_at":"2026-07-31T00:00:00Z","updated_at":"2026-07-31T00:00:00Z"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_1", hc); err != nil {
|
||||||
|
t.Fatalf("expected nil for successful cancel, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("structured_not_found_is_nil", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOT_FOUND","detail":"Checkout not found or already completed"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_missing", hc); err != nil {
|
||||||
|
t.Fatalf("expected nil for NOT_FOUND (already completed is a no-op), got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("plain_404_is_nil", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("checkout not found"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_404", hc); err != nil {
|
||||||
|
t.Fatalf("expected nil for plain HTTP 404, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("other_error_propagates", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"INVALID_VALUE","detail":"bad"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
if err := cancelCheckoutHTTPWithClient(context.Background(), "chk_bad", hc); err == nil {
|
||||||
|
t.Fatal("expected non-nil error for genuine failure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("noop_code_is_error", func(t *testing.T) {
|
||||||
|
// "NOOP" is NOT a confirmed Square error code, so it must propagate as
|
||||||
|
// an error — only NOT_FOUND is treated as an idempotent no-op.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"errors":[{"category":"INVALID_REQUEST_ERROR","code":"NOOP","detail":"nothing to cancel"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()}
|
||||||
|
err := cancelCheckoutHTTPWithClient(context.Background(), "chk_noop", hc)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected NOOP code to propagate as an error (NOOP is not a confirmed Square code)")
|
||||||
|
}
|
||||||
|
if code := ErrorCode(err); code != "NOOP" {
|
||||||
|
t.Errorf("expected NOOP code on error, got %q", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaymentFromSquare_ExpiryPointers verifies exp_month/exp_year are set as
|
||||||
|
// pointers when card details are present and left nil when absent.
|
||||||
|
func TestPaymentFromSquare_ExpiryPointers(t *testing.T) {
|
||||||
|
t.Run("card_details_sets_pointers", func(t *testing.T) {
|
||||||
|
p := &sqPayment{
|
||||||
|
ID: "pay_exp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 5000, Currency: "GBP"},
|
||||||
|
CardDetails: &sqCardDetails{
|
||||||
|
Card: sqCard{ID: "ccof_x", CardBrand: "VISA", Last4: "4242", ExpMonth: 12, ExpYear: 2030},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result := paymentFromSquare(p)
|
||||||
|
if result.ExpMonth == nil || result.ExpYear == nil {
|
||||||
|
t.Fatalf("expected non-nil expiry pointers, got %v/%v", result.ExpMonth, result.ExpYear)
|
||||||
|
}
|
||||||
|
if *result.ExpMonth != 12 || *result.ExpYear != 2030 {
|
||||||
|
t.Errorf("expected exp 12/2030, got %d/%d", *result.ExpMonth, *result.ExpYear)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no_card_details_leaves_nil", func(t *testing.T) {
|
||||||
|
p := &sqPayment{ID: "pay_noexp", Status: "COMPLETED", TotalMoney: sqMoney{Amount: 2500, Currency: "GBP"}}
|
||||||
|
result := paymentFromSquare(p)
|
||||||
|
if result.ExpMonth != nil || result.ExpYear != nil {
|
||||||
|
t.Errorf("expected nil expiry without card details, got %v/%v", result.ExpMonth, result.ExpYear)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidSquareID covers the URL path-segment safety check: Square IDs are
|
||||||
|
// alphanumeric plus '_' and '-' and at most 64 chars. Empty, over-long, and
|
||||||
|
// any character outside that set is rejected before it can reach a URL path.
|
||||||
|
func TestValidSquareID(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"pay_123",
|
||||||
|
"P1-abc",
|
||||||
|
"chk_1",
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-", // exactly 64 chars
|
||||||
|
}
|
||||||
|
invalid := []string{
|
||||||
|
"",
|
||||||
|
"has space",
|
||||||
|
"bad/char",
|
||||||
|
"bad.char",
|
||||||
|
"traversal/../..",
|
||||||
|
strings.Repeat("a", 65),
|
||||||
|
}
|
||||||
|
for _, id := range valid {
|
||||||
|
if !validSquareID(id) {
|
||||||
|
t.Errorf("expected %q to be a valid Square ID", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range invalid {
|
||||||
|
if validSquareID(id) {
|
||||||
|
t.Errorf("expected %q to be rejected", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIDValidation_RejectsBeforeHTTP verifies getPayment/getCheckout/cancelCheckout
|
||||||
|
// reject malformed IDs before building the request URL. The client points at an
|
||||||
|
// unused host: a request that slipped past validation would fail with a network
|
||||||
|
// error instead of an "invalid ... id" error, so the assertion is meaningful.
|
||||||
|
func TestIDValidation_RejectsBeforeHTTP(t *testing.T) {
|
||||||
|
hc := &httpClient{baseURL: "http://unused", token: "t", http: &http.Client{}}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := getPaymentHTTPWithClient(ctx, "bad/id", hc)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid payment id") {
|
||||||
|
t.Fatalf("expected invalid payment id error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = getCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
||||||
|
t.Fatalf("expected invalid checkout id error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cancelCheckoutHTTPWithClient(ctx, "bad/id", hc)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid checkout id") {
|
||||||
|
t.Fatalf("expected invalid checkout id error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorCode_ErrorDetail verifies the exported accessors surface the
|
||||||
|
// structured Square error Code/Detail for direct and wrapped *squareAPIError
|
||||||
|
// values, and return "" for non-Square errors (so handlers can classify charge
|
||||||
|
// failures structurally instead of substring-matching).
|
||||||
|
func TestErrorCode_ErrorDetail(t *testing.T) {
|
||||||
|
t.Run("direct", func(t *testing.T) {
|
||||||
|
base := &squareAPIError{Code: "INVALID_VALUE", Detail: "bad thing", err: errors.New("square: boom")}
|
||||||
|
if got := ErrorCode(base); got != "INVALID_VALUE" {
|
||||||
|
t.Errorf("expected INVALID_VALUE, got %q", got)
|
||||||
|
}
|
||||||
|
if got := ErrorDetail(base); got != "bad thing" {
|
||||||
|
t.Errorf("expected detail 'bad thing', got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrapped", func(t *testing.T) {
|
||||||
|
base := &squareAPIError{Code: "CARD_DECLINED", Detail: "card declined", err: errors.New("square: boom")}
|
||||||
|
wrapped := fmt.Errorf("wrap: %w", base)
|
||||||
|
if got := ErrorCode(wrapped); got != "CARD_DECLINED" {
|
||||||
|
t.Errorf("expected CARD_DECLINED through wrap, got %q", got)
|
||||||
|
}
|
||||||
|
if got := ErrorDetail(wrapped); got != "card declined" {
|
||||||
|
t.Errorf("expected detail through wrap, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non_square_error", func(t *testing.T) {
|
||||||
|
if got := ErrorCode(errors.New("plain")); got != "" {
|
||||||
|
t.Errorf("expected \"\", got %q", got)
|
||||||
|
}
|
||||||
|
if got := ErrorDetail(errors.New("plain")); got != "" {
|
||||||
|
t.Errorf("expected \"\", got %q", got)
|
||||||
|
}
|
||||||
|
if got := ErrorCode(nil); got != "" {
|
||||||
|
t.Errorf("expected \"\" for nil, got %q", got)
|
||||||
|
}
|
||||||
|
if got := ErrorDetail(nil); got != "" {
|
||||||
|
t.Errorf("expected \"\" for nil, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type CreatePaymentReq struct {
|
|||||||
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
|
Autocomplete *bool // nil (default) = true — complete immediately; false = approve only
|
||||||
TipMoney *int64 // optional tip amount in pence
|
TipMoney *int64 // optional tip amount in pence
|
||||||
CustomerID string // Square customer ID for card-on-file payments
|
CustomerID string // Square customer ID for card-on-file payments
|
||||||
LocationID string // Square location ID (required in production)
|
LocationID string // Square location ID (optional; defaults to main location)
|
||||||
VerificationToken string // 3DS / SCA verification token from buyer verification
|
VerificationToken string // 3DS / SCA verification token from buyer verification
|
||||||
BuyerEmail string // buyer email for receipt
|
BuyerEmail string // buyer email for receipt
|
||||||
}
|
}
|
||||||
@@ -46,10 +46,14 @@ type CreateCheckoutReq struct {
|
|||||||
Currency string
|
Currency string
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
ReferenceID string
|
ReferenceID string
|
||||||
TipEnabled bool // mock-only: simulates tip addition during checkout
|
// AllowTipping enables tip entry on the Square Terminal: when true, the
|
||||||
DeviceID string // Square Terminal device ID (required in production)
|
// checkout payload sends device_options.tip_settings.allow_tipping=true so
|
||||||
Note string // optional note for the checkout
|
// terminal tip revenue is actually collected (previously tips were silently
|
||||||
CustomerID string // optional Square customer ID
|
// lost in production because the payload never emitted tip settings).
|
||||||
|
AllowTipping bool // sends device_options.tip_settings.allow_tipping=true to Square
|
||||||
|
DeviceID string // Square Terminal device ID (required in production)
|
||||||
|
Note string // optional note for the checkout
|
||||||
|
CustomerID string // optional Square customer ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
// RefundPaymentReq maps to Square's RefundPayment endpoint (POST /v2/refunds).
|
||||||
@@ -58,7 +62,7 @@ type RefundPaymentReq struct {
|
|||||||
Amount int64 // in pence, 0 = full refund
|
Amount int64 // in pence, 0 = full refund
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
Reason string
|
Reason string
|
||||||
LocationID string // Square location ID (required in production)
|
LocationID string // Square location ID (optional; defaults to main location)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentResult maps to the Square Payment object returned by
|
// PaymentResult maps to the Square Payment object returned by
|
||||||
@@ -74,8 +78,11 @@ type PaymentResult struct {
|
|||||||
CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc.
|
CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc.
|
||||||
CardLast4 string
|
CardLast4 string
|
||||||
CardFingerprint string // unique card fingerprint from Square
|
CardFingerprint string // unique card fingerprint from Square
|
||||||
ExpMonth int
|
// ExpMonth/ExpYear are nil when the payment has no card details (e.g. a
|
||||||
ExpYear int
|
// non-card source). Square returns exp_month/exp_year only for card
|
||||||
|
// payments, so a plain int could not distinguish 0 from an absent value.
|
||||||
|
ExpMonth *int
|
||||||
|
ExpYear *int
|
||||||
EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS"
|
EntryMethod string // "KEYED", "ON_FILE", "EMV", "SWIPED", "CONTACTLESS"
|
||||||
CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED"
|
CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED"
|
||||||
AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED"
|
AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED"
|
||||||
@@ -119,7 +126,7 @@ type CardOnFile struct {
|
|||||||
ExpYear int
|
ExpYear int
|
||||||
Fingerprint string // Square card fingerprint
|
Fingerprint string // Square card fingerprint
|
||||||
CardholderName string // cardholder name (if provided)
|
CardholderName string // cardholder name (if provided)
|
||||||
CustomerID string // Square customer ID this card belongs to (unused: the app does not provision Square customers)
|
CustomerID string // Square customer ID this card belongs to (set when the app has provisioned a Square customer)
|
||||||
ReferenceID string // Square free-form client reference — holds the local user ID for client-side filtering
|
ReferenceID string // Square free-form client reference — holds the local user ID for client-side filtering
|
||||||
Enabled bool // whether the card is enabled (not disabled/expired)
|
Enabled bool // whether the card is enabled (not disabled/expired)
|
||||||
IsDefault bool // mock-only: first card saved for a user
|
IsDefault bool // mock-only: first card saved for a user
|
||||||
@@ -148,6 +155,16 @@ type SquareError struct {
|
|||||||
Field string `json:"field"`
|
Field string `json:"field"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CustomerResult maps to the Square Customer object returned by
|
||||||
|
// CreateCustomer (POST /v2/customers). Only the fields this application
|
||||||
|
// consumes are included.
|
||||||
|
// Reference: https://developer.squareup.com/reference/square/objects/Customer
|
||||||
|
type CustomerResult struct {
|
||||||
|
ID string // Square customer ID (e.g. "cus_xxx")
|
||||||
|
Email string // customer email address
|
||||||
|
CreatedAt string // ISO 8601 timestamp
|
||||||
|
}
|
||||||
|
|
||||||
// SquareClient is the interface for all Square payment operations.
|
// SquareClient is the interface for all Square payment operations.
|
||||||
// All implementations (mock, prod) must satisfy this interface.
|
// All implementations (mock, prod) must satisfy this interface.
|
||||||
type SquareClient interface {
|
type SquareClient interface {
|
||||||
@@ -155,10 +172,24 @@ type SquareClient interface {
|
|||||||
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
|
CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error)
|
||||||
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error)
|
||||||
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error)
|
||||||
CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error)
|
CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error)
|
||||||
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error)
|
||||||
DeleteCardOnFile(ctx context.Context, cardID string) error
|
DeleteCardOnFile(ctx context.Context, cardID string) error
|
||||||
|
|
||||||
|
// GetPayment returns a single payment by ID. Used by the sweep reconcile
|
||||||
|
// flow to check the authoritative payment status at Square.
|
||||||
|
GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error)
|
||||||
|
|
||||||
|
// CreateCustomer provisions a Square customer (customer provisioning for
|
||||||
|
// card-on-file payments). Square dedups on the deterministic
|
||||||
|
// idempotency key (derived from email).
|
||||||
|
CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error)
|
||||||
|
|
||||||
|
// CancelCheckout cancels a pending terminal checkout. Square returns a
|
||||||
|
// 404 / NOT_FOUND if the checkout is already completed or canceled —
|
||||||
|
// that is treated as a no-op, so CancelCheckout returns nil.
|
||||||
|
CancelCheckout(ctx context.Context, checkoutID string) error
|
||||||
|
|
||||||
// ListPaymentRefunds returns the refunds Square has recorded for a payment
|
// ListPaymentRefunds returns the refunds Square has recorded for a payment
|
||||||
// (charge), created at or after beginTime. Used to reconcile pending refund
|
// (charge), created at or after beginTime. Used to reconcile pending refund
|
||||||
// rows against Square before marking them failed (money may already have
|
// rows against Square before marking them failed (money may already have
|
||||||
|
|||||||
@@ -154,6 +154,15 @@
|
|||||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||||
// of re-tokenizing (the backend idempotency key dedups).
|
// of re-tokenizing (the backend idempotency key dedups).
|
||||||
let tipNonce = $state('');
|
let tipNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with tipNonce (both one-shot, reused
|
||||||
|
// together on retry). The verification token is amount-bound, so changing
|
||||||
|
// the tip invalidates the cached pair.
|
||||||
|
let tipVerificationToken = $state('');
|
||||||
|
let tipTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let tipTokenizedAt = $state(0);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||||
@@ -226,19 +235,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
if (tipSelectedCardId) {
|
if (tipSelectedCardId) {
|
||||||
// saved card — nothing to tokenize
|
// saved card — nothing to tokenize
|
||||||
} else if (tipCardSelection) {
|
} else if (tipCardSelection) {
|
||||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
if (!tipNonce) {
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
|
// idempotency key dedups). The verification token is amount-bound, so
|
||||||
|
// a changed tip amount forces a fresh tokenization.
|
||||||
|
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
||||||
try {
|
try {
|
||||||
tipNonce = await tipCardSelection.tokenize();
|
const tokenized = await tipCardSelection.tokenizeWithVerification(
|
||||||
|
Math.round(tipAmount * 100),
|
||||||
|
{
|
||||||
|
givenName: authStore.currentUser?.firstName,
|
||||||
|
familyName: authStore.currentUser?.lastName,
|
||||||
|
email: authStore.currentUser?.email
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tipNonce = tokenized.nonce;
|
||||||
|
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
tipTokenAmount = tipAmount;
|
||||||
|
tipTokenizedAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newCardToken = tipNonce;
|
newCardToken = tipNonce;
|
||||||
|
verificationToken = tipVerificationToken || undefined;
|
||||||
} else {
|
} else {
|
||||||
toast.error('Please select a payment method');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
@@ -255,7 +280,8 @@
|
|||||||
amount: Math.round(tipAmount * 100),
|
amount: Math.round(tipAmount * 100),
|
||||||
idempotency_key: tipIdempotencyKey,
|
idempotency_key: tipIdempotencyKey,
|
||||||
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
|
...(tipSelectedCardId ? { card_id: tipSelectedCardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {})
|
...(newCardToken ? { new_card_token: newCardToken, save_card: tipSaveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
const response = await apiFetch(`/api/bookings/${selectedBooking.id}/tip`, {
|
||||||
@@ -271,6 +297,9 @@
|
|||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
tipNonce = '';
|
tipNonce = '';
|
||||||
|
tipVerificationToken = '';
|
||||||
|
tipTokenAmount = 0;
|
||||||
|
tipTokenizedAt = 0;
|
||||||
showTipModal = false;
|
showTipModal = false;
|
||||||
fetchBookingDetails();
|
fetchBookingDetails();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1110,6 +1139,9 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
tipSelectedCardId = '';
|
tipSelectedCardId = '';
|
||||||
tipSaveCard = false;
|
tipSaveCard = false;
|
||||||
tipNonce = '';
|
tipNonce = '';
|
||||||
|
tipVerificationToken = '';
|
||||||
|
tipTokenAmount = 0;
|
||||||
|
tipTokenizedAt = 0;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1188,5 +1220,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
|
|||||||
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
{tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|||||||
@@ -610,15 +610,27 @@
|
|||||||
onlineSquareProcessing = true;
|
onlineSquareProcessing = true;
|
||||||
paymentError = '';
|
paymentError = '';
|
||||||
try {
|
try {
|
||||||
|
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
|
||||||
let token: string;
|
let token: string;
|
||||||
|
let verificationToken: string | null;
|
||||||
try {
|
try {
|
||||||
token = await onlineSquareCardInput.tokenize();
|
const contact = selectedCustomer
|
||||||
|
? {
|
||||||
|
givenName: selectedCustomer.name?.split(' ')[0],
|
||||||
|
email: selectedCustomer.email
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
|
||||||
|
Math.round(amt * 100),
|
||||||
|
contact
|
||||||
|
);
|
||||||
|
token = tokenized.nonce;
|
||||||
|
verificationToken = tokenized.verificationToken;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentError = err instanceof Error ? err.message : 'Card entry failed';
|
paymentError = err instanceof Error ? err.message : 'Card entry failed';
|
||||||
setModalStep(actionType, 'error');
|
setModalStep(actionType, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount);
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
item_type: 'gift_card',
|
item_type: 'gift_card',
|
||||||
action: actionType,
|
action: actionType,
|
||||||
@@ -627,6 +639,7 @@
|
|||||||
card_token: token,
|
card_token: token,
|
||||||
idempotency_key: getIdempotencyKey()
|
idempotency_key: getIdempotencyKey()
|
||||||
};
|
};
|
||||||
|
if (verificationToken) body.verification_token = verificationToken;
|
||||||
if (gcId) body.gift_card_id = gcId;
|
if (gcId) body.gift_card_id = gcId;
|
||||||
if (selectedCustomer) body.user_id = selectedCustomer.id;
|
if (selectedCustomer) body.user_id = selectedCustomer.id;
|
||||||
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
|
if (actionType === 'create' && generateType === 'account' && selectedCustomer)
|
||||||
@@ -1909,6 +1922,7 @@
|
|||||||
>
|
>
|
||||||
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -2181,6 +2195,7 @@
|
|||||||
>
|
>
|
||||||
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
{onlineSquareProcessing ? 'Processing...' : 'Pay by Card'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { generateUUID } from '$lib/utils/uuid';
|
import { generateUUID } from '$lib/utils/uuid';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
||||||
|
import { apiFetch } from '$lib/utils/api';
|
||||||
|
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||||
|
import { isSquareConfigured } from '$lib/square/square';
|
||||||
|
|
||||||
type CartItem = {
|
type CartItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,13 +16,35 @@
|
|||||||
qty: number;
|
qty: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square';
|
||||||
|
|
||||||
|
const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [
|
||||||
|
{ key: 'cash', label: 'Cash' },
|
||||||
|
{ key: 'card_machine', label: 'Card Machine' },
|
||||||
|
{ key: 'online_square', label: 'Online Card' }
|
||||||
|
];
|
||||||
|
|
||||||
let cart = $state<CartItem[]>([]);
|
let cart = $state<CartItem[]>([]);
|
||||||
let giftCardAmount = $state('25');
|
let giftCardAmount = $state('25');
|
||||||
let showGiftCardInput = $state(false);
|
let showGiftCardInput = $state(false);
|
||||||
|
|
||||||
|
let paymentMethod = $state<TillPaymentMethod>('cash');
|
||||||
|
let onlineSquareCardReady = $state(false);
|
||||||
|
let onlineSquareCardInput = $state<SquareCardInput | null>(null);
|
||||||
|
let processing = $state(false);
|
||||||
|
let paymentError = $state<string | null>(null);
|
||||||
|
// Synchronous double-click guard (see BookingFlow) — Svelte 5 reactivity is
|
||||||
|
// async, so `processing` may not reach the button before a fast second click.
|
||||||
|
let isProcessingPaymentSync = false;
|
||||||
|
|
||||||
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
|
const subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
|
||||||
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
const itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
||||||
|
|
||||||
|
// The backend till sale API currently only accepts item_type 'gift_card', so
|
||||||
|
// retail items cannot be charged yet — gate the Charge button to gift-card-only carts.
|
||||||
|
const hasRetailItems = $derived(cart.some((i) => i.label !== 'Gift Card'));
|
||||||
|
const canCharge = $derived(cart.length > 0 && !hasRetailItems && subtotal > 0);
|
||||||
|
|
||||||
function formatCurrency(n: number): string {
|
function formatCurrency(n: number): string {
|
||||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||||||
}
|
}
|
||||||
@@ -52,6 +79,95 @@
|
|||||||
})
|
})
|
||||||
.filter((i): i is CartItem => i !== null);
|
.filter((i): i is CartItem => i !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Polls a card-machine checkout until it completes (mirrors the gift-card management flow). */
|
||||||
|
async function pollTillCheckout(checkoutId: string): Promise<void> {
|
||||||
|
const maxAttempts = 60;
|
||||||
|
for (let attempts = 0; attempts < maxAttempts; attempts++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/admin/till/sale/checkout/${checkoutId}/status`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'COMPLETED') return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling — a transient network error is not fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Card machine payment timed out. Please check the Square dashboard.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chargeCart() {
|
||||||
|
if (isProcessingPaymentSync) return;
|
||||||
|
if (cart.length === 0) {
|
||||||
|
toast.error('Cart is empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasRetailItems) {
|
||||||
|
toast.error('Retail items cannot be charged yet — the till API currently supports gift card sales only');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isProcessingPaymentSync = true;
|
||||||
|
processing = true;
|
||||||
|
paymentError = null;
|
||||||
|
try {
|
||||||
|
// One sale per cart line × quantity — each till sale funds its own
|
||||||
|
// gift card (the backend only accepts item_type 'gift_card').
|
||||||
|
const saleBodies: Record<string, unknown>[] = [];
|
||||||
|
for (const item of cart) {
|
||||||
|
for (let i = 0; i < item.qty; i++) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
item_type: 'gift_card',
|
||||||
|
action: 'create',
|
||||||
|
amount: item.price,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
idempotency_key: generateUUID()
|
||||||
|
};
|
||||||
|
if (paymentMethod === 'online_square') {
|
||||||
|
if (!onlineSquareCardInput) {
|
||||||
|
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||||
|
}
|
||||||
|
// SCA verification amount must match the sale amount (pence).
|
||||||
|
const tokenized = await onlineSquareCardInput.tokenizeWithVerification(
|
||||||
|
Math.round(item.price * 100)
|
||||||
|
);
|
||||||
|
body.card_token = tokenized.nonce;
|
||||||
|
if (tokenized.verificationToken) {
|
||||||
|
body.verification_token = tokenized.verificationToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saleBodies.push(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const body of saleBodies) {
|
||||||
|
const res = await apiFetch('/api/admin/till/sale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(extractErrorMessage(errText) || 'Till sale failed');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (paymentMethod === 'card_machine' && data.status === 'pending' && data.checkout_id) {
|
||||||
|
await pollTillCheckout(data.checkout_id as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Sale complete');
|
||||||
|
cart = [];
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||||
|
paymentError = msg;
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
isProcessingPaymentSync = false;
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-xl border bg-card">
|
<div class="rounded-xl border bg-card">
|
||||||
@@ -64,6 +180,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-start gap-2"
|
class="justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => addItem('Cuticle Oil', 8)}
|
onclick={() => addItem('Cuticle Oil', 8)}
|
||||||
>
|
>
|
||||||
Cuticle Oil - £8
|
Cuticle Oil - £8
|
||||||
@@ -72,6 +189,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-start gap-2"
|
class="justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => addItem('Nail Files (Pack)', 5)}
|
onclick={() => addItem('Nail Files (Pack)', 5)}
|
||||||
>
|
>
|
||||||
Nail Files - £5
|
Nail Files - £5
|
||||||
@@ -80,6 +198,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-start gap-2"
|
class="justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => addItem('Hand Cream', 6)}
|
onclick={() => addItem('Hand Cream', 6)}
|
||||||
>
|
>
|
||||||
Hand Cream - £6
|
Hand Cream - £6
|
||||||
@@ -88,6 +207,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-start gap-2"
|
class="justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => addItem('Base Coat', 7)}
|
onclick={() => addItem('Base Coat', 7)}
|
||||||
>
|
>
|
||||||
Base Coat - £7
|
Base Coat - £7
|
||||||
@@ -96,6 +216,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="justify-start gap-2"
|
class="justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => addItem('Top Coat', 7)}
|
onclick={() => addItem('Top Coat', 7)}
|
||||||
>
|
>
|
||||||
Top Coat - £7
|
Top Coat - £7
|
||||||
@@ -112,12 +233,13 @@
|
|||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
bind:value={giftCardAmount}
|
bind:value={giftCardAmount}
|
||||||
class="h-9 pl-5 text-sm"
|
class="h-9 pl-5 text-sm"
|
||||||
|
disabled={processing}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') addGiftCard();
|
if (e.key === 'Enter') addGiftCard();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs"
|
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs" disabled={processing}
|
||||||
>Add</Button
|
>Add</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +248,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full justify-start gap-2"
|
class="w-full justify-start gap-2"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => (showGiftCardInput = true)}
|
onclick={() => (showGiftCardInput = true)}
|
||||||
>
|
>
|
||||||
Gift Card
|
Gift Card
|
||||||
@@ -154,7 +277,8 @@
|
|||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => updateQty(item.id, -1)}
|
onclick={() => updateQty(item.id, -1)}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
@@ -162,7 +286,8 @@
|
|||||||
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => updateQty(item.id, 1)}
|
onclick={() => updateQty(item.id, 1)}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
@@ -173,7 +298,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={processing}
|
||||||
onclick={() => removeItem(item.id)}
|
onclick={() => removeItem(item.id)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -200,11 +326,69 @@
|
|||||||
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
|
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="mt-3 w-full" disabled>
|
<div class="mt-3">
|
||||||
Charge {formatCurrency(subtotal)}
|
<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
|
||||||
|
>Payment Method</span
|
||||||
|
>
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
{#each PAYMENT_METHODS as m (m.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {paymentMethod ===
|
||||||
|
m.key
|
||||||
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50'}"
|
||||||
|
disabled={processing}
|
||||||
|
onclick={() => (paymentMethod = m.key)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if paymentMethod === 'online_square'}
|
||||||
|
<div class="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
|
||||||
|
{#if isSquareConfigured()}
|
||||||
|
<SquareCardInput
|
||||||
|
bind:this={onlineSquareCardInput}
|
||||||
|
onReady={(r) => (onlineSquareCardReady = r)}
|
||||||
|
disabled={processing}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Online card entry is unavailable — Square is not configured.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasRetailItems}
|
||||||
|
<p class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
|
||||||
|
Retail items can't be charged yet — the till API currently supports gift card sales
|
||||||
|
only. Remove retail items to complete this sale.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if paymentError}
|
||||||
|
<p class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-800">
|
||||||
|
{paymentError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="mt-3 w-full"
|
||||||
|
onclick={chargeCart}
|
||||||
|
loading={processing}
|
||||||
|
disabled={
|
||||||
|
!canCharge || processing || (paymentMethod === 'online_square' && !onlineSquareCardReady)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{processing ? 'Processing...' : `Charge ${formatCurrency(subtotal)}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
Payment flow and backend integration coming soon.
|
Gift card sales are processed through the till; retail items require manual recording for now.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,6 +103,16 @@
|
|||||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||||
// of re-tokenizing (the backend idempotency key dedups).
|
// of re-tokenizing (the backend idempotency key dedups).
|
||||||
let depositNonce = $state('');
|
let depositNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with depositNonce (tokenizeWithVerification
|
||||||
|
// returns both; both are one-shot and must be reused together on retry). The
|
||||||
|
// verification token is amount-bound, so a changed deposit amount forces a
|
||||||
|
// fresh tokenization.
|
||||||
|
let depositVerificationToken = $state('');
|
||||||
|
let depositTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let depositTokenizedAt = $state(0);
|
||||||
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||||
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
// on the next microtask), so `isProcessingPayment` may not propagate to the
|
||||||
// button's `disabled` binding before a fast second click fires. This non-
|
// button's `disabled` binding before a fast second click fires. This non-
|
||||||
@@ -286,24 +296,6 @@
|
|||||||
isProcessingPayment = true;
|
isProcessingPayment = true;
|
||||||
paymentAttempted = false;
|
paymentAttempted = false;
|
||||||
try {
|
try {
|
||||||
let newCardToken: string | undefined;
|
|
||||||
if (selectedPaymentMethod) {
|
|
||||||
// saved card — nothing to tokenize
|
|
||||||
} else if (paymentCardSelection) {
|
|
||||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
|
||||||
if (!depositNonce) {
|
|
||||||
try {
|
|
||||||
depositNonce = await paymentCardSelection.tokenize();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newCardToken = depositNonce;
|
|
||||||
} else {
|
|
||||||
toast.error('Please select a payment method');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await submitAndProceed();
|
await submitAndProceed();
|
||||||
if (!confirmedBooking) {
|
if (!confirmedBooking) {
|
||||||
toast.error('Booking was not created. Please try again.');
|
toast.error('Booking was not created. Please try again.');
|
||||||
@@ -320,6 +312,40 @@
|
|||||||
: _amount;
|
: _amount;
|
||||||
const amountCents = Math.round(depositAmount * 100);
|
const amountCents = Math.round(depositAmount * 100);
|
||||||
|
|
||||||
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
|
if (selectedPaymentMethod) {
|
||||||
|
// saved card — nothing to tokenize
|
||||||
|
} else if (paymentCardSelection) {
|
||||||
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
|
// verification token on retry (tokenization is one-shot; the
|
||||||
|
// backend idempotency key dedups). Tokenizing AFTER the booking is
|
||||||
|
// created so the SCA verification amount matches the exact charge.
|
||||||
|
// The verification token is amount-bound, so a changed deposit
|
||||||
|
// amount forces a fresh tokenization.
|
||||||
|
if (!depositNonce || depositTokenAmount !== amountCents || Date.now() - depositTokenizedAt > 240_000) {
|
||||||
|
try {
|
||||||
|
const tokenized = await paymentCardSelection.tokenizeWithVerification(amountCents, {
|
||||||
|
givenName: customerInfo.firstName || authStore.currentUser?.firstName,
|
||||||
|
familyName: customerInfo.lastName || authStore.currentUser?.lastName,
|
||||||
|
email: customerInfo.email || authStore.currentUser?.email
|
||||||
|
});
|
||||||
|
depositNonce = tokenized.nonce;
|
||||||
|
depositVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
depositTokenAmount = amountCents;
|
||||||
|
depositTokenizedAt = Date.now();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newCardToken = depositNonce;
|
||||||
|
verificationToken = depositVerificationToken || undefined;
|
||||||
|
} else {
|
||||||
|
toast.error('Please select a payment method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache the idempotency key per amount+card so a lost-response retry
|
// Cache the idempotency key per amount+card so a lost-response retry
|
||||||
// reuses it (backend dedups) instead of double-charging.
|
// reuses it (backend dedups) instead of double-charging.
|
||||||
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
|
const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`;
|
||||||
@@ -338,7 +364,8 @@
|
|||||||
amount: amountCents,
|
amount: amountCents,
|
||||||
idempotency_key: depositIdempotencyKey,
|
idempotency_key: depositIdempotencyKey,
|
||||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {})
|
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
paymentAttempted = true;
|
paymentAttempted = true;
|
||||||
@@ -358,6 +385,9 @@
|
|||||||
depositKeyedAmount = 0;
|
depositKeyedAmount = 0;
|
||||||
depositKeyedCard = '';
|
depositKeyedCard = '';
|
||||||
depositNonce = '';
|
depositNonce = '';
|
||||||
|
depositVerificationToken = '';
|
||||||
|
depositTokenAmount = 0;
|
||||||
|
depositTokenizedAt = 0;
|
||||||
depositSaveCard = false;
|
depositSaveCard = false;
|
||||||
// Immutable update — avoid mutating the existing object so
|
// Immutable update — avoid mutating the existing object so
|
||||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||||
@@ -2364,6 +2394,8 @@
|
|||||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import CardBrandIcon from './CardBrandIcon.svelte';
|
import CardBrandIcon from './CardBrandIcon.svelte';
|
||||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||||
import SquareCardInput from './SquareCardInput.svelte';
|
import SquareCardInput from './SquareCardInput.svelte';
|
||||||
|
import type { SquareVerificationContact } from './SquareCardInput.svelte';
|
||||||
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
import { isSquareConfigured } from '$lib/square/square';
|
import { isSquareConfigured } from '$lib/square/square';
|
||||||
|
|
||||||
export interface SelectableCard {
|
export interface SelectableCard {
|
||||||
@@ -68,6 +70,22 @@
|
|||||||
}
|
}
|
||||||
return squareCardInput.tokenize();
|
return squareCardInput.tokenize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenizes the new-card form with SCA verification details (SCA-mandated
|
||||||
|
* for UK card-not-present charges). Returns the nonce AND the verification
|
||||||
|
* token, which the caller must send to the backend as `verification_token`
|
||||||
|
* alongside the nonce so the charge completes.
|
||||||
|
*/
|
||||||
|
export async function tokenizeWithVerification(
|
||||||
|
amount: number,
|
||||||
|
contact?: SquareVerificationContact
|
||||||
|
): Promise<{ nonce: string; verificationToken: string | null }> {
|
||||||
|
if (!newCardMode || !squareCardInput) {
|
||||||
|
throw new Error('No new card form is open');
|
||||||
|
}
|
||||||
|
return squareCardInput.tokenizeWithVerification(amount, contact);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if cards.length > 0}
|
{#if cards.length > 0}
|
||||||
@@ -143,7 +161,10 @@
|
|||||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
|
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary"
|
||||||
bind:checked={saveCard}
|
bind:checked={saveCard}
|
||||||
/>
|
/>
|
||||||
<span>Save this card for next time</span>
|
<span>
|
||||||
|
Save this card securely with our payment provider (Square) for next time.
|
||||||
|
<PolicyPopover label="privacy policy" href="/privacy-policy" />
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
const nameId = `mock-card-name-${crypto.randomUUID()}`;
|
const nameId = `mock-card-name-${crypto.randomUUID()}`;
|
||||||
|
|
||||||
const inputClasses =
|
const inputClasses =
|
||||||
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20';
|
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm';
|
||||||
|
|
||||||
const digits = $derived(cardNumber.replace(/\D/g, ''));
|
const digits = $derived(cardNumber.replace(/\D/g, ''));
|
||||||
|
|
||||||
@@ -165,6 +165,37 @@
|
|||||||
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
||||||
return Promise.resolve(token);
|
return Promise.resolve(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors SquareCardInput.tokenizeWithVerification() so the dev mock
|
||||||
|
* exercises the full SCA path (card nonce + verification token) end to end.
|
||||||
|
* The fake verification token is deterministic and the backend mock accepts
|
||||||
|
* it alongside the cnon: nonce.
|
||||||
|
*
|
||||||
|
* @param amount The amount that WILL be charged, in pence — same pence input
|
||||||
|
* contract as the real form. The real form serializes this to
|
||||||
|
* a major-units decimal string ("50.00") on Square's wire; the
|
||||||
|
* mock only embeds the pence value in the fake token for
|
||||||
|
* deterministic identification, so no conversion is needed here.
|
||||||
|
*/
|
||||||
|
export async function tokenizeWithVerification(
|
||||||
|
amount: number,
|
||||||
|
_contact?: {
|
||||||
|
givenName?: string;
|
||||||
|
familyName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
): Promise<{ nonce: string; verificationToken: string | null }> {
|
||||||
|
if (!complete) {
|
||||||
|
throw new Error('Card details are incomplete');
|
||||||
|
}
|
||||||
|
const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card';
|
||||||
|
const prefix = digits.slice(0, 4) || 'test';
|
||||||
|
return Promise.resolve({
|
||||||
|
nonce: token,
|
||||||
|
verificationToken: `verify_mock_${prefix}_${String(Math.round(amount))}`
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
let checkoutId = $state<string | null>(null);
|
let checkoutId = $state<string | null>(null);
|
||||||
let paymentResult = $state<PaymentResult | null>(null);
|
let paymentResult = $state<PaymentResult | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
|
||||||
|
// on the next microtask), so the reactive `status` may not propagate to the
|
||||||
|
// button's `disabled` binding before a fast second click fires. This non-
|
||||||
|
// reactive flag is checked synchronously at the start of every handler.
|
||||||
|
let isProcessingPaymentSync = false;
|
||||||
|
|
||||||
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
|
||||||
let useLoyalty = $state(false);
|
let useLoyalty = $state(false);
|
||||||
@@ -228,6 +233,11 @@
|
|||||||
|
|
||||||
const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
|
const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
|
||||||
|
|
||||||
|
// True when there is genuinely nothing to charge — the booking is fully
|
||||||
|
// covered by discounts (and no tip is being added). Payment entry is
|
||||||
|
// disabled in that state; the handlers also guard defensively.
|
||||||
|
const nothingToCharge = $derived(totalDue <= 0);
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
return new Intl.NumberFormat('en-GB', {
|
return new Intl.NumberFormat('en-GB', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -248,13 +258,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCardPayment() {
|
async function handleCardPayment() {
|
||||||
|
if (isProcessingPaymentSync) return;
|
||||||
const finalAmount = totalDue;
|
const finalAmount = totalDue;
|
||||||
|
|
||||||
if (isNaN(finalAmount) || finalAmount <= 0) {
|
if (isNaN(finalAmount) || finalAmount <= 0) {
|
||||||
toast.error('Please enter a valid amount');
|
toast.error('Please enter a valid amount');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The loyalty redemption is applied on top of totalDue; guard against
|
||||||
|
// the effective charge being zero or negative.
|
||||||
|
if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
|
||||||
|
toast.error('Nothing to charge — the booking is fully covered by discounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingPaymentSync = true;
|
||||||
status = 'card-processing';
|
status = 'card-processing';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -284,6 +302,8 @@
|
|||||||
status = 'error';
|
status = 'error';
|
||||||
error = _err instanceof Error ? _err.message : 'Failed to initiate payment';
|
error = _err instanceof Error ? _err.message : 'Failed to initiate payment';
|
||||||
toast.error(error ?? 'Unknown error');
|
toast.error(error ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
isProcessingPaymentSync = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,8 +401,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCashPayment() {
|
async function handleCashPayment() {
|
||||||
|
if (isProcessingPaymentSync) return;
|
||||||
const cashDue = totalDue - loyaltyDiscount / 100;
|
const cashDue = totalDue - loyaltyDiscount / 100;
|
||||||
|
|
||||||
|
if (cashDue <= 0) {
|
||||||
|
toast.error('Nothing to charge — the booking is fully covered by discounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cashAmountNum < cashDue) {
|
if (cashAmountNum < cashDue) {
|
||||||
toast.error('Cash amount must cover the total');
|
toast.error('Cash amount must cover the total');
|
||||||
return;
|
return;
|
||||||
@@ -390,6 +415,7 @@
|
|||||||
|
|
||||||
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
|
const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;
|
||||||
|
|
||||||
|
isProcessingPaymentSync = true;
|
||||||
status = 'cash-confirming';
|
status = 'cash-confirming';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -430,6 +456,8 @@
|
|||||||
status = 'error';
|
status = 'error';
|
||||||
error = _err instanceof Error ? _err.message : 'Failed to process payment';
|
error = _err instanceof Error ? _err.message : 'Failed to process payment';
|
||||||
toast.error(error ?? 'Unknown error');
|
toast.error(error ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
isProcessingPaymentSync = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +520,7 @@
|
|||||||
const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
|
const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);
|
||||||
|
|
||||||
async function handleGiftCardPayment() {
|
async function handleGiftCardPayment() {
|
||||||
|
if (isProcessingPaymentSync) return;
|
||||||
if (!giftCardValid) {
|
if (!giftCardValid) {
|
||||||
toast.error('Please enter a valid 12-character gift card code');
|
toast.error('Please enter a valid 12-character gift card code');
|
||||||
return;
|
return;
|
||||||
@@ -499,6 +528,11 @@
|
|||||||
|
|
||||||
const giftDue = totalDue - loyaltyDiscount / 100;
|
const giftDue = totalDue - loyaltyDiscount / 100;
|
||||||
|
|
||||||
|
if (giftDue <= 0) {
|
||||||
|
toast.error('Nothing to charge — the booking is fully covered by discounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let payAmountCents = Math.round(giftDue * 100);
|
let payAmountCents = Math.round(giftDue * 100);
|
||||||
if (useAccountBalance) {
|
if (useAccountBalance) {
|
||||||
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
const parsedAmt = parseFloat(giftCardPaymentAmount);
|
||||||
@@ -513,6 +547,7 @@
|
|||||||
payAmountCents = Math.round(parsedAmt * 100);
|
payAmountCents = Math.round(parsedAmt * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isProcessingPaymentSync = true;
|
||||||
status = 'gift-confirming';
|
status = 'gift-confirming';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -557,6 +592,8 @@
|
|||||||
status = 'error';
|
status = 'error';
|
||||||
error = _err instanceof Error ? _err.message : 'Failed to process gift card';
|
error = _err instanceof Error ? _err.message : 'Failed to process gift card';
|
||||||
toast.error(error ?? 'Unknown error');
|
toast.error(error ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
isProcessingPaymentSync = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,11 +632,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSavedCardPayment() {
|
async function handleSavedCardPayment() {
|
||||||
|
if (isProcessingPaymentSync) return;
|
||||||
if (!selectedSavedCardId) {
|
if (!selectedSavedCardId) {
|
||||||
toast.error('Please select a saved card');
|
toast.error('Please select a saved card');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// totalDue can be £0 (fully discounted) and the loyalty discount is
|
||||||
|
// applied on top — the effective charge could otherwise be 0 or negative.
|
||||||
|
if (Math.round(totalDue * 100) - loyaltyDiscount <= 0) {
|
||||||
|
toast.error('Nothing to charge — the booking is fully covered by discounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingPaymentSync = true;
|
||||||
status = 'saved-card-processing';
|
status = 'saved-card-processing';
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -637,6 +683,8 @@
|
|||||||
status = 'error';
|
status = 'error';
|
||||||
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
error = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
|
||||||
toast.error(error ?? 'Unknown error');
|
toast.error(error ?? 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
isProcessingPaymentSync = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,6 +827,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if nothingToCharge}
|
||||||
|
<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||||
|
The booking is fully covered by discounts — nothing to charge.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if tipEnabled}
|
{#if tipEnabled}
|
||||||
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
|
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
|
||||||
<span class="text-sm font-medium text-green-800">
|
<span class="text-sm font-medium text-green-800">
|
||||||
@@ -797,7 +851,8 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
disabled={nothingToCharge}
|
||||||
|
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||||
'card'
|
'card'
|
||||||
? 'border-input bg-fuchsia-100 text-foreground'
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
: 'border-input hover:bg-fuchsia-50'}"
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
@@ -820,7 +875,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
disabled={nothingToCharge}
|
||||||
|
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||||
'cash'
|
'cash'
|
||||||
? 'border-input bg-fuchsia-100 text-foreground'
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
: 'border-input hover:bg-fuchsia-50'}"
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
@@ -844,7 +900,8 @@
|
|||||||
{#if savedCardList.length > 0}
|
{#if savedCardList.length > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
disabled={nothingToCharge}
|
||||||
|
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||||
'savedcard'
|
'savedcard'
|
||||||
? 'border-input bg-fuchsia-100 text-foreground'
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
: 'border-input hover:bg-fuchsia-50'}"
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
@@ -869,7 +926,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
disabled={nothingToCharge}
|
||||||
|
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
|
||||||
'giftcard'
|
'giftcard'
|
||||||
? 'border-input bg-fuchsia-100 text-foreground'
|
? 'border-input bg-fuchsia-100 text-foreground'
|
||||||
: 'border-input hover:bg-fuchsia-50'}"
|
: 'border-input hover:bg-fuchsia-50'}"
|
||||||
@@ -899,7 +957,8 @@
|
|||||||
{#if savedCardList.length > 0}
|
{#if savedCardList.length > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
disabled={nothingToCharge}
|
||||||
|
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedMethod = 'savedcard';
|
selectedMethod = 'savedcard';
|
||||||
status = 'saved-card-selecting';
|
status = 'saved-card-selecting';
|
||||||
@@ -910,7 +969,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
disabled={nothingToCharge}
|
||||||
|
class="text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
selectedMethod = 'giftcard';
|
selectedMethod = 'giftcard';
|
||||||
status = 'gift-entering';
|
status = 'gift-entering';
|
||||||
@@ -975,7 +1035,9 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
<Button onclick={handleCardPayment} class="flex-1">Charge Card</Button>
|
<Button onclick={handleCardPayment} class="flex-1" disabled={nothingToCharge}>
|
||||||
|
Charge Card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if status === 'card-processing' || status === 'card-polling'}
|
{:else if status === 'card-processing' || status === 'card-polling'}
|
||||||
@@ -1028,7 +1090,11 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
<Button onclick={handleCashPayment} class="flex-1" disabled={cashAmountNum < totalDue}>
|
<Button
|
||||||
|
onclick={handleCashPayment}
|
||||||
|
class="flex-1"
|
||||||
|
disabled={cashAmountNum < totalDue || nothingToCharge}
|
||||||
|
>
|
||||||
Confirm Cash
|
Confirm Cash
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1129,7 +1195,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid}>
|
<Button onclick={handleGiftCardPayment} class="flex-1" disabled={!giftCardValid || nothingToCharge}>
|
||||||
Apply Gift Card
|
Apply Gift Card
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1221,7 +1287,11 @@
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||||
<Button onclick={handleSavedCardPayment} class="flex-1" disabled={!selectedSavedCardId}>
|
<Button
|
||||||
|
onclick={handleSavedCardPayment}
|
||||||
|
class="flex-1"
|
||||||
|
disabled={!selectedSavedCardId || nothingToCharge}
|
||||||
|
>
|
||||||
Charge Saved Card
|
Charge Saved Card
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,73 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
/**
|
||||||
|
* The real card form is a CROSS-ORIGIN iframe (web.squarecdn.com) that does
|
||||||
|
* NOT inherit the page font or CSS — parent stylesheets cannot reach it; only
|
||||||
|
* the SDK `style` option can. This mirrors the app's shadcn Input (see
|
||||||
|
* ui/input/input.svelte + the tokens in app.css) so the iframe reads as the
|
||||||
|
* same input as the surrounding form instead of Square's default Helvetica
|
||||||
|
* Neue 16px. Selectors follow Square's CardClassSelectors schema; only the
|
||||||
|
* properties listed here are supported (fontSize is capped at 16px, and there
|
||||||
|
* is no per-field `inputs()` API).
|
||||||
|
*/
|
||||||
|
type SquareCardClassSelectors = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
const cardStyle: SquareCardClassSelectors = {
|
||||||
|
input: {
|
||||||
|
fontSize: '14px', // md:text-sm (text-base md:text-sm — desktop size)
|
||||||
|
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||||
|
fontWeight: '400',
|
||||||
|
color: 'oklch(0.129 0.042 264.695)', // --foreground
|
||||||
|
backgroundColor: 'oklch(1 0 0)' // --background
|
||||||
|
},
|
||||||
|
'input::placeholder': { color: 'oklch(0.554 0.046 257.417)' }, // --muted-foreground
|
||||||
|
'input.is-focus': { color: 'oklch(0.129 0.042 264.695)' },
|
||||||
|
'input.is-error': { color: 'oklch(0.64 0.21 25)' }, // --destructive
|
||||||
|
'.input-container': {
|
||||||
|
borderColor: 'oklch(0.929 0.013 255.508)', // --input (=== --border)
|
||||||
|
borderRadius: '8px' // rounded-md = calc(0.625rem - 2px) = --radius-md
|
||||||
|
},
|
||||||
|
'.input-container.is-focus': { borderColor: 'oklch(0.704 0.04 256.788)' }, // --ring
|
||||||
|
'.input-container.is-error': { borderColor: 'oklch(0.64 0.21 25)' },
|
||||||
|
'.message-text': { color: 'oklch(0.554 0.046 257.417)' },
|
||||||
|
'.message-text.is-error': { color: 'oklch(0.64 0.21 25)' },
|
||||||
|
'.message-icon': { color: 'oklch(0.554 0.046 257.417)' },
|
||||||
|
'.message-icon.is-error': { color: 'oklch(0.64 0.21 25)' }
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
import CardEntryUnavailable from './CardEntryUnavailable.svelte';
|
||||||
import type MockCardForm from './MockCardForm.svelte';
|
import type MockCardForm from './MockCardForm.svelte';
|
||||||
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
import { getSquarePayments, isSquareConfigured, isSquareMock } from '$lib/square/square';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing contact passed to Square's tokenize() verificationDetails for
|
||||||
|
* Strong Customer Authentication (SCA). Only fields we already hold are
|
||||||
|
* included; omit the object entirely when nothing is available.
|
||||||
|
*/
|
||||||
|
export interface SquareVerificationContact {
|
||||||
|
givenName?: string;
|
||||||
|
familyName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a tokenize-with-verification call. */
|
||||||
|
export interface TokenizeWithVerificationResult {
|
||||||
|
nonce: string;
|
||||||
|
verificationToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square Web Payments `card.tokenize()` verification details shape. */
|
||||||
|
interface SquareVerificationDetails {
|
||||||
|
amount: string;
|
||||||
|
billingContact?: SquareVerificationContact;
|
||||||
|
intent: string;
|
||||||
|
currencyCode: string;
|
||||||
|
customerInitiated: boolean;
|
||||||
|
sellerKeyedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Disable the form while a payment is processing. */
|
/** Disable the form while a payment is processing. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -35,17 +99,24 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payments = (await getSquarePayments()) as {
|
const payments = (await getSquarePayments()) as {
|
||||||
card: () => Promise<{
|
card: (options?: { style?: SquareCardClassSelectors }) => Promise<{
|
||||||
attach: (selector: string) => Promise<void>;
|
attach: (selector: string) => Promise<void>;
|
||||||
tokenize: () => Promise<{
|
tokenize: (
|
||||||
|
verificationDetails?: SquareVerificationDetails
|
||||||
|
) => Promise<{
|
||||||
status: string;
|
status: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
verificationResult?: { token?: string };
|
||||||
errors?: Array<{ message?: string; code?: string }>;
|
errors?: Array<{ message?: string; code?: string }>;
|
||||||
}>;
|
}>;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
const card = await payments.card();
|
// The iframe only honours styling passed via the SDK `style` option.
|
||||||
|
// card.configure({ style: cardStyle }) could re-apply it later if we
|
||||||
|
// ever need to restyle the already-attached form — no need to call it
|
||||||
|
// at init.
|
||||||
|
const card = await payments.card({ style: cardStyle });
|
||||||
await card.attach(`#${uniqueId}`);
|
await card.attach(`#${uniqueId}`);
|
||||||
cardInstance = card;
|
cardInstance = card;
|
||||||
ready = true;
|
ready = true;
|
||||||
@@ -82,9 +153,12 @@
|
|||||||
return mockForm.tokenize();
|
return mockForm.tokenize();
|
||||||
}
|
}
|
||||||
const card = cardInstance as {
|
const card = cardInstance as {
|
||||||
tokenize: () => Promise<{
|
tokenize: (
|
||||||
|
verificationDetails?: SquareVerificationDetails
|
||||||
|
) => Promise<{
|
||||||
status: string;
|
status: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
verificationResult?: { token?: string };
|
||||||
errors?: Array<{ message?: string; code?: string }>;
|
errors?: Array<{ message?: string; code?: string }>;
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -102,6 +176,82 @@
|
|||||||
.join(', ') || 'Card details are incomplete';
|
.join(', ') || 'Card details are incomplete';
|
||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenizes the entered card together with SCA verification details for a
|
||||||
|
* card-not-present charge. UK merchants must run Strong Customer
|
||||||
|
* Authentication for most online payments — without verificationDetails,
|
||||||
|
* Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED.
|
||||||
|
*
|
||||||
|
* Returns BOTH the card nonce and the verification token, which the caller
|
||||||
|
* must send to the backend as `verification_token` alongside
|
||||||
|
* `new_card_token`/`card_token`.
|
||||||
|
*
|
||||||
|
* @param amount The amount that WILL be charged, in pence (minor units).
|
||||||
|
* Square requires this to match the eventual payment amount.
|
||||||
|
* It is sent to Square as a MAJOR-units decimal string (e.g.
|
||||||
|
* 5000 pence → "50.00" for £50.00) per the W3C
|
||||||
|
* valid-decimal-monetary-value standard — sending pence as an
|
||||||
|
* integer string ("5000") would make Square's 3DS bind a
|
||||||
|
* 100×-too-large amount and fail SCA.
|
||||||
|
* @param contact Optional billing contact (name/email we already hold).
|
||||||
|
*/
|
||||||
|
export async function tokenizeWithVerification(
|
||||||
|
amount: number,
|
||||||
|
contact?: SquareVerificationContact
|
||||||
|
): Promise<TokenizeWithVerificationResult> {
|
||||||
|
if (isSquareMock()) {
|
||||||
|
if (!mockForm) {
|
||||||
|
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||||
|
}
|
||||||
|
return mockForm.tokenizeWithVerification(amount, contact);
|
||||||
|
}
|
||||||
|
const card = cardInstance as {
|
||||||
|
tokenize: (
|
||||||
|
verificationDetails: SquareVerificationDetails
|
||||||
|
) => Promise<{
|
||||||
|
status: string;
|
||||||
|
token?: string;
|
||||||
|
verificationResult?: { token?: string };
|
||||||
|
errors?: Array<{ message?: string; code?: string }>;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('Card form is not ready — please wait a moment and try again');
|
||||||
|
}
|
||||||
|
const verificationDetails: SquareVerificationDetails = {
|
||||||
|
// Square expects a MAJOR-units decimal string (W3C valid-decimal-
|
||||||
|
// monetary-value), e.g. "50.00" for £50.00 — NOT the minor-unit
|
||||||
|
// integer ("5000"), which would bind a 100×-too-large 3DS amount.
|
||||||
|
amount: (amount / 100).toFixed(2),
|
||||||
|
intent: 'CHARGE',
|
||||||
|
currencyCode: 'GBP',
|
||||||
|
customerInitiated: true,
|
||||||
|
sellerKeyedIn: false
|
||||||
|
};
|
||||||
|
if (contact && (contact.givenName || contact.familyName || contact.email)) {
|
||||||
|
verificationDetails.billingContact = contact;
|
||||||
|
}
|
||||||
|
const result = await card.tokenize(verificationDetails);
|
||||||
|
if (result.status === 'OK' && result.token) {
|
||||||
|
// In the current tokenize-with-verification flow the returned nonce
|
||||||
|
// (result.token) is ALREADY the 3DS-verified token — Square binds the
|
||||||
|
// SCA challenge to this exact amount, so charging it as
|
||||||
|
// `new_card_token`/`card_token` is sufficient. `verificationResult`
|
||||||
|
// only exists on the deprecated verifyBuyer() flow; we still read it
|
||||||
|
// defensively since the backend accepts an explicit verification_token.
|
||||||
|
return {
|
||||||
|
nonce: result.token,
|
||||||
|
verificationToken: result.verificationResult?.token ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const detail =
|
||||||
|
result.errors
|
||||||
|
?.map((e) => e.message || e.code)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Card details are incomplete';
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isSquareMock()}
|
{#if isSquareMock()}
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
|
// Cached nonce for the new-card form: tokenization is one-shot, so a retry
|
||||||
// reuses this token instead of re-tokenizing (backend idempotency dedups).
|
// reuses this token instead of re-tokenizing (backend idempotency dedups).
|
||||||
let newCardNonce = $state('');
|
let newCardNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with newCardNonce (both one-shot,
|
||||||
|
// reused together on retry). The verification token is amount-bound, so a
|
||||||
|
// changed payment amount invalidates the cached pair.
|
||||||
|
let newCardVerificationToken = $state('');
|
||||||
|
let newCardTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let newCardTokenizedAt = $state(0);
|
||||||
let paymentResult = $state<{
|
let paymentResult = $state<{
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -356,15 +365,27 @@
|
|||||||
|
|
||||||
let cardId: string | undefined;
|
let cardId: string | undefined;
|
||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
|
|
||||||
if (selectedCardId) {
|
if (selectedCardId) {
|
||||||
cardId = selectedCardId;
|
cardId = selectedCardId;
|
||||||
} else if (cardSelection) {
|
} else if (cardSelection) {
|
||||||
// New-card mode: tokenize once per attempt, then reuse the cached nonce
|
// New-card mode: tokenize once per attempt WITH SCA verification, then
|
||||||
// on retry (tokenization is one-shot; the backend idempotency key dedups).
|
// reuse the cached nonce + verification token on retry (tokenization
|
||||||
if (!newCardNonce) {
|
// is one-shot; the backend idempotency key dedups). The verification
|
||||||
|
// token is amount-bound, so a changed amount forces a fresh
|
||||||
|
// tokenization.
|
||||||
|
if (!newCardNonce || newCardTokenAmount !== amountCents || Date.now() - newCardTokenizedAt > 240_000) {
|
||||||
try {
|
try {
|
||||||
newCardNonce = await cardSelection.tokenize();
|
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
|
||||||
|
givenName: authStore.currentUser?.firstName,
|
||||||
|
familyName: authStore.currentUser?.lastName,
|
||||||
|
email: authStore.currentUser?.email
|
||||||
|
});
|
||||||
|
newCardNonce = tokenized.nonce;
|
||||||
|
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
newCardTokenAmount = amountCents;
|
||||||
|
newCardTokenizedAt = Date.now();
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
|
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
|
||||||
@@ -374,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newCardToken = newCardNonce;
|
newCardToken = newCardNonce;
|
||||||
|
verificationToken = newCardVerificationToken || undefined;
|
||||||
} else {
|
} else {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
error = 'Please select a payment method';
|
error = 'Please select a payment method';
|
||||||
@@ -405,6 +427,7 @@
|
|||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
...(cardId ? { card_id: cardId } : {}),
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
idempotency_key: payIdempotencyKey
|
idempotency_key: payIdempotencyKey
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -422,6 +445,9 @@
|
|||||||
payKeyedType = '';
|
payKeyedType = '';
|
||||||
payKeyedCard = '';
|
payKeyedCard = '';
|
||||||
newCardNonce = '';
|
newCardNonce = '';
|
||||||
|
newCardVerificationToken = '';
|
||||||
|
newCardTokenAmount = 0;
|
||||||
|
newCardTokenizedAt = 0;
|
||||||
paymentResult = {
|
paymentResult = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
@@ -777,6 +803,7 @@
|
|||||||
)}
|
)}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -855,6 +882,7 @@
|
|||||||
)}
|
)}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
trigger
|
trigger,
|
||||||
|
label = 'cancellation policy',
|
||||||
|
href = '/cancellation-policy'
|
||||||
}: {
|
}: {
|
||||||
trigger?: Snippet;
|
trigger?: Snippet;
|
||||||
|
/** Button label shown when no custom trigger snippet is provided. */
|
||||||
|
label?: string;
|
||||||
|
/** Route the "Open" link and "Download PDF" action point at. */
|
||||||
|
href?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -36,7 +42,7 @@
|
|||||||
{#if trigger}
|
{#if trigger}
|
||||||
{@render trigger()}
|
{@render trigger()}
|
||||||
{:else}
|
{:else}
|
||||||
cancellation policy
|
{label}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@
|
|||||||
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
|
class="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-gray-200 bg-white p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="/cancellation-policy"
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer external"
|
rel="noopener noreferrer external"
|
||||||
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
|
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
|
||||||
@@ -58,7 +64,7 @@
|
|||||||
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
|
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
open = false;
|
open = false;
|
||||||
const win = window.open('/cancellation-policy?format=pdf', '_blank');
|
const win = window.open(`${href}?format=pdf`, '_blank');
|
||||||
if (win) win.focus();
|
if (win) win.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -212,6 +212,15 @@
|
|||||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||||
// of re-tokenizing (the backend idempotency key dedups).
|
// of re-tokenizing (the backend idempotency key dedups).
|
||||||
let buyNonce = $state('');
|
let buyNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with buyNonce (both one-shot, reused
|
||||||
|
// together on retry). The verification token is amount-bound, so changing
|
||||||
|
// the amount invalidates the cached pair.
|
||||||
|
let buyVerificationToken = $state('');
|
||||||
|
let buyTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let buyTokenizedAt = $state(0);
|
||||||
|
|
||||||
// Cached idempotency key: generated once per purchase attempt, reused on
|
// Cached idempotency key: generated once per purchase attempt, reused on
|
||||||
// retry (so a lost-response retry dedups instead of double-charging),
|
// retry (so a lost-response retry dedups instead of double-charging),
|
||||||
@@ -269,13 +278,24 @@
|
|||||||
|
|
||||||
async function buyGiftCard() {
|
async function buyGiftCard() {
|
||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
if (buySelectedCard) {
|
if (buySelectedCard) {
|
||||||
// saved card — nothing to tokenize
|
// saved card — nothing to tokenize
|
||||||
} else if (buyCardSelection) {
|
} else if (buyCardSelection) {
|
||||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
if (!buyNonce) {
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
|
// idempotency key dedups).
|
||||||
|
if (!buyNonce || buyTokenAmount !== buyAmount * 100 || Date.now() - buyTokenizedAt > 240_000) {
|
||||||
try {
|
try {
|
||||||
buyNonce = await buyCardSelection.tokenize();
|
const tokenized = await buyCardSelection.tokenizeWithVerification(buyAmount * 100, {
|
||||||
|
givenName: userData?.firstName,
|
||||||
|
familyName: userData?.lastName,
|
||||||
|
email: userData?.email
|
||||||
|
});
|
||||||
|
buyNonce = tokenized.nonce;
|
||||||
|
buyVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
buyTokenAmount = buyAmount * 100;
|
||||||
|
buyTokenizedAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||||
buyingGiftCard = false;
|
buyingGiftCard = false;
|
||||||
@@ -283,6 +303,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newCardToken = buyNonce;
|
newCardToken = buyNonce;
|
||||||
|
verificationToken = buyVerificationToken || undefined;
|
||||||
} else {
|
} else {
|
||||||
toast.error('Please select a payment method');
|
toast.error('Please select a payment method');
|
||||||
buyingGiftCard = false;
|
buyingGiftCard = false;
|
||||||
@@ -312,6 +333,7 @@
|
|||||||
recipient_email: buyRecipientEmail,
|
recipient_email: buyRecipientEmail,
|
||||||
...(cardId ? { card_id: cardId } : {}),
|
...(cardId ? { card_id: cardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||||
idempotency_key: buyIdempotencyKey
|
idempotency_key: buyIdempotencyKey
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -324,6 +346,9 @@
|
|||||||
buyKeyedAmount = 0;
|
buyKeyedAmount = 0;
|
||||||
buyKeyedCard = '';
|
buyKeyedCard = '';
|
||||||
buyNonce = '';
|
buyNonce = '';
|
||||||
|
buyVerificationToken = '';
|
||||||
|
buyTokenAmount = 0;
|
||||||
|
buyTokenizedAt = 0;
|
||||||
await fetchGiftCardBalance();
|
await fetchGiftCardBalance();
|
||||||
} else {
|
} else {
|
||||||
const errText = await res.text();
|
const errText = await res.text();
|
||||||
@@ -1848,7 +1873,11 @@
|
|||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Saved Cards</Card.Title>
|
<Card.Title>Saved Cards</Card.Title>
|
||||||
<Card.Description>Manage your saved payment methods</Card.Description>
|
<Card.Description>
|
||||||
|
Manage your saved payment methods — cards are stored securely with our
|
||||||
|
payment provider (Square).
|
||||||
|
<PolicyPopover label="privacy policy" href="/privacy-policy" />
|
||||||
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if loadingCards}
|
{#if loadingCards}
|
||||||
@@ -2184,6 +2213,7 @@
|
|||||||
>
|
>
|
||||||
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
{buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -2376,6 +2406,23 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PolicyPopover>
|
</PolicyPopover>
|
||||||
|
<PolicyPopover label="privacy policy" href="/privacy-policy">
|
||||||
|
{#snippet trigger()}
|
||||||
|
<Button variant="outline" class="mt-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
Privacy Policy
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PolicyPopover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -58,6 +58,15 @@
|
|||||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||||
// of re-tokenizing (the backend idempotency key dedups).
|
// of re-tokenizing (the backend idempotency key dedups).
|
||||||
let tipNonce = $state('');
|
let tipNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with tipNonce (both one-shot, reused
|
||||||
|
// together on retry). The verification token is amount-bound, so changing
|
||||||
|
// the tip invalidates the cached pair.
|
||||||
|
let tipVerificationToken = $state('');
|
||||||
|
let tipTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let tipTokenizedAt = $state(0);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||||
@@ -203,19 +212,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
if (selectedCardId) {
|
if (selectedCardId) {
|
||||||
// saved card — nothing to tokenize
|
// saved card — nothing to tokenize
|
||||||
} else if (cardSelection) {
|
} else if (cardSelection) {
|
||||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
if (!tipNonce) {
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
|
// idempotency key dedups). The verification token is amount-bound, so
|
||||||
|
// a changed tip amount forces a fresh tokenization.
|
||||||
|
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
||||||
try {
|
try {
|
||||||
tipNonce = await cardSelection.tokenize();
|
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||||
|
Math.round(tipAmount * 100),
|
||||||
|
{
|
||||||
|
givenName: authStore.currentUser?.firstName,
|
||||||
|
familyName: authStore.currentUser?.lastName,
|
||||||
|
email: authStore.currentUser?.email
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tipNonce = tokenized.nonce;
|
||||||
|
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
tipTokenAmount = tipAmount;
|
||||||
|
tipTokenizedAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newCardToken = tipNonce;
|
newCardToken = tipNonce;
|
||||||
|
verificationToken = tipVerificationToken || undefined;
|
||||||
} else {
|
} else {
|
||||||
toast.error('Please select a payment method');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
@@ -233,7 +258,8 @@
|
|||||||
amount: amountInPence,
|
amount: amountInPence,
|
||||||
idempotency_key: tipIdempotencyKey,
|
idempotency_key: tipIdempotencyKey,
|
||||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
|
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
|
const response = await apiFetch(`/api/bookings/${bookingId}/tip`, {
|
||||||
@@ -251,6 +277,9 @@
|
|||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
tipNonce = '';
|
tipNonce = '';
|
||||||
|
tipVerificationToken = '';
|
||||||
|
tipTokenAmount = 0;
|
||||||
|
tipTokenizedAt = 0;
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let format = $state('html');
|
||||||
|
|
||||||
|
let pdfNotice = $state(true);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
format = $page.url.searchParams.get('format') || 'html';
|
||||||
|
if (format === 'pdf') {
|
||||||
|
// Strip ?format=pdf from the URL so a refresh doesn't re-trigger the print dialog.
|
||||||
|
const clean = window.location.pathname + window.location.hash;
|
||||||
|
history.replaceState(null, '', clean);
|
||||||
|
|
||||||
|
// Open the print dialog once the page is rendered.
|
||||||
|
// The notice and draft badges are removed before print so they won't appear in the PDF.
|
||||||
|
setTimeout(() => {
|
||||||
|
pdfNotice = false;
|
||||||
|
// Small delay so Svelte can remove the element before the print engine snapshots.
|
||||||
|
setTimeout(() => window.print(), 50);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Privacy Policy</title>
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
:global(nav),
|
||||||
|
:global(.no-print) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
:global(body) {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl px-4 py-8 text-gray-900">
|
||||||
|
<div class="mb-2 flex items-center gap-3">
|
||||||
|
<h1 class="border-b border-gray-200 pb-4 text-2xl font-bold">Privacy Policy</h1>
|
||||||
|
<span
|
||||||
|
class="no-print shrink-0 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-800"
|
||||||
|
>
|
||||||
|
DRAFT — for review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-8 font-mono text-xs text-gray-500">Last updated: August 2026</p>
|
||||||
|
|
||||||
|
{#if format === 'pdf' && pdfNotice}
|
||||||
|
<p class="mb-6 rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600 italic">
|
||||||
|
Generating PDF… If the print dialog does not appear, use Ctrl+P / Cmd+P.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-8 text-sm leading-relaxed text-gray-700">
|
||||||
|
<!-- Section 1 -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-base font-semibold text-gray-900">1. Introduction</h2>
|
||||||
|
<p class="mb-3">
|
||||||
|
This Privacy Policy explains how Crussell Salon (“we”, “us”,
|
||||||
|
“our”) collects, uses, and protects your personal data when you use our
|
||||||
|
booking platform (“Platform”).
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
We are committed to protecting your privacy and complying with the
|
||||||
|
<strong>UK General Data Protection Regulation (UK GDPR)</strong> and
|
||||||
|
<strong>Data Protection Act 2018</strong>.
|
||||||
|
</p>
|
||||||
|
<div class="rounded-md border border-gray-200 bg-gray-50/50 p-4 text-xs text-gray-600">
|
||||||
|
<p class="font-semibold text-gray-900">Data Controller</p>
|
||||||
|
<p class="mt-1">Crussell Salon</p>
|
||||||
|
<p>Edinburgh, Scotland</p>
|
||||||
|
<p>Email: help@crussell.invalid</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2 -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-base font-semibold text-gray-900">2. Data We Collect</h2>
|
||||||
|
|
||||||
|
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.1 Personal Data (Identifiable Information)</h3>
|
||||||
|
<p class="mb-2 font-medium text-gray-800">Account Information:</p>
|
||||||
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
|
<li>Name (first, last)</li>
|
||||||
|
<li>Email address</li>
|
||||||
|
<li>Phone number</li>
|
||||||
|
<li>Date of birth (optional, for age verification)</li>
|
||||||
|
<li>Account ID (for balance recovery after deletion)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-2 font-medium text-gray-800">Booking Information:</p>
|
||||||
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
|
<li>Appointment dates, times, services</li>
|
||||||
|
<li>Treatment notes and preferences</li>
|
||||||
|
<li>Allergy and patch test records (health data — special category)</li>
|
||||||
|
<li>Payment history and transaction records</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-2 font-medium text-gray-800">Financial Data:</p>
|
||||||
|
<ul class="mb-4 list-disc space-y-1 pl-5">
|
||||||
|
<li>Gift card codes and balances</li>
|
||||||
|
<li>Account balances</li>
|
||||||
|
<li>Payment transaction records (processed via Square, not stored by us)</li>
|
||||||
|
<li>Saved-card references (tokenised, stored with our payment provider Square — see §2.2)</li>
|
||||||
|
<li>Dormant balance records (Account ID only, no PII)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.2 Saved Cards & Payment Provider (Square)</h3>
|
||||||
|
<p class="mb-3">
|
||||||
|
When you choose to <strong>save a card for next time</strong>, we store a tokenised
|
||||||
|
reference to your card with our payment processor, <strong>Square</strong> (a data
|
||||||
|
processor), rather than on our own systems.
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
<strong>What Square stores:</strong> a tokenised reference to your card (never your
|
||||||
|
full card number or CVV), plus the name and email address we already hold on your
|
||||||
|
account, grouped into a Square customer profile.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Lawful basis:</strong> UK GDPR Article 6(1)(b) — necessary for the
|
||||||
|
performance of the contract (you asked to save your card for future payments).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Why:</strong> so you can pay for future bookings, tips, or gift-card purchases
|
||||||
|
without re-entering your card details.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>One-off payments:</strong> if you do not tick “save this card”,
|
||||||
|
<strong>no card is stored and no Square customer profile is created</strong> for you
|
||||||
|
— your card is used only for that single payment.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Retention & removal:</strong> the reference remains stored until you delete
|
||||||
|
the card from your account (Account → Saved Cards) or your account is deleted. You
|
||||||
|
can remove a saved card at any time.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Square’s privacy policy:</strong>
|
||||||
|
<a
|
||||||
|
href="https://squareup.com/gb/en/legal/privacy-no-account"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-medium text-blue-600 underline hover:text-blue-800"
|
||||||
|
>Square Privacy Policy</a
|
||||||
|
>
|
||||||
|
applies to data Square holds on our behalf.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-4">
|
||||||
|
We never store full card numbers, card security codes (CVV), or card expiry data on our
|
||||||
|
own systems at any point.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">2.3 Special Category Data (Health Data)</h3>
|
||||||
|
<p class="mb-3">We collect health-related information with your <strong>explicit consent</strong>:</p>
|
||||||
|
<ul class="mb-3 list-disc space-y-1 pl-5">
|
||||||
|
<li>Allergy records</li>
|
||||||
|
<li>Patch test results</li>
|
||||||
|
<li>Medical conditions affecting treatment</li>
|
||||||
|
<li>Skin sensitivity notes</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>Legal basis:</strong> UK GDPR Article 9(2)(a) — Explicit consent<br />
|
||||||
|
<strong>Retention:</strong> 7 years (insurance requirement) or account deletion (whichever
|
||||||
|
is later)
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3 -->
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-3 text-base font-semibold text-gray-900">3. Data Retention & Deletion Process</h2>
|
||||||
|
|
||||||
|
<h3 class="mt-4 mb-2 text-sm font-semibold text-gray-800">3.1 Retention Schedule</h3>
|
||||||
|
<div class="overflow-x-auto rounded-md border border-gray-200">
|
||||||
|
<table class="w-full border-collapse text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 bg-gray-50/50 text-left text-gray-900">
|
||||||
|
<th class="px-3 py-2 font-semibold">Data Category</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Retention Period</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Legal Basis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 text-gray-600">
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Active account data</td>
|
||||||
|
<td class="px-3 py-2">Account active + 2 years</td>
|
||||||
|
<td class="px-3 py-2">Legitimate interest</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Inactive accounts (no balance)</td>
|
||||||
|
<td class="px-3 py-2">2 years idle</td>
|
||||||
|
<td class="px-3 py-2">GDPR storage limitation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Inactive accounts (with balance)</td>
|
||||||
|
<td class="px-3 py-2">5 years idle</td>
|
||||||
|
<td class="px-3 py-2">Scottish prescriptive period</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Financial records</td>
|
||||||
|
<td class="px-3 py-2">7 years</td>
|
||||||
|
<td class="px-3 py-2">HMRC requirement</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Saved-card references (Square)</td>
|
||||||
|
<td class="px-3 py-2">Until user deletes card or account is deleted (Square-side)</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
Contract performance (Art 6(1)(b)); card-network card-on-file rules
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Allergy/health records</td>
|
||||||
|
<td class="px-3 py-2">7 years</td>
|
||||||
|
<td class="px-3 py-2">Insurance requirement</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Dormant balances</td>
|
||||||
|
<td class="px-3 py-2">Indefinite (Account ID only)</td>
|
||||||
|
<td class="px-3 py-2">Recovery mechanism</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">Marketing preferences</td>
|
||||||
|
<td class="px-3 py-2">Until withdrawn</td>
|
||||||
|
<td class="px-3 py-2">Consent</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-6 mb-2 text-sm font-semibold text-gray-800">Deletion Process</h3>
|
||||||
|
<p class="mb-2 font-medium text-gray-800">Account deletion (your request):</p>
|
||||||
|
<ol class="mb-3 list-decimal space-y-1 pl-5">
|
||||||
|
<li>You confirm deletion (warning about data loss).</li>
|
||||||
|
<li>If balance exists, transferred to dormant balance system.</li>
|
||||||
|
<li>Account ID sent to you via email.</li>
|
||||||
|
<li>Personal data anonymized (name, email, phone replaced with placeholders).</li>
|
||||||
|
<li>Financial records retained 7 years (HMRC) then aggregated.</li>
|
||||||
|
<li>Allergy records retained 7 years (insurance) then deleted.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>Saved cards:</strong> Deleting your account also removes your saved-card
|
||||||
|
references from our system and disables the corresponding card tokens at Square (see
|
||||||
|
§2.2). Card transaction records for payments already made are retained per the HMRC
|
||||||
|
schedule above.
|
||||||
|
</p>
|
||||||
|
<p class="mb-2 font-medium text-gray-800">Inactive account deletion (automatic):</p>
|
||||||
|
<ol class="mb-3 list-decimal space-y-1 pl-5">
|
||||||
|
<li>Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).</li>
|
||||||
|
<li>If no activity, account deleted as above.</li>
|
||||||
|
<li>Dormant balance recoverable with Account ID.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 4 -->
|
||||||
|
<section class="border-t border-gray-200 pt-6">
|
||||||
|
<h2 class="mb-2 text-base font-semibold text-gray-900">4. Your Rights</h2>
|
||||||
|
<p class="mb-3">Under UK GDPR, you have the right to:</p>
|
||||||
|
<ul class="mb-4 list-disc space-y-1 pl-5">
|
||||||
|
<li><strong>Access</strong> your personal data (Article 15)</li>
|
||||||
|
<li><strong>Rectify</strong> inaccurate data (Article 16)</li>
|
||||||
|
<li><strong>Erase</strong> your data (Article 17 — subject to HMRC/insurance retention)</li>
|
||||||
|
<li><strong>Restrict</strong> processing (Article 18)</li>
|
||||||
|
<li><strong>Data Portability</strong> (Article 20)</li>
|
||||||
|
<li><strong>Object</strong> to processing (Article 21)</li>
|
||||||
|
<li><strong>Withdraw Consent</strong> (Article 7(3))</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mb-4">
|
||||||
|
To exercise these rights, contact help@crussell.invalid. You also have the right to
|
||||||
|
complain to the Information Commissioner’s Office (ICO) at any time.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Questions about how we handle your data? Please use our official
|
||||||
|
<a
|
||||||
|
href={resolve('/contact')}
|
||||||
|
class="font-medium text-blue-600 underline hover:text-blue-800">Contact Channels</a
|
||||||
|
>
|
||||||
|
to get in touch.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -65,6 +65,15 @@
|
|||||||
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
// Cached nonce: tokenization is one-shot — a retry reuses this token instead
|
||||||
// of re-tokenizing (the backend idempotency key dedups).
|
// of re-tokenizing (the backend idempotency key dedups).
|
||||||
let tipNonce = $state('');
|
let tipNonce = $state('');
|
||||||
|
// Cached SCA verification token paired with tipNonce (both one-shot, reused
|
||||||
|
// together on retry). The verification token is amount-bound, so changing
|
||||||
|
// the tip invalidates the cached pair.
|
||||||
|
let tipVerificationToken = $state('');
|
||||||
|
let tipTokenAmount = $state(0);
|
||||||
|
// Epoch ms when the cached pair was tokenized — Square nonces and SCA
|
||||||
|
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||||
|
// on late retries and re-tokenized instead of rejected by Square.
|
||||||
|
let tipTokenizedAt = $state(0);
|
||||||
|
|
||||||
const canSaveCards = $derived(
|
const canSaveCards = $derived(
|
||||||
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate'
|
||||||
@@ -153,19 +162,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let newCardToken: string | undefined;
|
let newCardToken: string | undefined;
|
||||||
|
let verificationToken: string | undefined;
|
||||||
if (selectedCardId) {
|
if (selectedCardId) {
|
||||||
// saved card — nothing to tokenize
|
// saved card — nothing to tokenize
|
||||||
} else if (cardSelection) {
|
} else if (cardSelection) {
|
||||||
// New-card mode: tokenize once per attempt, reuse the nonce on retry.
|
// New-card mode: tokenize once per attempt, reuse the nonce + SCA
|
||||||
if (!tipNonce) {
|
// verification token on retry (tokenization is one-shot; the backend
|
||||||
|
// idempotency key dedups). The verification token is amount-bound, so
|
||||||
|
// a changed tip amount forces a fresh tokenization.
|
||||||
|
if (!tipNonce || tipTokenAmount !== tipAmount || Date.now() - tipTokenizedAt > 240_000) {
|
||||||
try {
|
try {
|
||||||
tipNonce = await cardSelection.tokenize();
|
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||||
|
Math.round(tipAmount * 100),
|
||||||
|
{
|
||||||
|
givenName: authStore.currentUser?.firstName,
|
||||||
|
familyName: authStore.currentUser?.lastName,
|
||||||
|
email: authStore.currentUser?.email
|
||||||
|
}
|
||||||
|
);
|
||||||
|
tipNonce = tokenized.nonce;
|
||||||
|
tipVerificationToken = tokenized.verificationToken ?? '';
|
||||||
|
tipTokenAmount = tipAmount;
|
||||||
|
tipTokenizedAt = Date.now();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
toast.error(err instanceof Error ? err.message : 'Card entry failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newCardToken = tipNonce;
|
newCardToken = tipNonce;
|
||||||
|
verificationToken = tipVerificationToken || undefined;
|
||||||
} else {
|
} else {
|
||||||
toast.error('Please select a payment method');
|
toast.error('Please select a payment method');
|
||||||
return;
|
return;
|
||||||
@@ -183,7 +208,8 @@
|
|||||||
amount: amountInPence,
|
amount: amountInPence,
|
||||||
idempotency_key: tipIdempotencyKey,
|
idempotency_key: tipIdempotencyKey,
|
||||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {})
|
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||||
|
...(verificationToken ? { verification_token: verificationToken } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
const response = await apiFetch(`/api/bookings/${booking.id}/tip`, {
|
||||||
@@ -201,6 +227,9 @@
|
|||||||
tipIdempotencyKey = '';
|
tipIdempotencyKey = '';
|
||||||
tipKeyedAmount = 0;
|
tipKeyedAmount = 0;
|
||||||
tipNonce = '';
|
tipNonce = '';
|
||||||
|
tipVerificationToken = '';
|
||||||
|
tipTokenAmount = 0;
|
||||||
|
tipTokenizedAt = 0;
|
||||||
toast.success('Thank you for your tip!');
|
toast.success('Thank you for your tip!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
paymentState = 'error';
|
paymentState = 'error';
|
||||||
|
|||||||
@@ -684,6 +684,26 @@ CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
|||||||
CREATE INDEX idx_payments_payment_method ON payments(payment_method);
|
CREATE INDEX idx_payments_payment_method ON payments(payment_method);
|
||||||
CREATE INDEX idx_payments_payment_method_created_at ON payments(payment_method, created_at);
|
CREATE INDEX idx_payments_payment_method_created_at ON payments(payment_method, created_at);
|
||||||
|
|
||||||
|
-- =======================================
|
||||||
|
-- TERMINAL CHECKOUTS TABLE
|
||||||
|
-- =======================================
|
||||||
|
-- Tracks in-flight Square Terminal checkouts per booking so a lost-response
|
||||||
|
-- retry cannot create a second live checkout, and records the payment type
|
||||||
|
-- the admin charged so GetCheckoutStatus records the payment with that type
|
||||||
|
-- instead of hardcoding 'full'.
|
||||||
|
CREATE TABLE terminal_checkouts (
|
||||||
|
checkout_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
|
||||||
|
payment_type payment_type NOT NULL DEFAULT 'full',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
amount NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_terminal_checkouts_booking ON terminal_checkouts(booking_id, status);
|
||||||
|
CREATE INDEX idx_terminal_checkouts_status ON terminal_checkouts(status);
|
||||||
|
|
||||||
-- =======================================
|
-- =======================================
|
||||||
-- LOYALTY REDEMPTIONS TABLE
|
-- LOYALTY REDEMPTIONS TABLE
|
||||||
-- =======================================
|
-- =======================================
|
||||||
@@ -1960,7 +1980,12 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE TABLE user_saved_cards (
|
CREATE TABLE user_saved_cards (
|
||||||
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
|
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
|
||||||
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
square_card_id TEXT NOT NULL UNIQUE,
|
square_card_id TEXT NOT NULL,
|
||||||
|
-- Square customer profile id (P14): populated lazily the first time the
|
||||||
|
-- user SAVES a card, then reused for every subsequent card save. NULL for
|
||||||
|
-- rows created before provisioning was introduced. One-off (non-save)
|
||||||
|
-- payments never mint a Square customer, so this stays NULL for them.
|
||||||
|
square_customer_id TEXT,
|
||||||
brand TEXT NOT NULL,
|
brand TEXT NOT NULL,
|
||||||
last_4 TEXT NOT NULL,
|
last_4 TEXT NOT NULL,
|
||||||
exp_month INT NOT NULL,
|
exp_month INT NOT NULL,
|
||||||
@@ -1970,7 +1995,12 @@ CREATE TABLE user_saved_cards (
|
|||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
retained_until TIMESTAMPTZ,
|
retained_until TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- Uniqueness is per-user: the same physical card saved by two users yields
|
||||||
|
-- two independent rows, so user B saving a card already on user A's account
|
||||||
|
-- can never mutate A's row (or revive A's deleted card). A response-lost
|
||||||
|
-- retry re-tokenizing the same card for the SAME user upserts via this key.
|
||||||
|
UNIQUE (user_id, square_card_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_user_saved_cards_user ON user_saved_cards(user_id);
|
CREATE INDEX idx_user_saved_cards_user ON user_saved_cards(user_id);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ These are things that work fine in dev (with mocks) but need real implementation
|
|||||||
| P10 | **No automated database backups** | M (1d) | Infrastructure | PostgreSQL volume is persistent in Docker but no `pg_dump` cron, no point-in-time recovery. | Standard production DB setup task. |
|
| P10 | **No automated database backups** | M (1d) | Infrastructure | PostgreSQL volume is persistent in Docker but no `pg_dump` cron, no point-in-time recovery. | Standard production DB setup task. |
|
||||||
| P12 | **Square sandbox smoke test (pre-go-live gate)** | S-M (1d, once credentials available) | E2E | **BLOCKED — no real Square credentials available.** Must exercise the real API path end-to-end: new-card tokenization → payment → saved card → refund → reconcile, against Square's sandbox. Also verifies the M-8 open question (is `card.customer_id` enforced as Required?). | The dev mock cannot exercise Square's real wire contract (key-length limits, `device_options`, refund statuses, error codes). This is the sole remaining item before the production flip. See `plans/p11-square-web-payments-sdk.md` Remaining Items. |
|
| P12 | **Square sandbox smoke test (pre-go-live gate)** | S-M (1d, once credentials available) | E2E | **BLOCKED — no real Square credentials available.** Must exercise the real API path end-to-end: new-card tokenization → payment → saved card → refund → reconcile, against Square's sandbox. Also verifies the M-8 open question (is `card.customer_id` enforced as Required?). | The dev mock cannot exercise Square's real wire contract (key-length limits, `device_options`, refund statuses, error codes). This is the sole remaining item before the production flip. See `plans/p11-square-web-payments-sdk.md` Remaining Items. |
|
||||||
| P13 | **Reconcile deterministically-keyed saved-card charges** | S (2-3h) | Backend | **Deferred — deliberate trade-off (N-OBS-1).** The admin "Charge Saved Card" idempotency key `bookingID-sc-type-amount-cardID` dedups two *identical* repeat charges on one booking. Not UI-reachable today (PaymentModal always sends the current `totalDue`, which changes after each charge). | Revisit if the admin flow ever gains a "charge exact amount twice" path — the key would then need a client nonce or attempt counter. Tracked from the final payment review. |
|
| P13 | **Reconcile deterministically-keyed saved-card charges** | S (2-3h) | Backend | **Deferred — deliberate trade-off (N-OBS-1).** The admin "Charge Saved Card" idempotency key `bookingID-sc-type-amount-cardID` dedups two *identical* repeat charges on one booking. Not UI-reachable today (PaymentModal always sends the current `totalDue`, which changes after each charge). | Revisit if the admin flow ever gains a "charge exact amount twice" path — the key would then need a client nonce or attempt counter. Tracked from the final payment review. |
|
||||||
| P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **PLANNED — gated on P12.** The final payment review flagged that Square's docs mark `card.customer_id` as Required on `POST /v2/cards` and on `ccof:` charges. If enforced, saved-card flows 400 in production. Plan: lazily provision Square customers only when a user saves a card, persist `square_customer_id`, charge one-off new-card payments directly with the `cnon:` nonce (no card/customer minted for non-savers or guests), update the privacy policy (Square as processor) and the card-save checkbox copy. **No standalone consent checkbox is required** — the existing card-save checkbox covers it. Includes a policy pop-over on the consent checkbox linking to `/privacy-policy` (mirrors the existing `/cancellation-policy` pop-over pattern — generalise `policyPopover.svelte`, new `/privacy-policy` route). Privacy Policy §2.2 + Terms §3.2 already drafted into the placeholders (Aug 2026). See `plans/p14-square-customer-provisioning-consent.md`. | The app currently mints a Square card-on-file for *every* new-card payment (even when not saving); if customer provisioning becomes mandatory, that would create customer profiles for all payers including guests. Data-minimisation design avoids this. Must sandbox-test first (P12): Square may not actually enforce `customer_id` (M-8/R2 open question). |
|
| P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **IMPLEMENTED (Aug 2026)** — lazy customer provisioning on card-save, `square_customer_id` persisted + forwarded to Square as `card.customer_id`/CreatePayment `CustomerID`, one-off/guest no-customer, `/privacy-policy` route + consent pop-over, SCA verificationDetails wired across all charge flows. **Remaining:** P12 sandbox verification that Square enforces `customer_id`, and final privacy-policy copy review (route ships DRAFT-bannered). See `plans/p14-square-customer-provisioning-consent.md`. | Closed out of the deep post-implementation review (Aug 2026). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ The DAV service has a completely separate database connection from the rest of t
|
|||||||
| `internal/validators` | ID validation (12-char hex format), cursor parsing |
|
| `internal/validators` | ID validation (12-char hex format), cursor parsing |
|
||||||
| `internal/dav` | SabreDAV CardDAV integration (build tags: `service_dev.go` / `service_prod.go`) |
|
| `internal/dav` | SabreDAV CardDAV integration (build tags: `service_dev.go` / `service_prod.go`) |
|
||||||
| `internal/s3` | S3/R2 storage abstraction (build tags: dev vs prod) |
|
| `internal/s3` | S3/R2 storage abstraction (build tags: dev vs prod) |
|
||||||
| `internal/square` | Square client interface + dev mock + prod stub (build tags: `dev` vs `!dev`) |
|
| `internal/square` | Square client interface + dev mock + real HTTP prod client (build tags: `dev` vs `!dev`) |
|
||||||
| `internal/zxcvbnjs` | Bundles @zxcvbn-ts/core via goja (ExecJS-style). Exact parity with frontend password scoring. Go binary embeds the 1.7MB IIFE JS bundle. |
|
| `internal/zxcvbnjs` | Bundles @zxcvbn-ts/core via goja (ExecJS-style). Exact parity with frontend password scoring. Go binary embeds the 1.7MB IIFE JS bundle. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1276,7 +1276,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
|||||||
| `handlers/auth` | Authentication (login, register, refresh, verification) |
|
| `handlers/auth` | Authentication (login, register, refresh, verification) |
|
||||||
| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, approval, time-blockers, exceptional hours, cancellation, cross-user isolation |
|
| `handlers/bookings` | User booking flow, guest bookings, reservations, edit requests, approval, time-blockers, exceptional hours, cancellation, cross-user isolation |
|
||||||
| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards, gift cards) |
|
| `handlers/payments` | Square payments (terminal, online, refunds, tips, saved cards, gift cards) |
|
||||||
| `internal/square` | Square client interface, dev mock, prod stub |
|
| `internal/square` | Square client interface, dev mock, real HTTP prod client |
|
||||||
| `handlers/admin` | Admin bookings, today view, users, services, GetBookingsByCreatedRange |
|
| `handlers/admin` | Admin bookings, today view, users, services, GetBookingsByCreatedRange |
|
||||||
| `handlers/scheduling` | Working hours, exceptional groups, available hours, time blockers, gift card expiry cleanup, idle account cleanup |
|
| `handlers/scheduling` | Working hours, exceptional groups, available hours, time blockers, gift card expiry cleanup, idle account cleanup |
|
||||||
| `handlers/services` | Service eligibility (age + patch test filtering) |
|
| `handlers/services` | Service eligibility (age + patch test filtering) |
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# P14 — Square Customer Provisioning & Consent
|
# P14 — Square Customer Provisioning & Consent
|
||||||
|
|
||||||
**Status:** 📋 PLANNED — not started. Gated on P12 (sandbox credentials) to confirm whether Square *enforces* `card.customer_id` as Required.
|
**Status:** ✅ PARTIALLY IMPLEMENTED (backend + frontend code landed Aug 2026) — remaining verification gated on P12 (sandbox credentials).
|
||||||
**Owner:** Implementation agent (payment integration round)
|
**Owner:** Implementation agent (payment integration round)
|
||||||
**Estimated effort:** S-M (1-2 days backend/frontend + privacy policy copy)
|
**Estimated effort:** S-M (1-2 days backend/frontend + privacy policy copy)
|
||||||
**Backlog reference:** `Future Work - Gap Backlog.md` item P14 (added alongside this plan)
|
**Backlog reference:** `Future Work - Gap Backlog.md` item P14 (added alongside this plan)
|
||||||
|
|
||||||
|
> **Implementation status (Aug 2026):** The code is DONE: `square_customer_id` is provisioned lazily on card-save, stored on `user_saved_cards`, and now forwarded to Square as `card.customer_id` (CreateCard) and `CustomerID` (ccof: CreatePayment). One-off/guest payments mint no customer. The `/privacy-policy` route + consent pop-over shipped. **What remains:** sandbox verification that Square enforces `customer_id` (P12 gate) and the final privacy-policy copy review (still DRAFT-bannered).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|||||||
Reference in New Issue
Block a user