diff --git a/README.md b/README.md index db59baf..1591985 100644 --- a/README.md +++ b/README.md @@ -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) 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. +#### 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 Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/). diff --git a/backend/handlers/admin/today_test.go b/backend/handlers/admin/today_test.go index d6108f4..c40e211 100644 --- a/backend/handlers/admin/today_test.go +++ b/backend/handlers/admin/today_test.go @@ -848,13 +848,17 @@ func TestAdminToday_ExceptionalHours_ClosedDay(t *testing.T) { } // Now seed EXCEPTIONAL hours making today CLOSED. - // Need: group → hours → application with week_start = Monday of this week - weekday := now.Weekday() + // Need: group → hours → application with week_start = Monday of this week. + // 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 if daysSinceMonday < 0 { daysSinceMonday = 6 } - monday := now.AddDate(0, 0, -daysSinceMonday) + monday := londonNow.AddDate(0, 0, -daysSinceMonday) mondayStr := monday.Format("2006-01-02") var groupID int diff --git a/backend/handlers/payments/discounts.go b/backend/handlers/payments/discounts.go new file mode 100644 index 0000000..abcc4cd --- /dev/null +++ b/backend/handlers/payments/discounts.go @@ -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) + } +} diff --git a/backend/handlers/payments/giftcards.go b/backend/handlers/payments/giftcards.go index 91d9c35..e1574e7 100644 --- a/backend/handlers/payments/giftcards.go +++ b/backend/handlers/payments/giftcards.go @@ -75,13 +75,14 @@ type TransferGiftCardRequest struct { } type BuyGiftCardRequest struct { - Amount int64 `json:"amount"` - RecipientType string `json:"recipient_type"` - RecipientEmail string `json:"recipient_email,omitempty"` - CardID *string `json:"card_id,omitempty"` - NewCardToken *string `json:"new_card_token,omitempty"` - SaveCard bool `json:"save_card"` - IdempotencyKey string `json:"idempotency_key"` + Amount int64 `json:"amount"` + RecipientType string `json:"recipient_type"` + RecipientEmail string `json:"recipient_email,omitempty"` + CardID *string `json:"card_id,omitempty"` + NewCardToken *string `json:"new_card_token,omitempty"` + SaveCard bool `json:"save_card"` + IdempotencyKey string `json:"idempotency_key"` + VerificationToken *string `json:"verification_token,omitempty"` } type RedeemGiftCardRequest struct { @@ -895,6 +896,12 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { return } + if err := ValidateVerificationToken(req.VerificationToken); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + paymentService := NewPaymentService() // Serialize gift-card purchase attempts on the idempotency key to prevent @@ -971,7 +978,21 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { var savedCardID *string if req.NewCardToken != nil && *req.NewCardToken != "" { - cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken) + // P14: when the card is being SAVED, provision (or reuse) the user's + // Square customer profile BEFORE tokenizing so the new card is created + // against that customer. One-off non-save charges pass "" — a cnon: + // nonce charge needs no customer. + squareCustomerID := "" + if req.SaveCard { + var custErr error + squareCustomerID, custErr = paymentService.EnsureSquareCustomer(ctx, userID) + if custErr != nil { + log.Printf("Failed to provision Square customer for user %s: %v", userID, custErr) + http.Error(w, "Failed to process card", http.StatusInternalServerError) + return + } + } + cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, *req.NewCardToken, squareCustomerID) if err != nil { log.Printf("Failed to create card on file: %v", err) http.Error(w, "Failed to process card", http.StatusInternalServerError) @@ -1087,13 +1108,19 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } + var verificationToken string + if req.VerificationToken != nil { + verificationToken = *req.VerificationToken + } + paymentReq := square.CreatePaymentReq{ - Amount: req.Amount, - Currency: "GBP", - SourceID: sourceID, - IdempotencyKey: req.IdempotencyKey, - Note: "Gift Card Purchase", - BuyerEmail: buyerEmail, + Amount: req.Amount, + Currency: "GBP", + SourceID: sourceID, + IdempotencyKey: req.IdempotencyKey, + Note: "Gift Card Purchase", + BuyerEmail: buyerEmail, + VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) diff --git a/backend/handlers/payments/handlers.go b/backend/handlers/payments/handlers.go index 20d7b5f..63eae62 100644 --- a/backend/handlers/payments/handlers.go +++ b/backend/handlers/payments/handlers.go @@ -2,13 +2,13 @@ package payments import ( "context" - "crypto/rand" - "crypto/sha256" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" + "crypto/rand" + "crypto/sha256" "database/sql" "encoding/json" "errors" @@ -39,12 +39,13 @@ type CreateTerminalPaymentRequest struct { } type CreateBookingPaymentRequest struct { - Amount int64 `json:"amount" validate:"required,gt=0"` - PaymentType string `json:"payment_type" validate:"required"` - CardID *string `json:"card_id,omitempty"` - NewCardToken *string `json:"new_card_token,omitempty"` - SaveCard bool `json:"save_card"` - IdempotencyKey string `json:"idempotency_key" validate:"required"` + Amount int64 `json:"amount" validate:"required,gt=0"` + PaymentType string `json:"payment_type" validate:"required"` + CardID *string `json:"card_id,omitempty"` + NewCardToken *string `json:"new_card_token,omitempty"` + SaveCard bool `json:"save_card"` + IdempotencyKey string `json:"idempotency_key" validate:"required"` + VerificationToken *string `json:"verification_token,omitempty"` } type RefundRequest struct { @@ -59,11 +60,12 @@ type RefundRequest struct { } type CreateTipPaymentRequest struct { - Amount int64 `json:"amount" validate:"required,gt=0"` - CardID *string `json:"card_id,omitempty"` - NewCardToken *string `json:"new_card_token,omitempty"` - SaveCard bool `json:"save_card"` - IdempotencyKey string `json:"idempotency_key,omitempty"` + Amount int64 `json:"amount" validate:"required,gt=0"` + CardID *string `json:"card_id,omitempty"` + NewCardToken *string `json:"new_card_token,omitempty"` + SaveCard bool `json:"save_card"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + VerificationToken *string `json:"verification_token,omitempty"` } type CheckoutResponse struct { @@ -159,8 +161,9 @@ func GetDiscountPreviewHandler(w http.ResponseWriter, r *http.Request) { } } -// calculateDiscountPreview runs the same queries as applyEligibleCampaignsAtPayment -// but returns the results without inserting any records. +// calculateDiscountPreview runs the same eligibility queries as +// applyEligibleCampaignsAtPayment (via ComputeEligibleDiscounts) but returns +// the results without inserting any records. func calculateDiscountPreview(ctx context.Context, bookingID string, userID string) DiscountPreviewResponse { resp := DiscountPreviewResponse{ Discounts: []DiscountPreview{}, @@ -180,146 +183,17 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri resp.OriginalTotal = bookingTotal discountTotal := 0.0 - var campaignID string - var campaignPercent float64 - var campaignName string - if err := db.Conn.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 != "" { - var exists int - if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists); err != nil { - log.Printf("Failed to scan campaign discount existence: %v", err) - } - if exists == 0 { - amount := roundTo2(bookingTotal * campaignPercent / 100) - resp.Discounts = append(resp.Discounts, DiscountPreview{ - Source: "campaign", - Name: campaignName, - Percent: campaignPercent, - Amount: amount, - }) - discountTotal += amount - } - } - - var userBookingCount int - if err := db.Conn.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 string - var milestonePercent float64 - var milestoneName string - if err := db.Conn.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 discount preview (user %s, count %d): %v", userID, userBookingCount, err) - } - - if milestoneCampaignID != "" { - var exists int - if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists); err != nil { - log.Printf("Failed to scan milestone discount existence: %v", err) - } - if exists == 0 { - amount := roundTo2(bookingTotal * milestonePercent / 100) - resp.Discounts = append(resp.Discounts, DiscountPreview{ - Source: "campaign", - Name: milestoneName, - Percent: milestonePercent, - Amount: amount, - }) - discountTotal += amount - } - } - - var firstVisitDate time.Time - if err := db.Conn.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 := db.Conn.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 { - var exists int - if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists); err != nil { - log.Printf("Failed to scan anniversary discount existence: %v", err) - } - if exists > 0 { - 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 { - amount := roundTo2(bookingTotal * c.pct / 100) - resp.Discounts = append(resp.Discounts, DiscountPreview{ - Source: "campaign", - Name: c.name, - Percent: c.pct, - Amount: amount, - }) - discountTotal += amount - } - } - } - } - - // Check for referrer's unused referral discount - var rdID string - var rdPercent float64 - if err := db.Conn.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 != "" { - exists := 0 - if err := db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists); err != nil { - log.Printf("Failed to scan referral discount existence: %v", err) - } - if exists == 0 { - amount := roundTo2(bookingTotal * rdPercent / 100) - resp.Discounts = append(resp.Discounts, DiscountPreview{ - Source: "referral", - Name: "Referral Discount (10%)", - Percent: rdPercent, - Amount: amount, - }) - discountTotal += amount - } + // Shared with the apply-at-payment path so the preview shows exactly what + // payment will apply — including the global in-person milestone discount + // that was previously only computed at payment time. + for _, d := range ComputeEligibleDiscounts(ctx, db.Conn, bookingID, userID, bookingTotal) { + resp.Discounts = append(resp.Discounts, DiscountPreview{ + Source: d.Source, + Name: d.Name, + Percent: d.Percent, + Amount: d.Amount, + }) + discountTotal += d.Amount } resp.Eligible = len(resp.Discounts) > 0 @@ -738,6 +612,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { Amount: amount, Currency: "GBP", SourceID: card.SquareCardID, + CustomerID: card.SquareCustomerID, IdempotencyKey: scKey, ReferenceID: bookingID, Note: req.PaymentType, @@ -771,9 +646,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } - // For Square checkout (terminal card reader), validate booking status - // and check idempotency. No DB transaction needed since Square handles - // the payment — no DB writes occur until GetCheckoutStatus. + // For Square checkout (terminal card reader), validate booking status. + // No DB transaction is needed for the Square call itself; the in-flight + // guard below serializes checkout creation per booking and records the + // checkout's payment type for GetCheckoutStatus to read back. status, err := service.GetBookingStatus(r.Context(), bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -789,14 +665,36 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } - existingPayment, err := service.CheckIdempotency(r.Context(), bookingID, idempotencyKey) + // Serialize terminal-checkout creation per booking. This is the backend + // half of the double-submit fix: a lost-response retry must not create a + // second live Square checkout for the same booking while the first is in + // flight. + pinConn, err := db.Conn.Acquire(r.Context()) if err != nil { - log.Printf("Failed to check idempotency: %v", err) + log.Printf("Failed to acquire connection for terminal checkout lock: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return } - if existingPayment != nil { + defer pinConn.Release() + if _, err := pinConn.Exec(r.Context(), ` + SELECT pg_advisory_lock(hashtext('crussell:payment:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to acquire terminal checkout serialization lock for %s: %v", bookingID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + defer func() { + if _, err := pinConn.Exec(context.Background(), ` + SELECT pg_advisory_unlock(hashtext('crussell:payment:' || $1)) + `, bookingID); err != nil { + log.Printf("Failed to release terminal checkout serialization lock for %s: %v", bookingID, err) + } + }() + + if existing := activeTerminalCheckoutID(r.Context(), bookingID); existing != "" { if err := json.NewEncoder(w).Encode(CheckoutResponse{ - CheckoutID: existingPayment.ID, - Status: existingPayment.Status, + CheckoutID: existing, + Status: "PENDING", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } @@ -808,7 +706,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { Currency: "GBP", IdempotencyKey: idempotencyKey, ReferenceID: bookingID, - TipEnabled: req.TipEnabled, + AllowTipping: req.TipEnabled, } checkout, err := SquareClient.CreateCheckout(r.Context(), checkoutReq) @@ -818,6 +716,22 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { return } + // Record the checkout with the payment type the admin charged so + // GetCheckoutStatus records the payment with that type. + if _, err := db.Conn.Exec(r.Context(), ` + INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) + VALUES ($1, $2, $3, 'PENDING', $4) + `, checkout.ID, bookingID, req.PaymentType, float64(amount)/100.0); err != nil { + log.Printf("Failed to record terminal checkout %s: %v", checkout.ID, err) + // The checkout is live at Square but untracked — best-effort cancel so + // a customer cannot complete a charge the backend can't record. + if cErr := SquareClient.CancelCheckout(r.Context(), checkout.ID); cErr != nil { + log.Printf("CRITICAL: failed to cancel orphaned terminal checkout %s after the DB insert failed: %v — MANUAL RECONCILIATION REQUIRED: the checkout may still be live at Square", checkout.ID, cErr) + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(CheckoutResponse{ CheckoutID: checkout.ID, Status: checkout.Status, @@ -826,6 +740,64 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) { } } +// activeTerminalCheckoutID returns the checkout_id of an in-flight terminal +// checkout for the booking, or "" if none. Called under the +// crussell:payment: advisory lock. A PENDING/IN_PROGRESS row is +// resolved against Square: an already-completed checkout must not block a new +// charge, while one still live at Square is returned so a lost-response retry +// reuses it instead of creating a second live checkout. +// +// A checkout in a definitively terminal state (CANCELED / CANCEL_REQUESTED, or +// NOT_FOUND for an expired checkout) is ALSO resolved: it can never complete, +// so it must not wedge the booking. Such a checkout surfaces as a GetCheckout +// error (the HTTP client returns an error for any non-COMPLETED, non-PENDING +// status) and would otherwise be treated as "still in flight" forever, blocking +// every future terminal charge on the booking. Only ErrCheckoutPending and +// ambiguous transport errors keep the checkout in flight — a second live +// checkout must never be created while the first one's money state is unknown. +func activeTerminalCheckoutID(ctx context.Context, bookingID string) string { + var checkoutID string + if err := db.Conn.QueryRow(ctx, ` + SELECT checkout_id FROM terminal_checkouts + WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') + ORDER BY created_at ASC LIMIT 1 + `, bookingID).Scan(&checkoutID); err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + log.Printf("Failed to query active terminal checkout for booking %s: %v", bookingID, err) + } + return "" + } + + // Resolve against Square: once the terminal charge finished, the checkout + // is COMPLETED and its payment may already be recorded — it must not + // block a subsequent charge on the same booking. + result, err := SquareClient.GetCheckout(ctx, checkoutID) + if err == nil && result.Status == "COMPLETED" { + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 + `, checkoutID); upErr != nil { + log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, upErr) + } + return "" + } + // A definitively terminal checkout (cancelled / cancel-requested / + // expired-NOT_FOUND) can never complete — mark the row failed and allow a + // new checkout instead of wedging the booking forever. + if isTerminalCheckoutError(err) { + log.Printf("Terminal checkout %s is definitively terminal at Square (%v) — allowing a new checkout for booking %s", checkoutID, err, bookingID) + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1 + `, checkoutID); upErr != nil { + log.Printf("Failed to mark terminal checkout %s failed after terminal state: %v", checkoutID, upErr) + } + return "" + } + // ErrCheckoutPending or any ambiguous error: treat the checkout as still + // in flight. Never create a second live checkout while the first one's + // money state at Square is unknown. + return checkoutID +} + func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { // Defense-in-depth admin check (S-1) — terminal completion records a // payment, so it must stay admin-only. @@ -952,9 +924,53 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to check for existing payment: %v", err) } + // Re-check the booking status after the advisory lock: a concurrent + // cancellation/eviction can move the booking out of a payable state + // between the terminal charge completing and this poll recording it. A + // charge landing on a cancelled/lapsed/no-show booking must not be + // recorded as a completed payment — the cancellation refund path + // computes refunds from completed payments and would silently exclude + // this charge. Mark the checkout failed and alert ops: money was taken + // at Square and MUST be refunded manually (mirrors CreateBookingPayment's + // post-charge recheck). + var recheckStatus string + if err := tx.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&recheckStatus); err != nil { + log.Printf("CRITICAL: Square payment %s for checkout %s was processed but re-reading booking %s status failed: %v — manual reconciliation required", + paymentResult.SquarePayID, checkoutID, bookingID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !bookingStatusAllowsCompletedPayment(recheckStatus) { + log.Printf("CRITICAL: Square payment %s for checkout %s was processed but booking %s is now %q — marking checkout failed; money taken at Square MUST be refunded manually", + paymentResult.SquarePayID, checkoutID, bookingID, recheckStatus) + if _, upErr := tx.Exec(r.Context(), `UPDATE terminal_checkouts SET status = 'failed', updated_at = NOW() WHERE checkout_id = $1`, checkoutID); upErr != nil { + log.Printf("CRITICAL: Square payment %s landed on %q booking %s but marking checkout %s failed errored: %v — manual reconciliation required", + paymentResult.SquarePayID, recheckStatus, bookingID, checkoutID, upErr) + } + if cErr := tx.Commit(r.Context()); cErr != nil { + log.Printf("CRITICAL: Square payment %s landed on %q booking %s and committing the checkout-failed mark errored: %v — manual reconciliation required", + paymentResult.SquarePayID, recheckStatus, bookingID, cErr) + } + http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) + return + } + + // The payment type the admin charged is recorded on the checkout row + // by CreateTerminalPayment. Fall back to 'full' for legacy checkouts + // created before that record existed. + var checkoutPaymentType string + if err := tx.QueryRow(r.Context(), ` + SELECT payment_type FROM terminal_checkouts WHERE checkout_id = $1 + `, checkoutID).Scan(&checkoutPaymentType); err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + log.Printf("Failed to read payment type for checkout %s: %v", checkoutID, err) + } + checkoutPaymentType = "full" + } + record := PaymentRecord{ BookingID: bookingID, - PaymentType: "full", + PaymentType: checkoutPaymentType, PaymentMethod: "in_person_card", Status: "completed", Amount: float64(paymentResult.Amount) / 100.0, @@ -973,6 +989,14 @@ func GetCheckoutStatus(w http.ResponseWriter, r *http.Request) { } ApplyVATToBookingPayment(r.Context(), tx, paymentID) + // Release the in-flight guard: this checkout is done, so a subsequent + // charge on the same booking is allowed. + if _, err := tx.Exec(r.Context(), ` + UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() WHERE checkout_id = $1 + `, checkoutID); err != nil { + log.Printf("Failed to mark terminal checkout %s completed: %v", checkoutID, err) + } + if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) @@ -1008,6 +1032,21 @@ func IsValidBookingStatusForPayment(status string) bool { } } +// bookingStatusAllowsCompletedPayment reports whether a charge that already +// went through Square can still be recorded as a completed payment. It differs +// from IsValidBookingStatusForPayment: a booking that legitimately completed +// ('completed') must still accept the recorded payment, while a cancelled, +// lapsed, or no-show booking must NOT — the money would bypass the +// cancellation refund system, which computes refunds from completed payments. +func bookingStatusAllowsCompletedPayment(status string) bool { + switch status { + case "confirmed", "pending", "pending_release", "in_progress", "completed": + return true + default: + return false + } +} + func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -1058,6 +1097,12 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { return } + if err := ValidateVerificationToken(req.VerificationToken); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + service := NewPaymentService() if req.PaymentType == "partial" { @@ -1246,6 +1291,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { var sourceID string var savedCardID *string + var savedCardCustomerID string if req.NewCardToken != nil && *req.NewCardToken != "" { // CreateCardOnFile runs before the charge. If the subsequent payment @@ -1253,7 +1299,22 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // record's retry re-creates it via the deterministic sha256 idempotency // key, and Square returns the same card — deleting it would break that // retry. The orphan is harmless (Square-side only, never charged). - cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), 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 = service.EnsureSquareCustomer(r.Context(), 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(r.Context(), userID, *req.NewCardToken, squareCustomerID) if err != nil { log.Printf("Failed to create card on file: %v", err) http.Error(w, "Failed to process card", http.StatusInternalServerError) @@ -1286,6 +1347,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } sourceID = card.SquareCardID savedCardID = req.CardID + savedCardCustomerID = card.SquareCustomerID } // If there is no pending record to reuse, insert one NOW and commit the @@ -1335,14 +1397,21 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // Step 2: DB transaction committed — safe to call Square now. If Square // fails, the record stays 'pending' and a same-key retry reuses it. + var verificationToken string + if req.VerificationToken != nil { + verificationToken = *req.VerificationToken + } + paymentReq := square.CreatePaymentReq{ - Amount: req.Amount, - Currency: "GBP", - SourceID: sourceID, - IdempotencyKey: req.IdempotencyKey, - ReferenceID: bookingID, - Note: req.PaymentType, - BuyerEmail: bookingBuyerEmail, + Amount: req.Amount, + Currency: "GBP", + SourceID: sourceID, + CustomerID: savedCardCustomerID, + IdempotencyKey: req.IdempotencyKey, + ReferenceID: bookingID, + Note: req.PaymentType, + BuyerEmail: bookingBuyerEmail, + VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq) @@ -1372,6 +1441,38 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { } }() + // Re-check the booking status under the advisory lock: a concurrent + // cancellation/eviction can move the booking out of a payable state between + // the pending commit (step 1) and the Square charge completing. A charge + // landing on a cancelled/lapsed/no-show booking must not be recorded as a + // completed payment — the cancellation refund path computes refunds from + // completed payments and would silently exclude this deposit. Mark it + // failed and alert ops: money was taken at Square and MUST be refunded + // manually. The pending row is marked 'failed' in the tx below, so + // idempotency dedup still blocks a second Square charge, but the row no + // longer shows pending — the frontend's retry gets a 409 Conflict. + var recheckStatus string + if err := tx2.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&recheckStatus); err != nil { + log.Printf("CRITICAL: Square payment %s (ID=%s) was processed but re-reading booking %s status failed: %v — manual reconciliation required", + paymentResult.Status, paymentResult.SquarePayID, bookingID, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !bookingStatusAllowsCompletedPayment(recheckStatus) { + log.Printf("CRITICAL: Square payment %s (ID=%s) for booking %s was processed but booking is now %q — marking payment failed; money taken at Square MUST be refunded manually", + paymentResult.Status, paymentResult.SquarePayID, bookingID, recheckStatus) + if _, upErr := tx2.Exec(r.Context(), `UPDATE payments SET status = 'failed' WHERE id = $1`, paymentID); upErr != nil { + log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s but marking payment %s failed errored: %v — manual reconciliation required", + paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, paymentID, upErr) + } + if cErr := tx2.Commit(r.Context()); cErr != nil { + log.Printf("CRITICAL: Square payment %s (ID=%s) landed on %q booking %s and committing the failed mark errored: %v — manual reconciliation required", + paymentResult.Status, paymentResult.SquarePayID, recheckStatus, bookingID, cErr) + } + http.Error(w, "This booking is no longer accepting payments", http.StatusConflict) + return + } + // Build payment records — may split a single Square charge into // a deposit portion (up to 50% of booking total) plus a balance // portion, so the refund system can correctly track deposit vs @@ -1457,6 +1558,8 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // Promote deposit to confirmed if total paid meets the 20% threshold. // Check is inside the transaction so it sees the just-completed primary. + // Tip rows are excluded (they are gratuity, not payment toward the booking) + // as are discount/on_the_house rows (no real money moved). var depositMet bool if err := tx2.QueryRow(r.Context(), ` WITH booking_total AS ( @@ -1466,6 +1569,8 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { SELECT COALESCE(SUM(amount), 0) * 100 AS paid_cents FROM payments WHERE booking_id = $1 AND status = 'completed' + AND payment_type != 'tip' + AND payment_method NOT IN ('discount', 'on_the_house') ) SELECT pt.paid_cents >= ROUND(bt.total_cents * 0.2) FROM booking_total bt, paid_total pt @@ -1517,20 +1622,6 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) { // prevents applying new discounts after a customer has already paid, which // would create a credit balance or require a refund. func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingID string, userID string) { - 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) - } - // Only block if this is the 2nd+ real payment — the first payment should still - // trigger discount application (existingPayment counts already-completed payments - // visible within the transaction, including the just-inserted one). - if existingPayment >= 2 { - return - } - var bookingTotal float64 if err := q.QueryRow(ctx, ` SELECT total_amount FROM bookings WHERE id = $1 @@ -1538,247 +1629,9 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, q db.Querier, bookingI log.Printf("Failed to calculate booking total for campaign check: %v", err) return } - if bookingTotal <= 0 { - return - } - var campaignID string - var campaignPercent float64 - if err := q.QueryRow(ctx, ` - SELECT id, discount_percent 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); err == nil && campaignID != "" { - var exists int - if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists); err != nil { - log.Printf("Failed to scan time-based campaign discount existence: %v", err) - } - if exists == 0 { - discountAmount := roundTo2(bookingTotal * campaignPercent / 100) - 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, 'time_based', NULL, $4, $5, $6) - `, bookingID, userID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { - log.Printf("Failed to insert time-based campaign discount: %v", err) - } else { - 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, discountAmount, userID); err != nil { - log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", campaignID, bookingID, err) - } - if _, err := q.Exec(ctx, ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, campaignID); err != nil { - log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", campaignID, bookingID, err) - } - } - } - } - - 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 booking count: %v", err) - } - - var milestoneCampaignID string - var milestonePercent float64 - if err := q.QueryRow(ctx, ` - SELECT id, discount_percent 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); err != nil { - log.Printf("Failed to query per-user milestone campaign: %v", err) - } - - if milestoneCampaignID != "" { - var exists int - if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists); err != nil { - log.Printf("Failed to scan milestone campaign discount existence: %v", err) - } - if exists == 0 { - discountAmount := roundTo2(bookingTotal * milestonePercent / 100) - 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, 'milestone', 'per_user_booking_count', $4, $5, $6) - `, bookingID, userID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { - log.Printf("Failed to insert per-user milestone discount: %v", err) - } else { - 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, discountAmount, userID); err != nil { - log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err) - } - if _, err := q.Exec(ctx, ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, milestoneCampaignID); err != nil { - log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", milestoneCampaignID, bookingID, err) - } - } - } - } - - 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() { - annRows, err := q.Query(ctx, ` - SELECT id, discount_percent, milestone_value, milestone_unit 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 { - type annCampaign struct { - id string - pct float64 - value int - unit string - } - var campaigns []annCampaign - for annRows.Next() { - var c annCampaign - if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil { - campaigns = append(campaigns, c) - } - } - annRows.Close() - - for _, c := range campaigns { - var exists int - if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists); err != nil { - log.Printf("Failed to scan anniversary discount existence: %v", err) - } - if exists > 0 { - continue - } - - var matches bool - elapsed := time.Since(firstVisitDate) - switch c.unit { - case "months": - months := int(elapsed.Hours() / (30 * 24)) - matches = months >= c.value - case "years": - years := int(elapsed.Hours() / (365.25 * 24)) - matches = years >= c.value - } - if matches { - discountAmount := roundTo2(bookingTotal * c.pct / 100) - 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, 'milestone', 'anniversary', $4, $5, $6) - `, bookingID, userID, c.id, c.pct, bookingTotal, discountAmount); err != nil { - log.Printf("Failed to insert anniversary discount: %v", err) - } else { - 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, discountAmount, userID); err != nil { - log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", c.id, bookingID, err) - } - if _, err := q.Exec(ctx, ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, c.id); err != nil { - log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", c.id, bookingID, err) - } - } - break - } - } - } else { - log.Printf("Failed to query anniversary campaigns: %v", err) - } - } - - 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 string - var globalPercent float64 - if err := q.QueryRow(ctx, ` - SELECT id, discount_percent 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); err != nil { - log.Printf("Failed to query global milestone campaign: %v", err) - } - - if globalCampaignID != "" { - var exists int - if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, globalCampaignID).Scan(&exists); err != nil { - log.Printf("Failed to scan global campaign discount existence: %v", err) - } - if exists == 0 { - discountAmount := roundTo2(bookingTotal * globalPercent / 100) - 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, 'milestone', 'global_booking_count', $4, $5, $6) - `, bookingID, userID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { - log.Printf("Failed to insert global milestone discount: %v", err) - } else { - 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, discountAmount, userID); err != nil { - log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", globalCampaignID, bookingID, err) - } - if _, err := q.Exec(ctx, ` - UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, globalCampaignID); err != nil { - log.Printf("ALERT: failed to increment times_redeemed for campaign %s, booking %s: %v", globalCampaignID, bookingID, err) - } - } - } - } - } - - // Apply referrer's referral discount if available - if bookingTotal > 0 { - 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 != "" { - exists := 0 - if err := q.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists); err != nil { - log.Printf("Failed to scan referral discount existence: %v", err) - } - if exists == 0 { - discountAmount := roundTo2(bookingTotal * rdPercent / 100) - 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, rdID, rdPercent, bookingTotal, discountAmount); err == nil { - 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, discountAmount, userID); err != nil { - log.Printf("ALERT: failed to insert discount payment record for campaign %s, booking %s: %v", rdID, bookingID, err) - } - if _, err := q.Exec(ctx, ` - UPDATE referral_discounts SET used = TRUE, used_at = NOW() WHERE id = $1 - `, rdID); err != nil { - log.Printf("ALERT: failed to mark referral discount as used, booking %s: %v", bookingID, err) - } - } - } - } + for _, d := range ComputeEligibleDiscounts(ctx, q, bookingID, userID, bookingTotal) { + ApplyEligibleDiscount(ctx, q, bookingID, userID, bookingTotal, d) } } @@ -2577,6 +2430,12 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } + if err := ValidateVerificationToken(req.VerificationToken); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + service := NewPaymentService() bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID) @@ -2595,6 +2454,22 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { return } + // Tips are only accepted on active bookings. A cancelled, lapsed, or + // no-show booking must not accept tips — money would land on a booking + // that can no longer pay out the service. Checked early, before any card + // resolution or Square call. + bookingStatus, err := service.GetBookingStatus(r.Context(), bookingID) + if err != nil { + log.Printf("Failed to get booking status: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !bookingStatusAllowsCompletedPayment(bookingStatus) { + log.Printf("Tip rejected: booking %s is in status %q (no longer accepting tips)", bookingID, bookingStatus) + http.Error(w, "This booking is no longer accepting tips", http.StatusConflict) + return + } + hasCompleted, err := service.HasCompletedPayment(r.Context(), bookingID) if err != nil { log.Printf("Failed to check for completed payments: %v", err) @@ -2619,6 +2494,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { // Resolve the card source ID — same pattern as CreateBookingPayment. var sourceID string var savedCardID *string + var savedCardCustomerID string if req.NewCardToken != nil && *req.NewCardToken != "" { // CreateCardOnFile runs before the charge. If the subsequent payment @@ -2626,7 +2502,22 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { // record's retry re-creates it via the deterministic sha256 idempotency // key, and Square returns the same card — deleting it would break that // retry. The orphan is harmless (Square-side only, never charged). - cardOnFile, err := SquareClient.CreateCardOnFile(r.Context(), 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 = service.EnsureSquareCustomer(r.Context(), 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(r.Context(), userID, *req.NewCardToken, squareCustomerID) if err != nil { log.Printf("Failed to create card on file: %v", err) http.Error(w, "Failed to process card", http.StatusInternalServerError) @@ -2659,6 +2550,7 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { } sourceID = card.SquareCardID savedCardID = req.CardID + savedCardCustomerID = card.SquareCustomerID } // Serialize tip attempts for this booking to prevent concurrent duplicate @@ -2766,17 +2658,17 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { if !reusePendingRecord { record := PaymentRecord{ - BookingID: bookingID, - PaymentType: "tip", - PaymentMethod: "online_square", - Status: "pending", - Amount: float64(req.Amount) / 100.0, - IdempotencyKey: &idempotencyKey, - Fees: 0, + BookingID: bookingID, + PaymentType: "tip", + PaymentMethod: "online_square", + Status: "pending", + Amount: float64(req.Amount) / 100.0, + IdempotencyKey: &idempotencyKey, + Fees: 0, UserSavedCardID: savedCardID, - CreatedAt: clock.Now(), - UpdatedAt: clock.Now(), - CreatedBy: &userID, + CreatedAt: clock.Now(), + UpdatedAt: clock.Now(), + CreatedBy: &userID, } paymentID, err = service.CreatePaymentRecordTx(r.Context(), tx, record, nil) @@ -2809,14 +2701,21 @@ func CreateTipPayment(w http.ResponseWriter, r *http.Request) { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } + var verificationToken string + if req.VerificationToken != nil { + verificationToken = *req.VerificationToken + } + paymentReq := square.CreatePaymentReq{ - Amount: req.Amount, - Currency: "GBP", - SourceID: sourceID, - IdempotencyKey: idempotencyKey, - ReferenceID: bookingID, - Note: "tip", - BuyerEmail: buyerEmail, + Amount: req.Amount, + Currency: "GBP", + SourceID: sourceID, + CustomerID: savedCardCustomerID, + IdempotencyKey: idempotencyKey, + ReferenceID: bookingID, + Note: "tip", + BuyerEmail: buyerEmail, + VerificationToken: verificationToken, } paymentResult, err := SquareClient.CreatePayment(r.Context(), paymentReq) @@ -2867,7 +2766,14 @@ func GetBookingPaymentSummary(w http.ResponseWriter, r *http.Request) { service := NewPaymentService() - if userRole != "admin" && userID != "" { + // Fail closed: a non-admin request must carry a user ID. The previous + // `userID != ""` guard silently skipped the ownership check for requests + // with no user context, leaking another user's payment summary. + if userRole != "admin" { + if userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } bookingUserID, err := service.GetBookingUserID(r.Context(), bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -3040,6 +2946,28 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) { return } + // Mirror AcquirePaymentLock's ownership check: releasing another user's + // PAYMENT_IN_FLIGHT blocker would evict their slot mid-payment. The + // booking's own user (or an admin) may release it. + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + userRole, _ := r.Context().Value(mw.UserRoleKey).(string) + var bookingUserID string + if err := db.Conn.QueryRow(r.Context(), + "SELECT user_id FROM bookings WHERE id = $1", bookingID, + ).Scan(&bookingUserID); err != nil { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + if userRole != "admin" && bookingUserID != userID { + http.Error(w, "Unauthorized", http.StatusForbidden) + return + } + tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) diff --git a/backend/handlers/payments/p14_payment_fixes_test.go b/backend/handlers/payments/p14_payment_fixes_test.go new file mode 100644 index 0000000..665de32 --- /dev/null +++ b/backend/handlers/payments/p14_payment_fixes_test.go @@ -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") +} diff --git a/backend/handlers/payments/payments_fixes_test.go b/backend/handlers/payments/payments_fixes_test.go new file mode 100644 index 0000000..06f3395 --- /dev/null +++ b/backend/handlers/payments/payments_fixes_test.go @@ -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) + } +} diff --git a/backend/handlers/payments/payments_review_fixes_test.go b/backend/handlers/payments/payments_review_fixes_test.go new file mode 100644 index 0000000..334e5ec --- /dev/null +++ b/backend/handlers/payments/payments_review_fixes_test.go @@ -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") +} diff --git a/backend/handlers/payments/payments_test.go b/backend/handlers/payments/payments_test.go index 7171831..ac4c6c1 100644 --- a/backend/handlers/payments/payments_test.go +++ b/backend/handlers/payments/payments_test.go @@ -424,7 +424,7 @@ func TestOnlinePayment_SavedCard(t *testing.T) { userID, bookingID, _ := setupTestData(t, ctx, tx) - cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_mock_card_123", "VISA", "4242") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") if err != nil { t.Fatalf("failed to create payment method: %v", err) } @@ -2360,7 +2360,7 @@ func TestTipPayment_WithSavedCard(t *testing.T) { var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) - VALUES ($1, 'ccof_mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved') + VALUES ($1, 'ccof:mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) @@ -2401,7 +2401,7 @@ func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) { var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) - VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') + VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) @@ -2458,7 +2458,7 @@ func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) { var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) - VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') + VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) @@ -2510,7 +2510,7 @@ func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) { var savedCardID string err = tx.QueryRow(ctx, ` INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint) - VALUES ($1, 'ccof_mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') + VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry') RETURNING id `, userID).Scan(&savedCardID) require.NoError(t, err) diff --git a/backend/handlers/payments/refunds.go b/backend/handlers/payments/refunds.go index 5203669..a47a62e 100644 --- a/backend/handlers/payments/refunds.go +++ b/backend/handlers/payments/refunds.go @@ -98,10 +98,6 @@ func CalculateRefundForCancellation( } } -// ProcessCancellationRefund calculates and records refunds for a cancelled booking. -// It processes refunds against completed payments on the booking up to the -// calculated refundable amount, creating refund records in the database. -// Returns the refund calculation and whether any refunds were processed. // lockCancellationPayments serializes a cancellation refund against the manual // RefundPayment handler and the sweep. Both hold // `pg_advisory_lock(hashtext('crussell:refund:' || payment_id))` (session-level) @@ -211,6 +207,20 @@ func ProcessCancellationRefundTx( // Prior refunds per payment record (completed + pending) — the loop must // not re-refund money already returned. Sums by payment_id; pending counts // because a Square call may already be in flight. + // + // This DB-side over-refund guard (completed + pending) is what prevents + // Square's REFUND_AMOUNT_INVALID in practice: a refund is never issued past + // the residual `amount - already`. Both this cancellation path and the + // manual RefundPayment handler compute residuals while holding the same + // advisory lock (`hashtext('crussell:refund:' || payment_id)` — see + // lockCancellationPayments), so a manual refund cannot slip past the guard + // and a cancellation refund cannot be recorded after the manual guard ran + // without the two serializing. Square no longer documents + // PAYMENT_ALREADY_REFUNDED; the realistic already-refunded response is + // REFUND_AMOUNT_INVALID, which the client maps to ErrRefundDeclined + // (definitive) — the charge-group/manual sweep handlers fail those rows and + // surface them via admin_notification rather than silently blocking the + // amount in the guard. priorRefunds := make(map[string]float64) prRows, prErr := tx.Query(ctx, ` SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds @@ -368,6 +378,14 @@ func ProcessCancellationRefundTx( return &calc, nil } +// ProcessCancellationRefund calculates and records refunds for a cancelled +// booking, processing refunds against the booking's completed payments up to +// the calculated refundable amount. The wrapper owns its own transaction and +// delegates the refund loop to ProcessCancellationRefundTx +// (forceFullRefund=false) so the standalone and in-transaction callers share +// one implementation; after a successful commit it runs the post-commit +// Square pass (ProcessPendingSquareRefunds). Returns the refund calculation +// and whether any refunds were processed. func ProcessCancellationRefund( ctx context.Context, bookingID string, @@ -395,215 +413,15 @@ func ProcessCancellationRefund( } }() - // Get the booking's user info for refund routing. - var bookingUserID string - var isGuest bool - if err := tx.QueryRow(ctx, ` - SELECT b.user_id, COALESCE(u.account_role = 'guest', false) - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - WHERE b.id = $1 - `, bookingID).Scan(&bookingUserID, &isGuest); err != nil { - log.Printf("Failed to get booking user info for refund: %v", err) - // Non-fatal — we'll still process Square refunds but skip balance credits. - } - - rows, err := tx.Query(ctx, ` - SELECT id, amount, payment_method, square_payment_id, gift_card_id - FROM payments - WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') - ORDER BY created_at ASC - `, bookingID) - if err != nil { - log.Printf("Failed to fetch payments for refund: %v", err) - return &calc, nil - } - defer rows.Close() - - // Read all payments into a slice, then close rows immediately. - // This avoids "conn busy" errors when db.Conn.QueryRow/Exec are called - // inside the processing loop with a per-test transaction (pgx.Tx does not - // support concurrent queries on the same connection). - var payments []paymentRow - for rows.Next() { - var p paymentRow - if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil { - log.Printf("Failed to scan payment row: %v", err) - continue - } - payments = append(payments, p) - } - if err := rows.Err(); err != nil { - log.Printf("Payment row iteration error: %v", err) - } - - // Serialize against the manual RefundPayment handler and the sweep. If any - // lock fails, log + return the error — the caller (bookings.go - // DeleteBookingHandler) aborts the cancellation so the user can retry; - // continuing without the lock reopens the over-refund race. - if err := lockCancellationPayments(ctx, tx, payments); err != nil { - log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, err) - return &calc, err - } - - refundRemaining := calc.RefundableAmount - - // Prior refunds per payment record (completed + pending) — the loop must - // not re-refund money already returned. Sums by payment_id; pending counts - // because a Square call may already be in flight. - priorRefunds := make(map[string]float64) - prRows, prErr := tx.Query(ctx, ` - SELECT payment_id, COALESCE(SUM(amount), 0) FROM refunds - WHERE booking_id = $1 AND status IN ('completed', 'pending') - GROUP BY payment_id`, bookingID) - if prErr != nil { - log.Printf("Failed to query prior refunds for booking %s: %v", bookingID, prErr) - } else { - for prRows.Next() { - var pid string - var amt float64 - if err := prRows.Scan(&pid, &amt); err == nil { - priorRefunds[pid] = amt - } - } - prRows.Close() - } - - for _, p := range payments { - if refundRemaining <= 0 { - break - } - - paymentID := p.ID - paymentMethod := p.PaymentMethod - amount := p.Amount - giftCardID := p.GiftCardID - - already := priorRefunds[paymentID] - residual := math.Round((amount-already)*100) / 100 - if residual <= 0 { - // already fully refunded — don't consume refundRemaining - continue - } - refundThisPayment := math.Min(residual, refundRemaining) - var squareRefundID *string - - switch paymentMethod { - case "online_square", "in_person_card": - // Square API refund is processed AFTER the transaction commits - // (see ProcessPendingSquareRefunds). Inside the tx we only record - // the refund record as "pending" for post-commit processing. - if isGuest || bookingUserID == "" { - log.Printf("Guest card refund: booking %s, payment %s, amount £%.2f — will be processed after commit", bookingID, paymentID, refundThisPayment) - } - // squareRefundID stays nil — will be set by ProcessPendingSquareRefunds - - case "giftcard": - if giftCardID == nil || *giftCardID == "" { - log.Printf("Giftcard payment %s has no gift_card_id — cannot refund to card. Skipping.", paymentID) - break - } - var expired bool - if err := tx.QueryRow(ctx, ` - SELECT expiry_date IS NOT NULL AND expiry_date < NOW() - FROM gift_cards WHERE id = $1 - `, *giftCardID).Scan(&expired); err != nil { - log.Printf("Failed to check gift card %s expiry: %v — proceeding with refund", *giftCardID, err) - } else if expired { - log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID) - break - } - if _, err := tx.Exec(ctx, ` - UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW() - WHERE id = $2 - `, refundThisPayment, *giftCardID); err != nil { - log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err) - break - } - if _, err := tx.Exec(ctx, ` - INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) - VALUES ($1, 'refund', $2, 'booking', $3, $4, $5) - `, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil { - log.Printf("Failed to create gift card transaction for refund: %v", err) - } - - case "cash": - if isGuest || bookingUserID == "" { - log.Printf("Guest cash refund: booking %s, payment %s, amount £%.2f — admin must process cash refund at till", bookingID, paymentID, refundThisPayment) - } else { - log.Printf("Crediting £%.2f to user %s balance for cash payment %s", refundThisPayment, bookingUserID, paymentID) - if _, balErr := tx.Exec(ctx, ` - INSERT INTO user_giftcard_balances (user_id, balance, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (user_id) DO UPDATE SET - balance = user_giftcard_balances.balance + EXCLUDED.balance, - updated_at = NOW() - `, bookingUserID, refundThisPayment); balErr != nil { - log.Printf("Failed to credit user %s balance for refund of booking %s: %v", bookingUserID, bookingID, balErr) - } - } - - default: - // discount, on_the_house — no real money to refund. - log.Printf("Skipping refund for payment %s with method %q (no money exchanged)", paymentID, paymentMethod) - } - - recordStatus := "completed" - if paymentMethod == "online_square" || paymentMethod == "in_person_card" { - recordStatus = "pending" - } - record := RefundRecord{ - PaymentID: paymentID, - BookingID: bookingID, - Amount: refundThisPayment, - SquareRefundID: squareRefundID, - Status: recordStatus, - Reason: reason, - Origin: "cancellation", - CreatedBy: actorID, - CreatedAt: clock.Now(), - } - - // Deterministic idempotency key so a scheduler retry can never issue a - // second Square refund. Format never collides with the handler's - // "-refund-" keys. - refundKey := paymentID + "-square-" + strconv.FormatInt(int64(math.Round(refundThisPayment*100)), 10) - record.IdempotencyKey = &refundKey - - tag, dbErr := tx.Exec(ctx, ` - INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (idempotency_key) DO NOTHING - `, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Status, record.Reason, record.IdempotencyKey, record.CreatedBy, record.CreatedAt, record.Origin) - if dbErr != nil { - log.Printf("Failed to create refund record for payment %s: %v", paymentID, dbErr) - continue - } - // tag.RowsAffected() == 0 means the same idempotency_key already exists - // (a prior refund row for this payment+amount in 'failed' state — money - // never moved, but a row exists). Dedup — skip WITHOUT consuming - // refundRemaining so the loop can allocate to the next payment, exactly - // as the pre-ON-CONFLICT UNIQUE-violation path behaved. - if tag.RowsAffected() == 0 { - log.Printf("Refund for payment %s amount £%.2f already exists (idempotency dedup) — skipping without consuming refundRemaining", paymentID, refundThisPayment) - continue - } - - refundRemaining -= refundThisPayment - } - - if bookingUserID != "" { - var loyaltyUsed bool - if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed); err != nil { - log.Printf("Failed to check loyalty stamp refund for booking %s: %v", bookingID, err) - } else if loyaltyUsed { - _, loyaltyErr := tx.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID) - if loyaltyErr != nil { - log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, loyaltyErr) - } else { - log.Printf("Refunded %d loyalty stamps to user %s after cancellation of booking %s", LoyaltyStampCost, bookingUserID, bookingID) - } - } + // Delegate the whole refund loop to the transactional variant — the + // non-tx wrapper exists only to own the transaction lifecycle and fire the + // post-commit Square pass (ProcessPendingSquareRefunds) after a successful + // commit. The Tx variant returns an error ONLY on lock failure; every other + // failure logs internally and returns (calc, nil). + res, txErr := ProcessCancellationRefundTx(ctx, tx, bookingID, subtotal, totalPrePaid, startTime, cancellationTime, reason, actorID, false) + if txErr != nil { + log.Printf("Failed to acquire cancellation refund locks for booking %s: %v", bookingID, txErr) + return &calc, txErr } if cErr := tx.Commit(ctx); cErr != nil { @@ -615,7 +433,7 @@ func ProcessCancellationRefund( // This ensures Square API calls only happen if the DB records persist. ProcessPendingSquareRefunds(ctx, bookingID, reason) - return &calc, nil + return res, nil } // ProcessPendingSquareRefunds resolves a booking's pending cancellation card @@ -1166,8 +984,13 @@ func idsOf(rows []pendingChargeRow) []string { return ids } -// manualPendingRow is one stale manual refund row (the handler's ambiguous-error -// path) eligible for retry by the sweep. +// manualPendingRow is one stale manual refund row eligible for the sweep's +// resolution. It covers BOTH pending shapes the RefundPayment handler can +// leave behind: +// - square_refund_id set: the handler's synchronous-PENDING response (Square +// already holds the refund, status='pending') — reconciled, never re-issued. +// - square_refund_id NULL: the handler's ambiguous-error path — re-issued +// with the row's OWN stored idempotency key. type manualPendingRow struct { ID string PaymentID string @@ -1175,23 +998,27 @@ type manualPendingRow struct { IdempotencyKey string Reason string SquarePaymentID string + SquareRefundID string // set when the handler's synchronous-PENDING path stored the refund id CreatedAt time.Time } // sweepManualPendingSquareRefunds retries stale MANUAL refunds left 'pending' -// by the RefundPayment handler's ambiguous-error path. The cancellation passes -// filter origin='cancellation', so manual rows were never re-attempted: they +// by the RefundPayment handler. The cancellation passes filter +// origin='cancellation', so manual rows were never re-attempted: they // permanently blocked the over-refund guard and depressed booking TotalPaid. -// Each row is retried with its OWN stored idempotency key (Square dedups -// same-key retries, so the retry is idempotent). +// Rows are split per-row by their stored square_refund_id: rows WITH one (the +// handler's synchronous-PENDING response) are reconciled at Square — never +// re-issued; rows WITHOUT one (the ambiguous-error path) are re-issued with +// their OWN stored idempotency key (Square dedups same-key retries, so the +// retry is idempotent). func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { rows, err := db.Conn.Query(ctx, ` SELECT r.id, r.payment_id, r.amount, r.idempotency_key, r.reason, - p.square_payment_id, r.created_at + p.square_payment_id, r.square_refund_id, r.created_at FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE r.status = 'pending' AND r.origin = 'manual' - AND r.refund_attempts < 3 AND r.square_refund_id IS NULL + AND r.refund_attempts < 3 AND p.square_payment_id IS NOT NULL ORDER BY r.payment_id, r.id `) @@ -1203,13 +1030,17 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) { for rows.Next() { var pr manualPendingRow var key *string - if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &pr.CreatedAt); err != nil { + var sqRefundID *string + if err := rows.Scan(&pr.ID, &pr.PaymentID, &pr.Amount, &key, &pr.Reason, &pr.SquarePaymentID, &sqRefundID, &pr.CreatedAt); err != nil { log.Printf("Failed to scan manual pending refund: %v", err) continue } if key != nil { pr.IdempotencyKey = *key } + if sqRefundID != nil { + pr.SquareRefundID = *sqRefundID + } pending = append(pending, pr) } rows.Close() @@ -1273,7 +1104,7 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man // cap are eligible (a concurrent manual refund may have resolved some). prRows, err := db.Conn.Query(ctx, ` SELECT r.id, r.amount, r.idempotency_key, r.reason, r.created_at, - r.payment_id, p.square_payment_id + r.payment_id, p.square_payment_id, r.square_refund_id FROM refunds r JOIN payments p ON p.id = r.payment_id WHERE r.id = ANY($1) AND r.status = 'pending' AND r.refund_attempts < 3 @@ -1286,13 +1117,17 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man for prRows.Next() { var pr manualPendingRow var key *string - if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID); err != nil { + var sqRefundID *string + if err := prRows.Scan(&pr.ID, &pr.Amount, &key, &pr.Reason, &pr.CreatedAt, &pr.PaymentID, &pr.SquarePaymentID, &sqRefundID); err != nil { log.Printf("Failed to scan manual pending refund under lock: %v", err) continue } if key != nil { pr.IdempotencyKey = *key } + if sqRefundID != nil { + pr.SquareRefundID = *sqRefundID + } pending = append(pending, pr) } prRows.Close() @@ -1350,6 +1185,42 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man for i := range pending { pr := &pending[i] amountCents := int64(math.Round(pr.Amount * 100)) + + // Row WITH a stored square_refund_id — the RefundPayment handler's + // synchronous-PENDING response. Square already holds the refund, so a + // re-issue would risk a SECOND refund (Square's key dedup does not + // protect a fresh key). Reconcile instead: an exact COMPLETED refund at + // Square resolves the row; a genuine no-match means Square never + // recorded it → failed + admin notification; a reconcile error is an + // UNKNOWN state → leave pending (never mark failed on an unknown state, + // that would let the over-refund guard exclude money that may have + // moved). Mirrors the 23h age-guard branch above. + if pr.SquareRefundID != "" { + sqRefundID, rcErr := reconcileRefundAtSquare(ctx, pr.SquarePaymentID, amountCents, pr.CreatedAt) + switch { + case rcErr != nil: + log.Printf("Reconcile failed for pending manual refund %s (square_refund_id %s, %v) — leaving pending for the next sweep", pr.ID, pr.SquareRefundID, rcErr) + case sqRefundID != nil: + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE refunds SET status = 'completed', square_refund_id = $1 + WHERE id = $2 AND status = 'pending' + `, *sqRefundID, pr.ID); upErr != nil { + log.Printf("Failed to mark manual refund %s completed after Square reconcile: %v", pr.ID, upErr) + } + processed++ + default: + if _, upErr := db.Conn.Exec(ctx, ` + UPDATE refunds SET status = 'failed' + WHERE id = $1 AND status = 'pending' + `, pr.ID); upErr != nil { + log.Printf("Failed to mark manual refund %s failed after Square reconcile showed no refund: %v", pr.ID, upErr) + } + insertRefundFailedNotifications(ctx, []string{pr.ID}) + log.Printf("Manual refund %s (square_refund_id %s) has no COMPLETED refund at Square — marked 'failed' and admin notified; TODO email user+admin to VERIFY the Square dashboard before arranging in-person cash pickup at the salon (give at least a day's notice for cash on hand)", pr.ID, pr.SquareRefundID) + } + continue + } + sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: pr.SquarePaymentID, Amount: amountCents, diff --git a/backend/handlers/payments/refunds_test.go b/backend/handlers/payments/refunds_test.go index a1d86da..b3e78d1 100644 --- a/backend/handlers/payments/refunds_test.go +++ b/backend/handlers/payments/refunds_test.go @@ -1943,6 +1943,17 @@ func TestSweepPendingSquareRefunds_NullSquareRef_NoReference_MarksFailed(t *test t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting @@ -2028,6 +2039,17 @@ func TestSweepPendingSquareRefunds_AttemptsExhausted_NotProcessed(t *testing.T) t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting @@ -2568,6 +2590,17 @@ func TestSweepPendingSquareRefunds_RetriesStaleManualRefund(t *testing.T) { t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting @@ -2665,6 +2698,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_ReconcilesCompletedRefund(t *testi t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // Pre-seed the COMPLETED refund Square recorded for this charge — the exact @@ -2751,6 +2795,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_ReconcileError_LeavesPending(t *te t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient recErr := &reconcileErrorClient{SquareClient: square.NewDevClient()} counting := &countingRefundClient{SquareClient: recErr} @@ -2836,6 +2891,17 @@ func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T) t.Fatalf("failed to commit test tx: %v", err) } + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + origClient := SquareClient // The MockClient records no refunds for this charge → reconcile returns a // genuine no-match (nil, nil). @@ -2872,3 +2938,217 @@ func TestProcessPendingSquareRefunds_AgeGuard_NoMatch_MarksFailed(t *testing.T) t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount) } } + +// ============================================================================= +// F1 — manual refunds with a stored square_refund_id are reconciled, not dropped +// ============================================================================= + +// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled locks the +// F1 fix: a manual refund the RefundPayment handler left 'pending' WITH a +// stored square_refund_id (Square's synchronous-PENDING response) is no longer +// filtered out of the sweep — it is reconciled against Square instead. When +// Square reports the exact COMPLETED refund, the row resolves to 'completed' +// and NO new refund is issued (re-issuing would risk a second refund). +func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_Reconciled(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create card payment: %v", err) + } + chargeID := "sqp_manual_reconcile_completed" + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at) + VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_completed', NOW() - INTERVAL '5 minutes') + RETURNING id + `, paymentID, bookingID, paymentID+"-refund-5000").Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + origClient := SquareClient + mock := square.NewDevClient().(*square.MockClient) + // Pre-seed the COMPLETED refund Square recorded for this charge — the exact + // amount, same payment. Simulates the handler's PENDING refund that has + // since completed at Square. + seeded, err := mock.RefundPayment(context.Background(), square.RefundPaymentReq{ + PaymentID: chargeID, + Amount: 5000, + IdempotencyKey: "seed-manual-reconcile-completed", + Reason: "customer request", + }) + if err != nil { + t.Fatalf("failed to seed completed Square refund: %v", err) + } + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + // The sweep processes the whole shared test database — clear pending rows + // left by earlier sequential tests so the call count is deterministic. + if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil { + t.Fatalf("failed to clean leftover pending refunds: %v", err) + } + if _, err := SweepPendingSquareRefunds(freshCtx); err != nil { + t.Fatalf("SweepPendingSquareRefunds failed: %v", err) + } + + var status string + var squareRefundID *string + err = db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, refundID).Scan(&status, &squareRefundID) + if err != nil { + t.Fatalf("failed to query refund: %v", err) + } + if status != "completed" { + t.Errorf("expected manual refund with square_refund_id resolved to 'completed' via Square reconcile, got %q", status) + } + if squareRefundID == nil || *squareRefundID != seeded.ID { + t.Errorf("expected square_refund_id %q (the refund Square recorded), got %v", seeded.ID, squareRefundID) + } + + // The reconcile must NOT have re-issued a new Square refund. + if calls := counting.refundCalls(); len(calls) != 0 { + t.Errorf("expected NO new Square refund call for a reconcilable row, got %d", len(calls)) + } +} + +// TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed locks +// the F1 no-match branch: a manual refund row WITH a stored square_refund_id +// whose Square reconcile finds no exact COMPLETED refund (Square never +// recorded it) is marked 'failed' and surfaced via admin_notification — it no +// longer blocks the over-refund guard forever. +func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + userID, err := fixtures.CreateTestUser(tx) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + serviceID, err := fixtures.CreateTestService(tx) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, + time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") + if err != nil { + t.Fatalf("failed to create card payment: %v", err) + } + chargeID := "sqp_manual_reconcile_nomatch" + if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID); err != nil { + t.Fatalf("failed to set square_payment_id: %v", err) + } + + var refundID string + err = tx.QueryRow(ctx, ` + INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, square_refund_id, created_at) + VALUES ($1, $2, 50, 'pending', 'customer request', $3, 'manual', 'ref_seeded_manual_nomatch', NOW() - INTERVAL '5 minutes') + RETURNING id + `, paymentID, bookingID, paymentID+"-refund-5001").Scan(&refundID) + if err != nil { + t.Fatalf("failed to insert pending manual refund with square_refund_id: %v", err) + } + + pgxTx := db.TxFromContext(ctx) + if pgxTx == nil { + t.Fatal("no transaction in context") + } + if err := pgxTx.Commit(ctx); err != nil { + t.Fatalf("failed to commit test tx: %v", err) + } + + // The committed rows live in the SHARED test pool — clean them up or + // parallel tests that count whole tables see them (test isolation). + t.Cleanup(func() { + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) + _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }) + + origClient := SquareClient + // The MockClient has no refund for this charge → reconcile is a genuine + // no-match (nil, nil). + mock := square.NewDevClient().(*square.MockClient) + counting := &countingRefundClient{SquareClient: mock} + SquareClient = counting + defer func() { SquareClient = origClient }() + + freshCtx := context.Background() + if _, err := db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE status = 'pending' AND id <> $1`, refundID); err != nil { + t.Fatalf("failed to clean leftover pending refunds: %v", err) + } + if _, err := SweepPendingSquareRefunds(freshCtx); err != nil { + t.Fatalf("SweepPendingSquareRefunds failed: %v", err) + } + + var status string + err = db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status) + if err != nil { + t.Fatalf("failed to query refund: %v", err) + } + if status != "failed" { + t.Errorf("expected manual refund with no COMPLETED refund at Square marked 'failed', got %q", status) + } + + // The no-match reconcile must NOT re-issue a new Square refund. + if calls := counting.refundCalls(); len(calls) != 0 { + t.Errorf("expected NO Square refund call on a no-match reconcile, got %d", len(calls)) + } + + // The terminal failure must surface an admin_notifications row. + var notifCount int + err = db.Conn.QueryRow(freshCtx, + `SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'refund_failed'`, bookingID).Scan(¬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) + } +} diff --git a/backend/handlers/payments/service.go b/backend/handlers/payments/service.go index b3f1c8f..f371278 100644 --- a/backend/handlers/payments/service.go +++ b/backend/handlers/payments/service.go @@ -17,14 +17,20 @@ import ( ) type SavedCard struct { - ID string `json:"id"` - SquareCardID string `json:"square_card_id"` - Brand string `json:"brand"` - Last4 string `json:"last_4"` - ExpMonth int `json:"exp_month"` - ExpYear int `json:"exp_year"` - Fingerprint string `json:"fingerprint"` - IsDefault bool `json:"is_default"` + ID string `json:"id"` + // SquareCustomerID is the user's provisioned Square customer profile id + // (P14), persisted on the row the first time the user saves a card. It is + // forwarded to CreatePayment as CustomerID on saved-card (ccof:) charges, + // which Square requires for card-on-file payments. Empty for rows created + // before provisioning was introduced. + SquareCustomerID string `json:"square_customer_id,omitempty"` + SquareCardID string `json:"square_card_id"` + Brand string `json:"brand"` + Last4 string `json:"last_4"` + ExpMonth int `json:"exp_month"` + ExpYear int `json:"exp_year"` + Fingerprint string `json:"fingerprint"` + IsDefault bool `json:"is_default"` } type PaymentService struct{} @@ -472,6 +478,9 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo SELECT COALESCE(SUM(amount), 0) AS paid_pounds FROM payments WHERE booking_id = $1 AND status = 'completed' + -- A tip is money paid beyond the booking total — it does not + -- reduce the balance owed, so it must not count as "paid". + AND payment_type <> 'tip' ) SELECT GREATEST(0, ROUND((bt.total_pounds - pt.paid_pounds) * 100))::bigint FROM booking_total bt, paid_total pt @@ -484,7 +493,7 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) { rows, err := db.Conn.Query(ctx, ` - SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default + SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '') FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL ORDER BY is_default DESC, created_at DESC @@ -498,7 +507,7 @@ func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID strin var cards []SavedCard for rows.Next() { var c SavedCard - err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault) + err := rows.Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault, &c.SquareCustomerID) if err != nil { return nil, err } @@ -546,55 +555,123 @@ func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userI // PCI-DSS: raw PANs are never accepted. The client must supply a Square // Web Payments nonce (cnon:xxx), which the backend tokenizes via the // Cards API — the full PAN exists only inside Square's vault. - cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken) + // + // P14: this endpoint saves a card, so lazily ensure the user has a Square + // customer profile BEFORE the card is tokenized — if provisioning fails the + // card cannot be saved, so abort with a clear error instead of creating an + // orphan card at Square. One-off (non-save) payments never call this. + squareCustomerID, err := s.ensureSquareCustomer(ctx, userID) + if err != nil { + return nil, err + } + + cardOnFile, err := SquareClient.CreateCardOnFile(ctx, userID, cardToken, squareCustomerID) if err != nil { return nil, fmt.Errorf("failed to tokenize card: %w", err) } var savedCardID string var isDefault bool - // ON CONFLICT (square_card_id): a response-lost retry re-tokenizes the same - // card (CreateCardOnFile's deterministic key returns the same ccof: id), so - // the UNIQUE constraint would otherwise 500 on the duplicate. Upsert instead - // so the retry returns the existing saved card (N-8). + // ON CONFLICT (user_id, square_card_id): a response-lost retry re-tokenizes + // the same card for the SAME user (CreateCardOnFile's deterministic key + // returns the same ccof: id), so the per-user UNIQUE constraint would + // otherwise 500 on the duplicate. Upsert instead so the retry returns the + // existing saved card (N-8). The conflict target is scoped per user — a + // card tokenized by user B that user A already saved is a brand-new row for + // B, never a mutation of A's row. err = db.Conn.QueryRow(ctx, ` - INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default) - SELECT $1, $2, $3, $4, $5, $6, $7, + INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, square_customer_id, is_default) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL) - ON CONFLICT (square_card_id) DO UPDATE SET + ON CONFLICT (user_id, square_card_id) DO UPDATE SET brand = EXCLUDED.brand, last_4 = EXCLUDED.last_4, exp_month = EXCLUDED.exp_month, exp_year = EXCLUDED.exp_year, fingerprint = EXCLUDED.fingerprint, + square_customer_id = EXCLUDED.square_customer_id, deleted_at = NULL, retained_until = NULL + WHERE user_saved_cards.user_id = EXCLUDED.user_id RETURNING id, is_default - `, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint).Scan(&savedCardID, &isDefault) + `, userID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint, squareCustomerID).Scan(&savedCardID, &isDefault) if err != nil { return nil, fmt.Errorf("failed to save card: %w", err) } return &SavedCard{ - ID: savedCardID, - SquareCardID: cardOnFile.CardID, - Brand: cardOnFile.Brand, - Last4: cardOnFile.Last4, - ExpMonth: cardOnFile.ExpMonth, - ExpYear: cardOnFile.ExpYear, - Fingerprint: cardOnFile.Fingerprint, - IsDefault: isDefault, + ID: savedCardID, + SquareCustomerID: squareCustomerID, + SquareCardID: cardOnFile.CardID, + Brand: cardOnFile.Brand, + Last4: cardOnFile.Last4, + ExpMonth: cardOnFile.ExpMonth, + ExpYear: cardOnFile.ExpYear, + Fingerprint: cardOnFile.Fingerprint, + IsDefault: isDefault, }, nil } -func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) { - var id string +// ensureSquareCustomer lazily provisions a Square customer profile for the +// user (P14). A customer is only ever minted when a card is being SAVED — the +// saved-card row is the persistence point, and the id is reused for every +// subsequent card save by the same user. Square dedups on a deterministic +// idempotency key derived from the email, so a response-lost retry returns the +// same customer instead of minting a duplicate. +func (s *PaymentService) ensureSquareCustomer(ctx context.Context, userID string) (string, error) { + var customerID sql.NullString err := db.Conn.QueryRow(ctx, ` + SELECT square_customer_id FROM user_saved_cards + WHERE user_id = $1 AND square_customer_id IS NOT NULL AND square_customer_id <> '' + ORDER BY created_at DESC + LIMIT 1 + `, userID).Scan(&customerID) + if err == nil && customerID.Valid { + return customerID.String, nil + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("failed to look up Square customer id: %w", err) + } + + var name, email string + if err := db.Conn.QueryRow(ctx, ` + SELECT fn, email FROM users WHERE id = $1 + `, userID).Scan(&name, &email); err != nil { + return "", fmt.Errorf("failed to load user for Square customer provisioning: %w", err) + } + + customer, err := SquareClient.CreateCustomer(ctx, name, email) + if err != nil { + return "", fmt.Errorf("failed to create Square customer for card save: %w", err) + } + return customer.ID, nil +} + +// EnsureSquareCustomer lazily provisions (or reuses) the user's Square customer +// profile, persisting its id on the saved-card row for reuse. Exported for +// handlers that must pass the customer id to CreateCardOnFile in save-card +// flows (P14): Square creates the card against that customer, and subsequent +// saved-card (ccof:) charges carry it as CreatePaymentReq.CustomerID. +func (s *PaymentService) EnsureSquareCustomer(ctx context.Context, userID string) (string, error) { + return s.ensureSquareCustomer(ctx, userID) +} + +func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) { + // P14: SaveCardForUser is only ever called in save-card flows, so lazily + // ensure the Square customer exists and persist its id on the saved-card + // row for reuse by subsequent card saves from the same user. + squareCustomerID, err := s.ensureSquareCustomer(ctx, userID) + if err != nil { + return "", err + } + + var id string + err = db.Conn.QueryRow(ctx, ` INSERT INTO user_saved_cards ( - user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, false, NOW()) + user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NOW()) RETURNING id - `, userID, squareCardID, brand, last4, expMonth, expYear, fingerprint).Scan(&id) + `, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id) if err != nil { return "", err @@ -612,10 +689,10 @@ func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) { var c SavedCard err := q.QueryRow(ctx, ` - SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default + SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL - `, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault) + `, cardID, userID).Scan(&c.ID, &c.SquareCardID, &c.Brand, &c.Last4, &c.ExpMonth, &c.ExpYear, &c.Fingerprint, &c.IsDefault, &c.SquareCustomerID) if err != nil { return nil, err diff --git a/backend/handlers/payments/sweep.go b/backend/handlers/payments/sweep.go index 9835e69..678d137 100644 --- a/backend/handlers/payments/sweep.go +++ b/backend/handlers/payments/sweep.go @@ -2,44 +2,46 @@ package payments import ( "context" + "errors" + "fmt" "log" + "strings" "time" "crussell/clock" "crussell/db" + "crussell/internal/square" ) -// SweepStalePendingPayments marks pending payment records that are older than -// Square's idempotency-key retention window (~24h) as 'failed'. A pending -// record means the DB committed but the Square charge outcome is unknown; it +// SweepStalePendingPayments resolves pending payment records that are older +// than Square's idempotency-key retention window (~24h). A pending record +// means the DB committed but the Square charge outcome is unknown; it // normally resolves on a same-key client retry. But if the client abandoned // the attempt, the record stays pending forever — and retrying it after the // key expires would ISSUE A SECOND CHARGE (Square no longer dedups). Failing // stale pendings closes that double-charge window: a late retry finds a // 'failed' record and stops instead of charging again. // +// Before failing a row, the sweep reconciles it against Square: a +// genuinely-charged row (Square success, DB post-charge failure) with a +// square_payment_id is rescued to 'completed' instead of being swept to +// 'failed' with no automatic resolution — the money would otherwise be lost +// in limbo (MINOR-R3). Reconciliation is deliberately minimal: status + +// updated_at only, no split/VAT recomputation (that is the handler's job; the +// row is >24h stale and this is a reconciliation rescue). +// // Only online/till card payments can be pending — cash/giftcard/on_the_house // are committed synchronously and never enter this state. Both the payments // table and till_sales carry pending card-sale rows and are swept here. -// -// A swept row may have been genuinely charged at Square with a lost response — -// it is flagged with a CRITICAL manual-reconciliation log (like the refund -// sweep) so the money is not silently lost in limbo (MINOR-R3). const stalePendingPaymentAge = 24 * time.Hour func SweepStalePendingPayments(ctx context.Context) (int, error) { cutoff := clock.Now().Add(-stalePendingPaymentAge) - tag, err := db.Conn.Exec(ctx, ` - UPDATE payments - SET status = 'failed', updated_at = NOW() - WHERE status = 'pending' - AND created_at < $1 - `, cutoff) + payCount, payCompleted, err := sweepStaleRows(ctx, "payments", cutoff) if err != nil { return 0, err } - payCount := int(tag.RowsAffected()) // till_sales rows for card payments (stored as 'online_square' or // 'in_person_card' in the payment_method enum — saved_card/online_square/ @@ -48,27 +50,376 @@ func SweepStalePendingPayments(ctx context.Context) (int, error) { // and a retry after key retention would reuse the stored key → Square sees // an expired key → second charge (R3). Cash / on_the_house are committed // synchronously and never pending. - tillTag, err := db.Conn.Exec(ctx, ` - UPDATE till_sales - SET status = 'failed', updated_at = NOW() - WHERE status = 'pending' + tillCount, tillCompleted, err := sweepStaleRows(ctx, "till_sales", cutoff) + if err != nil { + return 0, err + } + + total := payCount + tillCount + if total > 0 { + log.Printf("[SWEEP] Resolved %d stale pending payments (%d payments, %d till sales) older than %s — late retries will be rejected, preventing a second Square charge; %d reconciled to completed against Square (%d payments, %d till sales)", total, payCount, tillCount, stalePendingPaymentAge, payCompleted+tillCompleted, payCompleted, tillCompleted) + } + if payCount > 0 { + log.Printf("CRITICAL: %d pending payments swept to failed may have been charged at Square with a lost response — manual reconciliation required before refunding/charging", payCount-payCompleted) + } + if tillCount > 0 { + log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount-tillCompleted) + } + return total, nil +} + +// staleRow is one stale pending row read by the sweep so it can reconcile +// rows that carry a Square reference BEFORE failing them. +type staleRow struct { + ID string + SquarePaymentID string +} + +// sweepStaleRows resolves the stale pending rows of one table. Rows with a +// square_payment_id are reconciled at Square first (COMPLETED → 'completed', +// anything else → 'failed' exactly as the legacy bulk UPDATE did); rows +// without one cannot be reconciled and are failed directly. Returns the total +// rows resolved and how many were rescued to 'completed'. +func sweepStaleRows(ctx context.Context, table string, cutoff time.Time) (resolved int, completed int, err error) { + switch table { + case "payments", "till_sales": + default: + return 0, 0, fmt.Errorf("sweep: unknown stale table %q", table) + } + // The legacy till_sales sweep only touched card methods — cash and + // on_the_house are committed synchronously and never pending, but keep the + // predicate so behaviour is byte-identical for any unexpected row. + methodFilter := "" + if table == "till_sales" { + methodFilter = ` AND payment_method IN ('online_square', 'in_person_card')` + } + rows, err := db.Conn.Query(ctx, ` + SELECT id, COALESCE(square_payment_id, '') + FROM `+table+` + WHERE status = 'pending' AND created_at < $1`+methodFilter+` + `, cutoff) + if err != nil { + return 0, 0, err + } + var stale []staleRow + for rows.Next() { + var r staleRow + if err := rows.Scan(&r.ID, &r.SquarePaymentID); err != nil { + log.Printf("Failed to scan stale pending row from %s: %v", table, err) + continue + } + stale = append(stale, r) + } + rows.Close() + + for _, r := range stale { + if r.SquarePaymentID != "" { + switch reconcileStalePaymentAtSquare(ctx, table, r.SquarePaymentID) { + case staleReconcileCompleted: + if tag, upErr := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'completed', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + `, r.ID); upErr != nil { + log.Printf("Failed to rescue stale pending row %s to completed: %v", r.ID, upErr) + } else if n := int(tag.RowsAffected()); n > 0 { + resolved++ + completed++ + } + continue + case staleReconcileLeavePending: + // Square's answer was ambiguous (transport/5xx) — the charge + // may still be in flight at Square. Do NOT touch the row: the + // next sweep run reconciles it again, and a same-key retry + // must still be able to reuse the pending row if the charge + // actually completed. + log.Printf("Stale pending %s row %s left pending (Square reconcile ambiguous) — will retry next sweep", table, r.ID) + continue + } + // staleReconcileDefinitivelyFailed falls through to the fail path. + } + if tag, upErr := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'failed', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + `, r.ID); upErr != nil { + log.Printf("Failed to mark stale pending row %s failed: %v", r.ID, upErr) + } else if n := int(tag.RowsAffected()); n > 0 { + resolved++ + } + } + return resolved, completed, nil +} + +// staleReconcileResult is the tri-state outcome of reconciling one stale +// pending row against Square. Only a definitively-resolved outcome touches the +// row: an ambiguous answer (transport error / 5xx) leaves it pending so a +// same-key retry can still reuse it if the charge actually completed. +type staleReconcileResult int + +const ( + // staleReconcileLeavePending — Square's answer was ambiguous; the row stays + // pending for the next sweep run. + staleReconcileLeavePending staleReconcileResult = iota + // staleReconcileCompleted — Square confirms the charge completed; rescue + // the row to 'completed'. + staleReconcileCompleted + // staleReconcileDefinitivelyFailed — Square proves the charge never + // completed (payment not found / non-completed status); mark the row + // 'failed' exactly as the legacy bulk sweep did. + staleReconcileDefinitivelyFailed +) + +// reconcileStalePaymentAtSquare asks Square for the authoritative status of a +// stale pending charge and returns the tri-state result. A COMPLETED payment +// rescues the row to 'completed'; a NOT_FOUND error or any non-COMPLETED status +// proves the charge never completed and fails the row as the legacy bulk sweep +// did. Any OTHER error (transport / 5xx / ambiguous) is NOT treated as a +// definitive failure — the charge may still have completed at Square, and +// marking the row failed would close the double-charge window (blocking a +// same-key retry with a 409) even though the money moved. Such rows stay +// pending for a later run. +func reconcileStalePaymentAtSquare(ctx context.Context, table, squarePaymentID string) staleReconcileResult { + pr, err := SquareClient.GetPayment(ctx, squarePaymentID) + if err != nil { + if squarePaymentErrorIsNotFound(err) { + log.Printf("Stale pending %s reconcile: Square payment %s not found (%v) — marking failed as the legacy sweep would", table, squarePaymentID, err) + return staleReconcileDefinitivelyFailed + } + log.Printf("Stale pending %s reconcile for Square payment %s hit an ambiguous error (%v) — leaving pending for a later sweep run", table, squarePaymentID, err) + return staleReconcileLeavePending + } + if pr.Status != "COMPLETED" { + log.Printf("Stale pending %s is %q at Square — marking failed", table, pr.Status) + return staleReconcileDefinitivelyFailed + } + return staleReconcileCompleted +} + +// squarePaymentErrorIsNotFound reports whether a GetPayment error proves the +// payment does not exist at Square. The structured Square error code is the +// primary check (square.ErrorCode); the message fallback also covers the dev +// mock (a plain "payment not found" error) and a non-JSON 404 response. +func squarePaymentErrorIsNotFound(err error) bool { + if err == nil { + return false + } + if square.ErrorCode(err) == "NOT_FOUND" { + return true + } + msg := strings.ToUpper(err.Error()) + return strings.Contains(msg, "NOT_FOUND") || + strings.Contains(msg, "NOT FOUND") || + strings.Contains(msg, "HTTP 404") +} + +// staleTerminalCheckoutAge is how old a still-pending terminal checkout must +// be before the sweep cancels it. Terminal checkouts normally complete within +// minutes; an hour is far past any legitimate card-reader interaction while +// still short enough that a completed charge can never be misread as stale. +const staleTerminalCheckoutAge = 1 * time.Hour + +// SweepStaleTerminalCheckouts cancels terminal (card-machine) checkouts that +// are still PENDING/IN_PROGRESS long after they were created, so the terminal +// stops waiting on a customer who walked away. A checkout created by +// CreateTerminalPayment / CreateTillSale that is never polled would otherwise +// sit live at Square indefinitely; if it later completes it is an invisible, +// untracked charge. +// +// Two tables track live checkout IDs and are both swept: +// - terminal_checkouts: booking terminal checkouts created by +// CreateTerminalPayment. PENDING/IN_PROGRESS rows older than the cutoff +// are resolved at Square first: a checkout still waiting at Square is +// cancelled and re-checked once — if it completed during the cancel window +// the row is marked 'completed' (the poll handler records the payment), +// otherwise 'failed'; a COMPLETED checkout releases the in-flight guard +// (the poll handler records the payment); a definitively +// cancelled/expired checkout is marked 'failed'; an ambiguous status is +// left for a later run. +// - till_sales.square_checkout_id: card-machine till sales. A checkout still +// waiting at Square is cancelled and the sale marked 'failed' (the +// payment_status enum has no 'cancelled' value, and 'failed' is the same +// terminal state the stale-pending sweep uses, blocking the till +// pending-retry path); a checkout that completed during the cancel window +// leaves the sale pending for the poll handler to record. +// +// Each row is checked at Square FIRST and only cancelled when the checkout is +// provably still waiting (ErrCheckoutPending): a COMPLETED checkout is never +// cancelled, and a checkout whose status is unknown (transport error) is left +// alone for a later run. After a cancel the checkout is re-checked once — the +// customer may have completed the payment in the cancel window, in which case +// the row is resolved to 'completed' rather than 'failed'. +func SweepStaleTerminalCheckouts(ctx context.Context) (int, error) { + cutoff := clock.Now().Add(-staleTerminalCheckoutAge) + + var pending []staleTerminalCheckoutRow + + // Booking terminal checkouts (CreateTerminalPayment) live in the + // terminal_checkouts table. A stale PENDING/IN_PROGRESS row means the + // checkout is still live at Square (or was left after a crash / lost poll). + rows, err := db.Conn.Query(ctx, ` + SELECT 'terminal_checkout', checkout_id, checkout_id + FROM terminal_checkouts + WHERE status IN ('PENDING', 'IN_PROGRESS') AND created_at < $1 - AND payment_method IN ('online_square', 'in_person_card') `, cutoff) if err != nil { return 0, err } - tillCount := int(tillTag.RowsAffected()) + for rows.Next() { + var r staleTerminalCheckoutRow + if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil { + log.Printf("Failed to scan stale terminal checkout row: %v", err) + continue + } + pending = append(pending, r) + } + rows.Close() - total := payCount + tillCount - if total > 0 { - log.Printf("[SWEEP] Marked %d stale pending payments (%d payments, %d till sales) as failed (older than %s) — late retries will be rejected, preventing a second Square charge", total, payCount, tillCount, stalePendingPaymentAge) + // Till-sale card-machine checkouts are tracked on the till_sales row. + rows, err = db.Conn.Query(ctx, ` + SELECT 'till_sale', id, square_checkout_id + FROM till_sales + WHERE status = 'pending' AND square_checkout_id IS NOT NULL + AND created_at < $1 + `, cutoff) + if err != nil { + return 0, err } - if payCount > 0 { - log.Printf("CRITICAL: %d pending payments swept to failed may have been charged at Square with a lost response — manual reconciliation required before refunding/charging", payCount) + for rows.Next() { + var r staleTerminalCheckoutRow + if err := rows.Scan(&r.Kind, &r.RowID, &r.CheckoutID); err != nil { + log.Printf("Failed to scan stale terminal checkout row: %v", err) + continue + } + pending = append(pending, r) } - if tillCount > 0 { - log.Printf("CRITICAL: %d pending till sales swept to failed may have been charged at Square with a lost response — manual reconciliation required", tillCount) + rows.Close() + + resolved := 0 + for _, r := range pending { + // Conservative status check: only cancel a checkout that is provably + // still waiting at Square. A COMPLETED checkout must never be + // cancelled, and an ambiguous status (network error) is left alone for + // the next run. + pr, gErr := SquareClient.GetCheckout(ctx, r.CheckoutID) + switch { + case errors.Is(gErr, square.ErrCheckoutPending): + // Still live at the terminal — cancel it. A customer can complete + // the payment in the small window between the GetCheckout above and + // the cancel, so re-check once before marking the row failed: a + // COMPLETED charge must never be recorded as failed. + if cErr := SquareClient.CancelCheckout(ctx, r.CheckoutID); cErr != nil { + log.Printf("Failed to cancel stale terminal checkout %s (%s %s): %v", r.CheckoutID, r.Kind, r.RowID, cErr) + continue + } + recheck, rErr := SquareClient.GetCheckout(ctx, r.CheckoutID) + switch { + case rErr == nil && recheck.Status == "COMPLETED": + // The customer completed the payment during the cancel window. + // The poll handler records it — mark the row COMPLETED (or + // leave the till sale pending) instead of failed. + if r.Kind == "terminal_checkout" { + if tag, upErr := db.Conn.Exec(ctx, ` + UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() + WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') + `, r.RowID); upErr != nil { + log.Printf("Failed to mark terminal checkout %s completed after cancel re-check: %v", r.RowID, upErr) + } else if int(tag.RowsAffected()) > 0 { + resolved++ + } + } else { + log.Printf("Terminal checkout %s completed during sweep cancel — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID) + } + log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) but re-check shows COMPLETED — recorded as completed, payment handled by the poll handler", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge) + case isTerminalCheckoutError(rErr) || errors.Is(rErr, square.ErrCheckoutPending): + // The cancel landed (CANCELED / cancel-requested / expired / + // still-reporting-pending-but-now-cancelled) — it can never + // complete, so resolve the row to the terminal 'failed' state. + if markTerminalCheckoutRowFailed(ctx, r) { + resolved++ + } + log.Printf("Cancelled stale terminal checkout %s (%s %s, pending >%s) — marked failed", r.CheckoutID, r.Kind, r.RowID, staleTerminalCheckoutAge) + default: + // The cancel succeeded but the re-check itself is ambiguous — + // leave the row for a later run. + log.Printf("Terminal checkout %s was cancelled but its re-check is ambiguous (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, rErr, r.Kind, r.RowID) + } + case gErr == nil && pr.Status == "COMPLETED": + if r.Kind == "terminal_checkout" { + // The payment is recorded by the poll handler; release the + // in-flight guard so a fresh charge can be created. + if tag, upErr := db.Conn.Exec(ctx, ` + UPDATE terminal_checkouts SET status = 'COMPLETED', updated_at = NOW() + WHERE checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') + `, r.RowID); upErr != nil { + log.Printf("Failed to mark terminal checkout %s completed: %v", r.RowID, upErr) + } else if int(tag.RowsAffected()) > 0 { + resolved++ + } + } else { + // The till poll handler records it — leave the sale pending. + log.Printf("Terminal checkout %s already COMPLETED at Square — leaving sale %s pending (poll handler records it)", r.CheckoutID, r.RowID) + } + case isTerminalCheckoutError(gErr): + // Definitely cancelled / cancel-requested / expired — the checkout + // can never complete, so resolve it to the terminal 'failed' state. + if markTerminalCheckoutRowFailed(ctx, r) { + resolved++ + } + log.Printf("Terminal checkout %s is definitively terminal (%v) — marked %s %s failed", r.CheckoutID, gErr, r.Kind, r.RowID) + default: + // Ambiguous transport/unknown status — leave for a later run. + log.Printf("Terminal checkout %s status unknown (%v) — leaving %s %s pending for a later sweep", r.CheckoutID, gErr, r.Kind, r.RowID) + } } - return total, nil + return resolved, nil +} + +// staleTerminalCheckoutRow is one live-checkout row the sweep reads from +// either table so it can resolve the checkout at Square before touching the +// row. Kind is "terminal_checkout" (booking, terminal_checkouts table) or +// "till_sale" (till_sales.square_checkout_id). +type staleTerminalCheckoutRow struct { + Kind string + RowID string + CheckoutID string +} + +// markTerminalCheckoutRowFailed moves one tracked row to the terminal 'failed' +// state after its checkout is cancelled or proven terminal at Square. Returns +// true when the row was updated (status was still active). +func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutRow) bool { + var table, where string + if r.Kind == "till_sale" { + table = "till_sales" + where = "id = $1 AND status = 'pending'" + } else { + table = "terminal_checkouts" + where = "checkout_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')" + } + tag, err := db.Conn.Exec(ctx, ` + UPDATE `+table+` SET status = 'failed', updated_at = NOW() + WHERE `+where, r.RowID) + if err != nil { + log.Printf("Failed to mark %s %s failed: %v", r.Kind, r.RowID, err) + return false + } + return int(tag.RowsAffected()) > 0 +} + +// isTerminalCheckoutError reports whether a GetCheckout error proves the +// checkout can never complete. Square's HTTP client returns ErrCheckoutPending +// for a still-live checkout and surfaces a definitively CANCELED status as a +// "square: checkout 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") } diff --git a/backend/handlers/payments/sweep_test.go b/backend/handlers/payments/sweep_test.go new file mode 100644 index 0000000..861e293 --- /dev/null +++ b/backend/handlers/payments/sweep_test.go @@ -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) + } +} diff --git a/backend/handlers/payments/till.go b/backend/handlers/payments/till.go index 45cd25f..f5983d9 100644 --- a/backend/handlers/payments/till.go +++ b/backend/handlers/payments/till.go @@ -11,6 +11,7 @@ import ( "log/slog" "math" "net/http" + "strings" "crussell/db" "crussell/internal/square" @@ -22,16 +23,17 @@ import ( ) type TillSaleRequest struct { - ItemType string `json:"item_type" validate:"required"` - Action string `json:"action" validate:"required"` - Amount float64 `json:"amount" validate:"required,gt=0"` - GiftCardID *string `json:"gift_card_id,omitempty"` - PaymentMethod string `json:"payment_method" validate:"required"` - UserSavedCardID *string `json:"user_saved_card_id,omitempty"` - UserID *string `json:"user_id,omitempty"` - IdempotencyKey string `json:"idempotency_key,omitempty"` - CardToken string `json:"card_token,omitempty"` - RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` + ItemType string `json:"item_type" validate:"required"` + Action string `json:"action" validate:"required"` + Amount float64 `json:"amount" validate:"required,gt=0"` + GiftCardID *string `json:"gift_card_id,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required"` + UserSavedCardID *string `json:"user_saved_card_id,omitempty"` + UserID *string `json:"user_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + CardToken string `json:"card_token,omitempty"` + RedeemToUserID *string `json:"redeem_to_user_id,omitempty"` + VerificationToken *string `json:"verification_token,omitempty"` } type TillSaleResponse struct { @@ -54,6 +56,126 @@ func uniqueTillKey() string { return "till-" + rand.Text() } +// definitivePaymentDeclineCodes are Square payment error codes meaning the +// card charge can never succeed (declined / expired / not supported). They are +// matched against the formatted Square API error so a DEFINITIVE rejection can +// claw back a gift card funded earlier in the same till-sale request. Anything +// else (transport errors, 5xx, unknown) is treated as ambiguous: the sale is +// left pending for the stale-pending sweep, which may still resolve it. +var definitivePaymentDeclineCodes = []string{ + "CARD_DECLINED", + "CARD_EXPIRED", + "INVALID_EXPIRATION", + "INVALID_EXPIRATION_DATE", + "CARD_NOT_SUPPORTED", + "VERIFY_CVV_FAILURE", + "AVS_FAILURE", + "PAYMENT_CARD_DECLINED", + "GENERIC_DECLINE", + "INSUFFICIENT_FUNDS", + "ADDRESS_VERIFICATION_FAILURE", + "TRANSACTION_LIMIT", +} + +// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a +// definitive business rejection (declined/expired) rather than an ambiguous +// transport/server error. The real HTTP client formats declines as +// "square: POST /v2/payments: [CATEGORY/CODE] ...", so the code is matched +// against the uppercased error message. +func isDefinitiveChargeFailure(err error) bool { + if err == nil { + return false + } + msg := strings.ToUpper(err.Error()) + for _, code := range definitivePaymentDeclineCodes { + if strings.Contains(msg, code) { + return true + } + } + return false +} + +// revertGiftCardFunding undoes the gift-card funding performed earlier in the +// SAME till-sale request after a definitive Square charge rejection, matching +// the gift_card_transactions accounting: a created card is deleted (with its +// purchase transaction) and any immediate redeem-to-account credit reversed; a +// topped-up card has the amount subtracted back out and its top-up transaction +// removed. The till sale is marked 'failed' in the same compensating +// transaction so a late same-key retry cannot re-complete a sale whose gift +// card no longer exists. +func revertGiftCardFunding(ctx context.Context, action, giftCardID string, amount float64, redeemToUserID *string, tillSaleID string) error { + tx, err := db.Conn.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin clawback transaction: %w", err) + } + defer func() { + if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + slog.Error("failed to rollback gift-card clawback transaction", "err", err) + } + }() + + if action == "create" { + // A newly created card has exactly one funding transaction (this + // request's purchase) — remove it, then the card itself. + if _, err := tx.Exec(ctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID); err != nil { + return fmt.Errorf("failed to delete gift card transaction: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM gift_cards WHERE id = $1`, giftCardID); err != nil { + return fmt.Errorf("failed to delete gift card: %w", err) + } + // If the card was immediately redeemed to a user balance in this + // request, reverse that credit (guarded so it can never go negative). + if redeemToUserID != nil && *redeemToUserID != "" { + if _, err := tx.Exec(ctx, ` + UPDATE user_giftcard_balances + SET balance = user_giftcard_balances.balance - $1, updated_at = NOW() + WHERE user_id = $2 AND balance >= $1 + `, amount, *redeemToUserID); err != nil { + return fmt.Errorf("failed to reverse redeemed gift card balance: %w", err) + } + } + } else { + // Top-up: subtract the amount back out of the card. The guard keeps + // amount_remaining from ever going negative in the pathological case + // where some of the top-up was already spent before the charge failed. + tag, err := tx.Exec(ctx, ` + UPDATE gift_cards + SET total_funds_added = total_funds_added - $1, + amount_remaining = amount_remaining - $1 + WHERE id = $2 AND amount_remaining >= $1 + `, amount, giftCardID) + if err != nil { + return fmt.Errorf("failed to reverse gift card top-up: %w", err) + } + if tag.RowsAffected() == 0 { + // The guard blocked the reversal because some of the top-up was + // already spent. The sale is still marked failed below — do not + // fail the whole clawback tx — but the unreversed money must be + // flagged for manual reconciliation. + log.Printf("CRITICAL: ... MANUAL RECONCILIATION REQUIRED: top-up %v on gift card %s could not be fully reversed (amount_remaining < top-up)", amount, giftCardID) + } + // Remove only this request's top-up transaction (reference_id = till + // sale) so prior sales' accounting on the same card is untouched. + if _, err := tx.Exec(ctx, ` + DELETE FROM gift_card_transactions + WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2 + `, giftCardID, tillSaleID); err != nil { + return fmt.Errorf("failed to delete gift card top-up transaction: %w", err) + } + } + + if _, err := tx.Exec(ctx, ` + UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending' + `, tillSaleID); err != nil { + return fmt.Errorf("failed to mark till sale failed after clawback: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit clawback transaction: %w", err) + } + return nil +} + func CreateTillSale(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — a till sale moves money (charges a @@ -113,6 +235,12 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { return } + if err := ValidateVerificationToken(req.VerificationToken); err != nil { + log.Printf("Failed to process request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + // Serialize till-sale attempts on the idempotency key to prevent concurrent // same-key requests from both passing the idempotency check, both funding // the gift card, and one dying on the till_sales idempotency_key UNIQUE @@ -351,6 +479,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // leaves a Square charge with no DB record. var needsSquarePayment bool var savedCardSqCardID string + var savedCardCustomerID string // Pending-retry for card_machine: the original Square checkout may still be // live at the terminal. If the pending till_sales row already recorded a @@ -403,10 +532,10 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { } err = tx.QueryRow(ctx, ` - SELECT square_card_id + SELECT square_card_id, COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL - `, *req.UserSavedCardID).Scan(&savedCardSqCardID) + `, *req.UserSavedCardID).Scan(&savedCardSqCardID, &savedCardCustomerID) if err != nil { log.Printf("Failed to get saved card details: %v", err) http.Error(w, "Card not found", http.StatusNotFound) @@ -438,7 +567,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { Currency: "GBP", IdempotencyKey: req.IdempotencyKey, ReferenceID: giftCardID, - TipEnabled: false, + AllowTipping: false, } checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq) @@ -550,11 +679,17 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { var paymentResult *square.PaymentResult var squareErr error + var verificationToken string + if req.VerificationToken != nil { + verificationToken = *req.VerificationToken + } + if req.PaymentMethod == "saved_card" { paymentReq := square.CreatePaymentReq{ Amount: penceAmount, Currency: "GBP", SourceID: savedCardSqCardID, + CustomerID: savedCardCustomerID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card " + req.Action, BuyerEmail: buyerEmail, @@ -572,8 +707,9 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { // "till-" namespace, NOT a real user — this card is // ephemeral (used once for this charge) and is never stored in // user_saved_cards or re-listed. The prefix can't collide with a - // real CHAR(12)-hex user ID. - cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken) + // real CHAR(12)-hex user ID. No Square customer is provisioned for + // it ("" as the customerID): a cnon: nonce charge needs none. + cardOnFile, cardErr := SquareClient.CreateCardOnFile(ctx, "till-"+giftCardID, req.CardToken, "") if cardErr != nil { log.Printf("Failed to tokenize card: %v", cardErr) http.Error(w, "Card tokenization failed", http.StatusInternalServerError) @@ -581,18 +717,36 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) { } paymentReq := square.CreatePaymentReq{ - Amount: penceAmount, - Currency: "GBP", - SourceID: cardOnFile.CardID, - IdempotencyKey: req.IdempotencyKey, - Note: "Gift Card " + req.Action, - BuyerEmail: buyerEmail, + Amount: penceAmount, + Currency: "GBP", + SourceID: cardOnFile.CardID, + IdempotencyKey: req.IdempotencyKey, + Note: "Gift Card " + req.Action, + BuyerEmail: buyerEmail, + VerificationToken: verificationToken, } paymentResult, squareErr = SquareClient.CreatePayment(ctx, paymentReq) } if squareErr != nil { log.Printf("Failed to process payment: %v", squareErr) + // The gift card was already created/top-upped in the committed DB + // transaction before this Square charge. On a DEFINITIVE rejection + // (declined/expired) the customer was never charged, so the funded + // card must be clawed back — otherwise it stays funded forever (the + // stale-pending sweep only marks the sale failed, it never reverts + // the card). Ambiguous failures (network/5xx) leave the sale pending + // so a late retry can still complete it — the card must stay funded. + // The clawback also runs on a pending-retry: the card was funded by + // a PRIOR request of this same sale (same idempotency key), and the + // retry's definitive failure proves this sale's charge can never + // complete — reverting the funding is required, not "the prior + // attempt's responsibility". + if isDefinitiveChargeFailure(squareErr) { + if revErr := revertGiftCardFunding(ctx, req.Action, giftCardID, req.Amount, req.RedeemToUserID, tillSaleID); revErr != nil { + log.Printf("CRITICAL: till sale %s charge definitively failed (%v) but gift-card clawback also failed: %v — MANUAL RECONCILIATION REQUIRED: gift card %s may still be funded", tillSaleID, squareErr, revErr, giftCardID) + } + } http.Error(w, "Payment failed", http.StatusPaymentRequired) return } diff --git a/backend/handlers/payments/till_test.go b/backend/handlers/payments/till_test.go index e9042d5..d227795 100644 --- a/backend/handlers/payments/till_test.go +++ b/backend/handlers/payments/till_test.go @@ -360,7 +360,7 @@ func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) { adminToken := jwt.GenerateTestToken(adminID, "admin") - cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } @@ -885,7 +885,7 @@ func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) { } adminToken := jwt.GenerateTestToken(adminID, "admin") - cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } @@ -1068,7 +1068,7 @@ func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing } adminToken := jwt.GenerateTestToken(adminID, "admin") - cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "sq_test_card_id", "VISA", "1234") + cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } @@ -1568,3 +1568,76 @@ func TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected(t *testing.T) { t.Errorf("expected pending sale to remain pending after rejected switch, got %s", saleStatus) } } + +// ============================================================================= +// revertGiftCardFunding — top-up guard-blocked reversal still fails the sale +// ============================================================================= + +func TestRevertGiftCardFunding_TopupPartiallySpent_LogsCritical_StillFailsSale(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + + adminID, err := fixtures.CreateTestAdminUser(tx) + if err != nil { + t.Fatalf("failed to create admin user: %v", err) + } + + var cardID string + if err := tx.QueryRow(ctx, ` + INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory) + VALUES (50.00, 10.00, $1, FALSE) + RETURNING id + `, adminID).Scan(&cardID); err != nil { + t.Fatalf("failed to seed gift card: %v", err) + } + + var saleID string + if err := tx.QueryRow(ctx, ` + INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) + VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW()) + RETURNING id + `, adminID).Scan(&saleID); err != nil { + t.Fatalf("failed to seed till sale: %v", err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'topup', 50.00, 'till_sale', $2) + `, cardID, saleID); err != nil { + t.Fatalf("failed to seed gift card transaction: %v", err) + } + + // amount_remaining (10.00) < top-up (50.00) — the guarded UPDATE matches 0 + // rows. The clawback must NOT fail (the sale still has to be marked failed) + // and must log CRITICAL for manual reconciliation. + err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID) + if err != nil { + t.Fatalf("revertGiftCardFunding must not fail when the guard blocks the reversal, got: %v", err) + } + + var remaining, totalAdded float64 + if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil { + t.Fatalf("failed to query gift card: %v", err) + } + if remaining != 10.00 || totalAdded != 50.00 { + t.Errorf("guard-blocked reversal must leave the card amounts untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded) + } + + // The sale must still be marked failed — the whole point of the clawback. + var saleStatus string + if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil { + t.Fatalf("failed to query till sale: %v", err) + } + if saleStatus != "failed" { + t.Errorf("expected till sale marked failed despite the guard-blocked reversal, got %q", saleStatus) + } + + // This request's top-up transaction must be removed even though the card + // amount could not be reversed. + var txCount int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, cardID, saleID).Scan(&txCount); err != nil { + t.Fatalf("failed to count gift card transactions: %v", err) + } + if txCount != 0 { + t.Errorf("expected this request's top-up transaction removed, got %d", txCount) + } +} diff --git a/backend/handlers/payments/validators.go b/backend/handlers/payments/validators.go index 6f001b2..379b8df 100644 --- a/backend/handlers/payments/validators.go +++ b/backend/handlers/payments/validators.go @@ -66,3 +66,17 @@ func ValidateCardInfo(cardID, newCardToken *string) error { } return nil } + +// ValidateVerificationToken checks that a Square 3DS/SCA verification token is +// non-empty when present and within a sane length. Square's tokens are short +// opaque strings; the bound guards against absurd/malformed payloads before +// the token is forwarded to Square's API. +func ValidateVerificationToken(token *string) error { + if token == nil || *token == "" { + return nil + } + if len(*token) > 512 { + return errors.New("verification_token is too long") + } + return nil +} diff --git a/backend/handlers/webhooks/square.go b/backend/handlers/webhooks/square.go index 10bd03d..4b7858b 100644 --- a/backend/handlers/webhooks/square.go +++ b/backend/handlers/webhooks/square.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "os" + "sync" ) type SquareWebhookEvent struct { @@ -19,6 +20,48 @@ type SquareWebhookEvent struct { 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) { r.Body = http.MaxBytesReader(w, r.Body, 512*1024) body, err := io.ReadAll(r.Body) @@ -67,6 +110,16 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { 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) switch event.Type { diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go index 9715395..2474950 100644 --- a/backend/handlers/webhooks/webhooks_test.go +++ b/backend/handlers/webhooks/webhooks_test.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "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()) } } + +// ============================================================================= +// 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") + } +} diff --git a/backend/internal/jobs/cleanup.go b/backend/internal/jobs/cleanup.go index b9fe852..1f6ee6b 100644 --- a/backend/internal/jobs/cleanup.go +++ b/backend/internal/jobs/cleanup.go @@ -66,6 +66,17 @@ func RegisterAll(s *Scheduler) { 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) === s.Register(Job{ @@ -156,7 +167,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ 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, Concurrency: 1, Handler: scheduling.ApplyScheduledDefaultHours, @@ -166,7 +177,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ 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, Concurrency: 1, Handler: scheduling.NotifyUnpaidOneWeek, @@ -174,7 +185,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ 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, Concurrency: 1, Handler: scheduling.NotifyUnpaidOneMonth, @@ -182,7 +193,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ Name: "transition-discount-campaigns", - Schedule: "0 * * * *", // Hourly + Schedule: "0 * * * *", // Hourly Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.TransitionDiscountCampaigns, @@ -190,7 +201,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ Name: "cleanup-verification-codes", - Schedule: "0 2 * * *", // Daily at 2am + Schedule: "0 2 * * *", // Daily at 2am Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredVerificationCodes, @@ -198,7 +209,7 @@ func RegisterAll(s *Scheduler) { s.Register(Job{ Name: "cleanup-refresh-tokens", - Schedule: "0 2 * * *", // Daily at 2am + Schedule: "0 2 * * *", // Daily at 2am Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredRefreshTokens, diff --git a/backend/internal/jobs/scheduler_test.go b/backend/internal/jobs/scheduler_test.go index 3e705ee..ceeec01 100644 --- a/backend/internal/jobs/scheduler_test.go +++ b/backend/internal/jobs/scheduler_test.go @@ -413,8 +413,8 @@ func TestRegisterAll_RegistersExpectedJobs(t *testing.T) { s := New() RegisterAll(s) - if got := len(s.registry); got != 22 { - t.Fatalf("RegisterAll() registered %d jobs, want 22", got) + if got := len(s.registry); got != 23 { + t.Fatalf("RegisterAll() registered %d jobs, want 23", got) } registered := make(map[string]Job, len(s.registry)) @@ -474,6 +474,7 @@ func expectedJobNames() map[string]bool { "cleanup-gdpr-export-cache": true, "sweep-pending-square-refunds": true, "sweep-stale-pending-payments": true, + "sweep-stale-terminal-checkouts": true, "cleanup-progressive-rate-limiter": true, "cleanup-expired-loyalty-redemptions": true, "cleanup-old-idempotency-keys": true, @@ -511,7 +512,7 @@ func TestRegisterAll_NoDuplicateCronExpressions(t *testing.T) { // disjoint tables (no contention risk). knownGroupings := map[int]bool{ 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 } diff --git a/backend/internal/square/square.go b/backend/internal/square/square.go index 828fca5..b011a60 100644 --- a/backend/internal/square/square.go +++ b/backend/internal/square/square.go @@ -31,12 +31,24 @@ func (p *ProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme 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) { return refundPaymentHTTP(ctx, req) } -func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) { - return createCardOnFileHTTP(ctx, userID, cardToken) +func (p *ProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { + return createCardOnFileHTTP(ctx, userID, cardToken, customerID) } func (p *ProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index cfb47ce..a9a0f50 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -5,6 +5,7 @@ package square import ( "context" "crussell/clock" + "crypto/sha256" "fmt" "log" "os" @@ -31,6 +32,7 @@ type MockClient struct { paymentByKey map[string]*PaymentResult refunds map[string]*RefundResult refundByKey map[string]*RefundResult + customers map[string]*CustomerResult completed map[string]*PaymentResult HoldCheckouts bool 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"), // RefundPayment returns the sentinel-wrapped error for that code. 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{} @@ -51,11 +57,20 @@ func (d *devProdClient) CreateCheckout(ctx context.Context, req CreateCheckoutRe func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) { 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) { return refundPaymentHTTP(ctx, req) } -func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) { - return createCardOnFileHTTP(ctx, userID, cardToken) +func (d *devProdClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { + return createCardOnFileHTTP(ctx, userID, cardToken, customerID) } func (d *devProdClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { return getCardsOnFileHTTP(ctx, userID) @@ -85,6 +100,7 @@ func NewDevClient() SquareClient { paymentByKey: make(map[string]*PaymentResult), refunds: make(map[string]*RefundResult), refundByKey: make(map[string]*RefundResult), + customers: make(map[string]*CustomerResult), completed: make(map[string]*PaymentResult), } } @@ -108,6 +124,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* if m.ShouldFail { 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 // card reference (ccof:) that could be replayed. Log only its prefix and // length for debugging (S-2). @@ -164,6 +186,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* locationID = "L_MOCK" } + expMonth := 12 + expYear := 2030 + result := &PaymentResult{ ID: paymentID, Status: status, @@ -171,8 +196,8 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (* CardBrand: cardBrand, CardLast4: cardLast4, CardFingerprint: fmt.Sprintf("sqfp_mock_%d", now.UnixNano()), - ExpMonth: 12, - ExpYear: 2030, + ExpMonth: &expMonth, + ExpYear: &expYear, EntryMethod: entryMethod, CVVStatus: "CVV_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) { - 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() 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()) amount := req.Amount tipAmount := int64(0) - if req.TipEnabled { + if req.AllowTipping { tipAmount = 500 amount += tipAmount } fees := amount * 175 / 10000 // in-person rate: 1.75% + expMonth := 12 + expYear := 2030 + paymentResult := &PaymentResult{ ID: paymentID, Status: "COMPLETED", @@ -252,8 +280,8 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) CardBrand: "VISA", CardLast4: "4242", CardFingerprint: fmt.Sprintf("sqfp_mock_%d", payNow.UnixNano()), - ExpMonth: 12, - ExpYear: 2030, + ExpMonth: &expMonth, + ExpYear: &expYear, EntryMethod: "EMV", CVVStatus: "CVV_ACCEPTED", AVSStatus: "AVS_ACCEPTED", @@ -302,6 +330,19 @@ func (m *MockClient) GetCheckout(ctx context.Context, checkoutID string) (*Payme 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) { if m.ShouldFail { 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] 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 // create payments via DB fixture with a square_payment_id, bypassing // 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" } + status := "COMPLETED" + if m.ForceRefundPending { + status = "PENDING" + } + result := &RefundResult{ ID: refundID, - Status: "COMPLETED", + Status: status, Amount: amount, PaymentID: req.PaymentID, LocationID: locationID, @@ -369,7 +422,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (* 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) // 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) { log.Printf("[SQUARE-MOCK] ListPaymentRefunds: payment=%s, begin=%s", paymentID, beginTime.UTC().Format(time.RFC3339)) - m.mu.Lock() - defer m.mu.Unlock() + m.mu.RLock() + defer m.mu.RUnlock() out := []RefundResult{} for _, r := range m.refunds { @@ -473,6 +526,63 @@ func isTokenLike(s string) bool { 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 { if env == "production" { return squareProductionURL diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 0072d5b..264cc98 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -3,9 +3,13 @@ package square import ( + "bytes" "context" "errors" "fmt" + "log" + "os" + "strings" "sync" "testing" "time" @@ -36,8 +40,10 @@ func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) { assert.NotZero(t, result.Fees) assert.NotEmpty(t, result.CardFingerprint) - assert.Equal(t, 12, result.ExpMonth) - assert.Equal(t, 2030, result.ExpYear) + require.NotNil(t, result.ExpMonth) + 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, "CVV_ACCEPTED", result.CVVStatus) assert.Equal(t, "AVS_ACCEPTED", result.AVSStatus) @@ -55,7 +61,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) { Currency: "GBP", IdempotencyKey: "checkout-key-1", ReferenceID: "booking-456", - TipEnabled: true, + AllowTipping: true, } result, err := client.CreateCheckout(ctx, req) @@ -91,7 +97,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) { Currency: "GBP", IdempotencyKey: "checkout-key-notip", ReferenceID: "booking-789", - TipEnabled: false, + AllowTipping: false, } result, err := client.CreateCheckout(ctx, req) @@ -157,7 +163,7 @@ func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) { ctx := context.Background() 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) assert.NotEmpty(t, card.ID) @@ -182,10 +188,10 @@ func TestDevClient_CardOnFile_MultipleCards(t *testing.T) { ctx := context.Background() 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) - card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2") + card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "") require.NoError(t, err) assert.True(t, card1.Enabled) @@ -207,7 +213,7 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) { ctx := context.Background() 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) err = client.DeleteCardOnFile(ctx, card.ID) @@ -257,7 +263,7 @@ func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) { for _, tt := range tests { 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") assert.Nil(t, card) assert.Contains(t, err.Error(), "invalid source_id") @@ -535,14 +541,14 @@ func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) { ctx := context.Background() 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) assert.True(t, card.Enabled) assert.NotEmpty(t, card.CardholderName) - // Local linkage goes in reference_id, NOT customer_id — the app has no - // Square customer provisioning, and a local ID in customer_id would be - // rejected by the real Cards API. + // Local linkage goes in reference_id; the mock does not store the + // customer_id (prod sends it on card creation when the app has provisioned + // a Square customer for the user). assert.Equal(t, userID, card.ReferenceID) assert.Empty(t, card.CustomerID) assert.Greater(t, card.Version, int64(0)) @@ -554,7 +560,7 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) { ctx := context.Background() 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) 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) +} diff --git a/backend/internal/square/square_http_client.go b/backend/internal/square/square_http_client.go index 2582860..32b1cd3 100644 --- a/backend/internal/square/square_http_client.go +++ b/backend/internal/square/square_http_client.go @@ -8,9 +8,11 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" + "strings" "time" ) @@ -192,15 +194,27 @@ type sqTerminalCheckoutRequest struct { } type sqTerminalCheckoutPayload struct { - AmountMoney sqMoney `json:"amount_money"` - ReferenceID string `json:"reference_id,omitempty"` - Note string `json:"note,omitempty"` - CustomerID string `json:"customer_id,omitempty"` - DeviceOptions *sqDeviceOptions `json:"device_options,omitempty"` + AmountMoney sqMoney `json:"amount_money"` + ReferenceID string `json:"reference_id,omitempty"` + Note string `json:"note,omitempty"` + CustomerID string `json:"customer_id,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 { - DeviceID string `json:"device_id"` + DeviceID string `json:"device_id"` + TipSettings *sqTipSettings `json:"tip_settings,omitempty"` } type sqTerminalCheckoutResponse struct { @@ -214,9 +228,11 @@ type sqTerminalCheckout struct { ReferenceID string `json:"reference_id,omitempty"` Note string `json:"note,omitempty"` PaymentIDs []string `json:"payment_ids,omitempty"` - Deadline string `json:"deadline_duration,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Deadline (deadline_duration) is deprecated in the TerminalCheckout API — + // retained read-only for informational purposes; harmless when set. + Deadline string `json:"deadline_duration,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type sqGetPaymentResponse struct { @@ -280,11 +296,49 @@ type sqDisableCardResponse struct { 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. // 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) { 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 if err := hc.doJSON(ctx, http.MethodPost, "/v2/terminals/checkouts", body, &resp); err != nil { 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) { + if !validSquareID(checkoutID) { + return nil, fmt.Errorf("square: invalid checkout id %q", checkoutID) + } var tcResp sqTerminalCheckoutResponse if err := hc.doJSON(ctx, http.MethodGet, "/v2/terminals/checkouts/"+checkoutID, nil, &tcResp); err != nil { return nil, err @@ -373,6 +436,21 @@ func getCheckoutHTTPWithClient(ctx context.Context, checkoutID string, hc *httpC 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 // structured Square error code so callers can classify definitive business // 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) 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 // never succeed, so retrying is pointless and the refund record should be // 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) } - 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) { - return createCardOnFileHTTPWithClient(ctx, userID, cardToken, newHTTPClient()) +func createCardOnFileHTTP(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) { + 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) // 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], SourceID: cardToken, 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 - // 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, + CustomerID: customerID, }, } 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) { // Filter by reference_id natively: Square's List Cards API supports 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 - // used). List Cards pages at 25 cards, so loop on the cursor to avoid - // silently truncating a large saved-card list (N-10). + // local user ID. customer_id is not used for the filter because a user may + // have no provisioned Square customer. List Cards pages at 25 cards, so + // loop on the cursor to avoid silently truncating a large saved-card list + // (N-10). var cards []CardOnFile path := "/v2/cards?reference_id=" + url.QueryEscape(userID) for page := 0; page < 20; page++ { @@ -521,6 +629,56 @@ func deleteCardOnFileHTTP(ctx context.Context, cardID string) error { 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. // --------------------------------------------------------------------------- @@ -552,16 +710,15 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult { r.EntryMethod = cd.EntryMethod r.CVVStatus = cd.CVVStatus 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 != "" { - r.CardBrand = cd.Card.CardBrand - r.CardLast4 = cd.Card.Last4 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 diff --git a/backend/internal/square/square_http_client_test.go b/backend/internal/square/square_http_client_test.go index 02c96c2..d73bcd1 100644 --- a/backend/internal/square/square_http_client_test.go +++ b/backend/internal/square/square_http_client_test.go @@ -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 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ 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() hc := &httpClient{baseURL: srv.URL, token: "t", http: srv.Client()} - _, err := listRefundsHTTPWithClient(context.Background(), "pay_x", time.Now(), hc) - if err == nil || !strings.Contains(err.Error(), "exceeded 20 pages") { - t.Fatalf("expected 20-page guard error, got %v", err) + refunds, err := listRefundsHTTPWithClient(context.Background(), "pay_partial", time.Now(), hc) + if err != nil { + t.Fatalf("expected partial results (nil error), got %v", err) } if calls != 20 { 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() 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 { t.Fatalf("createCardOnFileHTTP failed: %v", err) } @@ -570,14 +576,14 @@ func TestCreateCardOnFileHTTP_IdempotencyKey(t *testing.T) { if !ok { t.Fatalf("expected card object, got %v", captured["card"]) } - // The local user ID goes in reference_id (free-form), NOT customer_id — - // the app has no Square customer provisioning, and customer_id would be - // rejected by the real Cards API (P1 regression guard). + // The local user ID goes in reference_id (free-form); customer_id is + // emitted only when the app has provisioned a Square customer for the user + // (empty customerID → omitted via omitempty). if card["reference_id"] != "user_1" { t.Errorf("expected card.reference_id user_1, got %v", card["reference_id"]) } 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" { 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 // the native reference_id filter (the local user ID) — not the invalid // 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]) } } + +// 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) + } + }) +} diff --git a/backend/internal/square/types.go b/backend/internal/square/types.go index d0c41f8..dc1950c 100644 --- a/backend/internal/square/types.go +++ b/backend/internal/square/types.go @@ -33,7 +33,7 @@ type CreatePaymentReq struct { Autocomplete *bool // nil (default) = true — complete immediately; false = approve only TipMoney *int64 // optional tip amount in pence 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 BuyerEmail string // buyer email for receipt } @@ -46,10 +46,14 @@ type CreateCheckoutReq struct { Currency string IdempotencyKey string ReferenceID string - TipEnabled bool // mock-only: simulates tip addition during checkout - DeviceID string // Square Terminal device ID (required in production) - Note string // optional note for the checkout - CustomerID string // optional Square customer ID + // AllowTipping enables tip entry on the Square Terminal: when true, the + // checkout payload sends device_options.tip_settings.allow_tipping=true so + // terminal tip revenue is actually collected (previously tips were silently + // 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). @@ -58,7 +62,7 @@ type RefundPaymentReq struct { Amount int64 // in pence, 0 = full refund IdempotencyKey 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 @@ -74,8 +78,11 @@ type PaymentResult struct { CardBrand string // "VISA", "MASTERCARD", "AMERICAN_EXPRESS", "DISCOVER", etc. CardLast4 string CardFingerprint string // unique card fingerprint from Square - ExpMonth int - ExpYear int + // ExpMonth/ExpYear are nil when the payment has no card details (e.g. a + // 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" CVVStatus string // "CVV_ACCEPTED", "CVV_REJECTED", "CVV_NOT_CHECKED" AVSStatus string // "AVS_ACCEPTED", "AVS_REJECTED", "AVS_NOT_CHECKED" @@ -119,7 +126,7 @@ type CardOnFile struct { ExpYear int Fingerprint string // Square card fingerprint 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 Enabled bool // whether the card is enabled (not disabled/expired) IsDefault bool // mock-only: first card saved for a user @@ -148,6 +155,16 @@ type SquareError struct { 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. // All implementations (mock, prod) must satisfy this interface. type SquareClient interface { @@ -155,10 +172,24 @@ type SquareClient interface { CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, 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) 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 // (charge), created at or after beginTime. Used to reconcile pending refund // rows against Square before marking them failed (money may already have diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 351feb4..9c36ebd 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -154,6 +154,15 @@ // Cached nonce: tokenization is one-shot — a retry reuses this token instead // of re-tokenizing (the backend idempotency key dedups). 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( authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' @@ -226,19 +235,35 @@ } let newCardToken: string | undefined; + let verificationToken: string | undefined; if (tipSelectedCardId) { // saved card — nothing to tokenize } else if (tipCardSelection) { - // New-card mode: tokenize once per attempt, reuse the nonce on retry. - if (!tipNonce) { + // New-card mode: tokenize once per attempt, reuse the nonce + SCA + // 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 { - 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) { toast.error(err instanceof Error ? err.message : 'Card entry failed'); return; } } newCardToken = tipNonce; + verificationToken = tipVerificationToken || undefined; } else { toast.error('Please select a payment method'); return; @@ -255,7 +280,8 @@ amount: Math.round(tipAmount * 100), idempotency_key: tipIdempotencyKey, ...(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`, { @@ -271,6 +297,9 @@ tipIdempotencyKey = ''; tipKeyedAmount = 0; tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; showTipModal = false; fetchBookingDetails(); } catch (err) { @@ -1110,6 +1139,9 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} tipSelectedCardId = ''; tipSaveCard = false; tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; } }} > @@ -1188,5 +1220,7 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20} {tipProcessing ? 'Processing...' : `Pay Tip £${tipAmount.toFixed(2)}`} + +

Secure payment powered by Square

diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index 6b00b70..f123165 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -610,15 +610,27 @@ onlineSquareProcessing = true; paymentError = ''; try { + const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount); let token: string; + let verificationToken: string | null; 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) { paymentError = err instanceof Error ? err.message : 'Card entry failed'; setModalStep(actionType, 'error'); return; } - const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount); const body: Record = { item_type: 'gift_card', action: actionType, @@ -627,6 +639,7 @@ card_token: token, idempotency_key: getIdempotencyKey() }; + if (verificationToken) body.verification_token = verificationToken; if (gcId) body.gift_card_id = gcId; if (selectedCustomer) body.user_id = selectedCustomer.id; if (actionType === 'create' && generateType === 'account' && selectedCustomer) @@ -1909,6 +1922,7 @@ > {onlineSquareProcessing ? 'Processing...' : 'Pay by Card'} +

Secure payment powered by Square

{/if} @@ -2181,6 +2195,7 @@ > {onlineSquareProcessing ? 'Processing...' : 'Pay by Card'} +

Secure payment powered by Square

{/if} diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte index 0ef6b00..7e9d54d 100644 --- a/frontend/src/lib/components/admin/TillPurchases.svelte +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -3,6 +3,11 @@ import { Input } from '$lib/components/ui/input'; import { Separator } from '$lib/components/ui/separator'; 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 = { id: string; @@ -11,13 +16,35 @@ 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([]); let giftCardAmount = $state('25'); let showGiftCardInput = $state(false); + let paymentMethod = $state('cash'); + let onlineSquareCardReady = $state(false); + let onlineSquareCardInput = $state(null); + let processing = $state(false); + let paymentError = $state(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 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 { return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n); } @@ -52,6 +79,95 @@ }) .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 { + 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[] = []; + for (const item of cart) { + for (let i = 0; i < item.qty; i++) { + const body: Record = { + 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; + } + }
@@ -64,6 +180,7 @@ variant="outline" size="sm" class="justify-start gap-2" + disabled={processing} onclick={() => addItem('Cuticle Oil', 8)} > Cuticle Oil - £8 @@ -72,6 +189,7 @@ variant="outline" size="sm" class="justify-start gap-2" + disabled={processing} onclick={() => addItem('Nail Files (Pack)', 5)} > Nail Files - £5 @@ -80,6 +198,7 @@ variant="outline" size="sm" class="justify-start gap-2" + disabled={processing} onclick={() => addItem('Hand Cream', 6)} > Hand Cream - £6 @@ -88,6 +207,7 @@ variant="outline" size="sm" class="justify-start gap-2" + disabled={processing} onclick={() => addItem('Base Coat', 7)} > Base Coat - £7 @@ -96,6 +216,7 @@ variant="outline" size="sm" class="justify-start gap-2" + disabled={processing} onclick={() => addItem('Top Coat', 7)} > Top Coat - £7 @@ -112,12 +233,13 @@ inputmode="decimal" bind:value={giftCardAmount} class="h-9 pl-5 text-sm" + disabled={processing} onkeydown={(e) => { if (e.key === 'Enter') addGiftCard(); }} />
- @@ -126,6 +248,7 @@ variant="outline" size="sm" class="w-full justify-start gap-2" + disabled={processing} onclick={() => (showGiftCardInput = true)} > Gift Card @@ -154,7 +277,8 @@
- + {/each} + + + + {#if paymentMethod === 'online_square'} +
+ {#if isSquareConfigured()} + (onlineSquareCardReady = r)} + disabled={processing} + /> + {:else} +

+ Online card entry is unavailable — Square is not configured. +

+ {/if} +
+ {/if} + + {#if hasRetailItems} +

+ Retail items can't be charged yet — the till API currently supports gift card sales + only. Remove retail items to complete this sale. +

+ {/if} + + {#if paymentError} +

+ {paymentError} +

+ {/if} + + +

Secure payment powered by Square

- Payment flow and backend integration coming soon. + Gift card sales are processed through the till; retail items require manual recording for now.

{/if} diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index c90b7f0..bbe7598 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -103,6 +103,16 @@ // Cached nonce: tokenization is one-shot — a retry reuses this token instead // of re-tokenizing (the backend idempotency key dedups). 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 // on the next microtask), so `isProcessingPayment` may not propagate to the // button's `disabled` binding before a fast second click fires. This non- @@ -286,24 +296,6 @@ isProcessingPayment = true; paymentAttempted = false; 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(); if (!confirmedBooking) { toast.error('Booking was not created. Please try again.'); @@ -320,6 +312,40 @@ : _amount; 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 // reuses it (backend dedups) instead of double-charging. const cardKey = selectedPaymentMethod || `new:${newCardToken ?? ''}`; @@ -338,7 +364,8 @@ amount: amountCents, idempotency_key: depositIdempotencyKey, ...(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; @@ -358,6 +385,9 @@ depositKeyedAmount = 0; depositKeyedCard = ''; depositNonce = ''; + depositVerificationToken = ''; + depositTokenAmount = 0; + depositTokenizedAt = 0; depositSaveCard = false; // Immutable update — avoid mutating the existing object so // concurrent renders (e.g. a stale fetch) can't observe partial @@ -2364,6 +2394,8 @@ : `Pay Deposit £${calculateDepositAmount()}`} + +

Secure payment powered by Square

diff --git a/frontend/src/lib/components/payments/CardSelection.svelte b/frontend/src/lib/components/payments/CardSelection.svelte index 3818bb2..a1c9bbc 100644 --- a/frontend/src/lib/components/payments/CardSelection.svelte +++ b/frontend/src/lib/components/payments/CardSelection.svelte @@ -2,6 +2,8 @@ import CardBrandIcon from './CardBrandIcon.svelte'; import CardEntryUnavailable from './CardEntryUnavailable.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'; export interface SelectableCard { @@ -68,6 +70,22 @@ } 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); + } {#if cards.length > 0} @@ -143,7 +161,10 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary accent-primary" bind:checked={saveCard} /> - Save this card for next time + + Save this card securely with our payment provider (Square) for next time. + + {/if} diff --git a/frontend/src/lib/components/payments/MockCardForm.svelte b/frontend/src/lib/components/payments/MockCardForm.svelte index 95f0067..b7d869b 100644 --- a/frontend/src/lib/components/payments/MockCardForm.svelte +++ b/frontend/src/lib/components/payments/MockCardForm.svelte @@ -35,7 +35,7 @@ const nameId = `mock-card-name-${crypto.randomUUID()}`; 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, '')); @@ -165,6 +165,37 @@ const token = MOCK_TOKENS[digits.slice(0, 4)] ?? 'cnon:test-card'; 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))}` + }); + }
diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 9d0648f..953391c 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -47,6 +47,11 @@ let checkoutId = $state(null); let paymentResult = $state(null); let error = $state(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); let useLoyalty = $state(false); @@ -228,6 +233,11 @@ 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 { return new Intl.NumberFormat('en-GB', { style: 'currency', @@ -248,13 +258,21 @@ } async function handleCardPayment() { + if (isProcessingPaymentSync) return; const finalAmount = totalDue; if (isNaN(finalAmount) || finalAmount <= 0) { toast.error('Please enter a valid amount'); 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'; error = null; @@ -284,6 +302,8 @@ status = 'error'; error = _err instanceof Error ? _err.message : 'Failed to initiate payment'; toast.error(error ?? 'Unknown error'); + } finally { + isProcessingPaymentSync = false; } } @@ -381,8 +401,13 @@ } async function handleCashPayment() { + if (isProcessingPaymentSync) return; const cashDue = totalDue - loyaltyDiscount / 100; + if (cashDue <= 0) { + toast.error('Nothing to charge — the booking is fully covered by discounts'); + return; + } if (cashAmountNum < cashDue) { toast.error('Cash amount must cover the total'); return; @@ -390,6 +415,7 @@ const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0; + isProcessingPaymentSync = true; status = 'cash-confirming'; error = null; @@ -430,6 +456,8 @@ status = 'error'; error = _err instanceof Error ? _err.message : 'Failed to process payment'; toast.error(error ?? 'Unknown error'); + } finally { + isProcessingPaymentSync = false; } } @@ -492,6 +520,7 @@ const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12); async function handleGiftCardPayment() { + if (isProcessingPaymentSync) return; if (!giftCardValid) { toast.error('Please enter a valid 12-character gift card code'); return; @@ -499,6 +528,11 @@ 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); if (useAccountBalance) { const parsedAmt = parseFloat(giftCardPaymentAmount); @@ -513,6 +547,7 @@ payAmountCents = Math.round(parsedAmt * 100); } + isProcessingPaymentSync = true; status = 'gift-confirming'; error = null; @@ -557,6 +592,8 @@ status = 'error'; error = _err instanceof Error ? _err.message : 'Failed to process gift card'; toast.error(error ?? 'Unknown error'); + } finally { + isProcessingPaymentSync = false; } } @@ -595,11 +632,20 @@ } async function handleSavedCardPayment() { + if (isProcessingPaymentSync) return; if (!selectedSavedCardId) { toast.error('Please select a saved card'); 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'; error = null; @@ -637,6 +683,8 @@ status = 'error'; error = _err instanceof Error ? _err.message : 'Failed to process saved card payment'; toast.error(error ?? 'Unknown error'); + } finally { + isProcessingPaymentSync = false; } } @@ -779,6 +827,12 @@
+ {#if nothingToCharge} +

+ The booking is fully covered by discounts — nothing to charge. +

+ {/if} + {#if tipEnabled}
@@ -797,7 +851,8 @@ > - +
{:else if status === 'card-processing' || status === 'card-polling'} @@ -1028,7 +1090,11 @@
-
@@ -1129,7 +1195,7 @@
-
@@ -1221,7 +1287,11 @@
-
diff --git a/frontend/src/lib/components/payments/SquareCardInput.svelte b/frontend/src/lib/components/payments/SquareCardInput.svelte index b3c11e0..62d98f8 100644 --- a/frontend/src/lib/components/payments/SquareCardInput.svelte +++ b/frontend/src/lib/components/payments/SquareCardInput.svelte @@ -1,9 +1,73 @@ + + {#if isSquareMock()} diff --git a/frontend/src/lib/components/payments/UserPaymentModal.svelte b/frontend/src/lib/components/payments/UserPaymentModal.svelte index 3663aa5..de16545 100644 --- a/frontend/src/lib/components/payments/UserPaymentModal.svelte +++ b/frontend/src/lib/components/payments/UserPaymentModal.svelte @@ -50,6 +50,15 @@ // Cached nonce for the new-card form: tokenization is one-shot, so a retry // reuses this token instead of re-tokenizing (backend idempotency dedups). 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<{ id: string; amount: number; @@ -356,15 +365,27 @@ let cardId: string | undefined; let newCardToken: string | undefined; + let verificationToken: string | undefined; if (selectedCardId) { cardId = selectedCardId; } else if (cardSelection) { - // New-card mode: tokenize once per attempt, then reuse the cached nonce - // on retry (tokenization is one-shot; the backend idempotency key dedups). - if (!newCardNonce) { + // New-card mode: tokenize once per attempt WITH SCA verification, then + // reuse the cached nonce + verification token on retry (tokenization + // 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 { - 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) { status = 'error'; const msg = _err instanceof Error ? _err.message : 'Card entry failed'; @@ -374,6 +395,7 @@ } } newCardToken = newCardNonce; + verificationToken = newCardVerificationToken || undefined; } else { status = 'error'; error = 'Please select a payment method'; @@ -405,6 +427,7 @@ payment_type: paymentType, ...(cardId ? { card_id: cardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), + ...(verificationToken ? { verification_token: verificationToken } : {}), idempotency_key: payIdempotencyKey }) }); @@ -422,6 +445,9 @@ payKeyedType = ''; payKeyedCard = ''; newCardNonce = ''; + newCardVerificationToken = ''; + newCardTokenAmount = 0; + newCardTokenizedAt = 0; paymentResult = { id: data.id, amount: data.amount, @@ -777,6 +803,7 @@ )} {/if} +

Secure payment powered by Square

{/if} @@ -855,6 +882,7 @@ )} {/if} +

Secure payment powered by Square

{/if} diff --git a/frontend/src/lib/components/ui/policyPopover.svelte b/frontend/src/lib/components/ui/policyPopover.svelte index 3cbe1de..4e30d65 100644 --- a/frontend/src/lib/components/ui/policyPopover.svelte +++ b/frontend/src/lib/components/ui/policyPopover.svelte @@ -2,9 +2,15 @@ import type { Snippet } from 'svelte'; const { - trigger + trigger, + label = 'cancellation policy', + href = '/cancellation-policy' }: { 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(); let open = $state(false); @@ -36,7 +42,7 @@ {#if trigger} {@render trigger()} {:else} - cancellation policy + {label} {/if} @@ -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" > { open = false; - const win = window.open('/cancellation-policy?format=pdf', '_blank'); + const win = window.open(`${href}?format=pdf`, '_blank'); if (win) win.focus(); }} > diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index baf1b9f..f8f782e 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -212,6 +212,15 @@ // Cached nonce: tokenization is one-shot — a retry reuses this token instead // of re-tokenizing (the backend idempotency key dedups). 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 // retry (so a lost-response retry dedups instead of double-charging), @@ -269,13 +278,24 @@ async function buyGiftCard() { let newCardToken: string | undefined; + let verificationToken: string | undefined; if (buySelectedCard) { // saved card — nothing to tokenize } else if (buyCardSelection) { - // New-card mode: tokenize once per attempt, reuse the nonce on retry. - if (!buyNonce) { + // New-card mode: tokenize once per attempt, reuse the nonce + SCA + // verification token on retry (tokenization is one-shot; the backend + // idempotency key dedups). + if (!buyNonce || buyTokenAmount !== buyAmount * 100 || Date.now() - buyTokenizedAt > 240_000) { 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) { toast.error(err instanceof Error ? err.message : 'Card entry failed'); buyingGiftCard = false; @@ -283,6 +303,7 @@ } } newCardToken = buyNonce; + verificationToken = buyVerificationToken || undefined; } else { toast.error('Please select a payment method'); buyingGiftCard = false; @@ -312,6 +333,7 @@ recipient_email: buyRecipientEmail, ...(cardId ? { card_id: cardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}), + ...(verificationToken ? { verification_token: verificationToken } : {}), idempotency_key: buyIdempotencyKey }) }); @@ -324,6 +346,9 @@ buyKeyedAmount = 0; buyKeyedCard = ''; buyNonce = ''; + buyVerificationToken = ''; + buyTokenAmount = 0; + buyTokenizedAt = 0; await fetchGiftCardBalance(); } else { const errText = await res.text(); @@ -1848,7 +1873,11 @@ Saved Cards - Manage your saved payment methods + + Manage your saved payment methods — cards are stored securely with our + payment provider (Square). + + {#if loadingCards} @@ -2184,6 +2213,7 @@ > {buyingGiftCard ? 'Processing Payment...' : `Pay ${formatCurrency(buyAmount)}`} +

Secure payment powered by Square

{/if}
@@ -2376,6 +2406,23 @@ {/snippet} + + {#snippet trigger()} + + {/snippet} + diff --git a/frontend/src/routes/pay-tip/[id]/+page.svelte b/frontend/src/routes/pay-tip/[id]/+page.svelte index 0b873b3..273144d 100644 --- a/frontend/src/routes/pay-tip/[id]/+page.svelte +++ b/frontend/src/routes/pay-tip/[id]/+page.svelte @@ -58,6 +58,15 @@ // Cached nonce: tokenization is one-shot — a retry reuses this token instead // of re-tokenizing (the backend idempotency key dedups). 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( authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' @@ -203,19 +212,35 @@ } let newCardToken: string | undefined; + let verificationToken: string | undefined; if (selectedCardId) { // saved card — nothing to tokenize } else if (cardSelection) { - // New-card mode: tokenize once per attempt, reuse the nonce on retry. - if (!tipNonce) { + // New-card mode: tokenize once per attempt, reuse the nonce + SCA + // 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 { - 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) { toast.error(err instanceof Error ? err.message : 'Card entry failed'); return; } } newCardToken = tipNonce; + verificationToken = tipVerificationToken || undefined; } else { toast.error('Please select a payment method'); return; @@ -233,7 +258,8 @@ amount: amountInPence, idempotency_key: tipIdempotencyKey, ...(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`, { @@ -251,6 +277,9 @@ tipIdempotencyKey = ''; tipKeyedAmount = 0; tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; toast.success('Thank you for your tip!'); } catch (err) { paymentState = 'error'; diff --git a/frontend/src/routes/privacy-policy/+page.svelte b/frontend/src/routes/privacy-policy/+page.svelte new file mode 100644 index 0000000..983cfdf --- /dev/null +++ b/frontend/src/routes/privacy-policy/+page.svelte @@ -0,0 +1,285 @@ + + + + Privacy Policy + + + +
+
+

Privacy Policy

+ + DRAFT — for review + +
+

Last updated: August 2026

+ + {#if format === 'pdf' && pdfNotice} +

+ Generating PDF… If the print dialog does not appear, use Ctrl+P / Cmd+P. +

+ {/if} + +
+ +
+

1. Introduction

+

+ This Privacy Policy explains how Crussell Salon (“we”, “us”, + “our”) collects, uses, and protects your personal data when you use our + booking platform (“Platform”). +

+

+ We are committed to protecting your privacy and complying with the + UK General Data Protection Regulation (UK GDPR) and + Data Protection Act 2018. +

+
+

Data Controller

+

Crussell Salon

+

Edinburgh, Scotland

+

Email: help@crussell.invalid

+
+
+ + +
+

2. Data We Collect

+ +

2.1 Personal Data (Identifiable Information)

+

Account Information:

+
    +
  • Name (first, last)
  • +
  • Email address
  • +
  • Phone number
  • +
  • Date of birth (optional, for age verification)
  • +
  • Account ID (for balance recovery after deletion)
  • +
+

Booking Information:

+
    +
  • Appointment dates, times, services
  • +
  • Treatment notes and preferences
  • +
  • Allergy and patch test records (health data — special category)
  • +
  • Payment history and transaction records
  • +
+

Financial Data:

+
    +
  • Gift card codes and balances
  • +
  • Account balances
  • +
  • Payment transaction records (processed via Square, not stored by us)
  • +
  • Saved-card references (tokenised, stored with our payment provider Square — see §2.2)
  • +
  • Dormant balance records (Account ID only, no PII)
  • +
+ +

2.2 Saved Cards & Payment Provider (Square)

+

+ When you choose to save a card for next time, we store a tokenised + reference to your card with our payment processor, Square (a data + processor), rather than on our own systems. +

+
+

+ We never store full card numbers, card security codes (CVV), or card expiry data on our + own systems at any point. +

+ +

2.3 Special Category Data (Health Data)

+

We collect health-related information with your explicit consent:

+
    +
  • Allergy records
  • +
  • Patch test results
  • +
  • Medical conditions affecting treatment
  • +
  • Skin sensitivity notes
  • +
+

+ Legal basis: UK GDPR Article 9(2)(a) — Explicit consent
+ Retention: 7 years (insurance requirement) or account deletion (whichever + is later) +

+
+ + +
+

3. Data Retention & Deletion Process

+ +

3.1 Retention Schedule

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Data CategoryRetention PeriodLegal Basis
Active account dataAccount active + 2 yearsLegitimate interest
Inactive accounts (no balance)2 years idleGDPR storage limitation
Inactive accounts (with balance)5 years idleScottish prescriptive period
Financial records7 yearsHMRC requirement
Saved-card references (Square)Until user deletes card or account is deleted (Square-side) + Contract performance (Art 6(1)(b)); card-network card-on-file rules +
Allergy/health records7 yearsInsurance requirement
Dormant balancesIndefinite (Account ID only)Recovery mechanism
Marketing preferencesUntil withdrawnConsent
+
+ +

Deletion Process

+

Account deletion (your request):

+
    +
  1. You confirm deletion (warning about data loss).
  2. +
  3. If balance exists, transferred to dormant balance system.
  4. +
  5. Account ID sent to you via email.
  6. +
  7. Personal data anonymized (name, email, phone replaced with placeholders).
  8. +
  9. Financial records retained 7 years (HMRC) then aggregated.
  10. +
  11. Allergy records retained 7 years (insurance) then deleted.
  12. +
+

+ Saved cards: 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. +

+

Inactive account deletion (automatic):

+
    +
  1. Warning emails sent at 18/23 months (no balance) or 4/59 months (with balance).
  2. +
  3. If no activity, account deleted as above.
  4. +
  5. Dormant balance recoverable with Account ID.
  6. +
+
+ + +
+

4. Your Rights

+

Under UK GDPR, you have the right to:

+
    +
  • Access your personal data (Article 15)
  • +
  • Rectify inaccurate data (Article 16)
  • +
  • Erase your data (Article 17 — subject to HMRC/insurance retention)
  • +
  • Restrict processing (Article 18)
  • +
  • Data Portability (Article 20)
  • +
  • Object to processing (Article 21)
  • +
  • Withdraw Consent (Article 7(3))
  • +
+

+ 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. +

+

+ Questions about how we handle your data? Please use our official + Contact Channels + to get in touch. +

+
+
+
diff --git a/frontend/src/routes/tip/+page.svelte b/frontend/src/routes/tip/+page.svelte index 0bcef46..93a33d3 100644 --- a/frontend/src/routes/tip/+page.svelte +++ b/frontend/src/routes/tip/+page.svelte @@ -65,6 +65,15 @@ // Cached nonce: tokenization is one-shot — a retry reuses this token instead // of re-tokenizing (the backend idempotency key dedups). 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( authStore.currentUser?.role === 'verified_email' || authStore.currentUser?.role === 'affiliate' @@ -153,19 +162,35 @@ } let newCardToken: string | undefined; + let verificationToken: string | undefined; if (selectedCardId) { // saved card — nothing to tokenize } else if (cardSelection) { - // New-card mode: tokenize once per attempt, reuse the nonce on retry. - if (!tipNonce) { + // New-card mode: tokenize once per attempt, reuse the nonce + SCA + // 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 { - 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) { toast.error(err instanceof Error ? err.message : 'Card entry failed'); return; } } newCardToken = tipNonce; + verificationToken = tipVerificationToken || undefined; } else { toast.error('Please select a payment method'); return; @@ -183,7 +208,8 @@ amount: amountInPence, idempotency_key: tipIdempotencyKey, ...(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`, { @@ -201,6 +227,9 @@ tipIdempotencyKey = ''; tipKeyedAmount = 0; tipNonce = ''; + tipVerificationToken = ''; + tipTokenAmount = 0; + tipTokenizedAt = 0; toast.success('Thank you for your tip!'); } catch (err) { paymentState = 'error'; diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 093014e..7609399 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -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_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 -- ======================================= @@ -1960,7 +1980,12 @@ $$ LANGUAGE plpgsql; CREATE TABLE user_saved_cards ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(), 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, last_4 TEXT NOT NULL, exp_month INT NOT NULL, @@ -1970,7 +1995,12 @@ CREATE TABLE user_saved_cards ( deleted_at TIMESTAMPTZ, deleted_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL, 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); diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index ab2a112..9165490 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -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. | | 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. | -| 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). | --- diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 7d85a1e..4466f0d 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -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/dav` | SabreDAV CardDAV integration (build tags: `service_dev.go` / `service_prod.go`) | | `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. | --- @@ -1276,7 +1276,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user | `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/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/scheduling` | Working hours, exceptional groups, available hours, time blockers, gift card expiry cleanup, idle account cleanup | | `handlers/services` | Service eligibility (age + patch test filtering) | diff --git a/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md b/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md index 278ee1e..311f10e 100644 --- a/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md +++ b/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md @@ -1,10 +1,12 @@ # 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) **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) +> **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