Files
Crussell/backend/handlers/payments/service.go
T
popertots a8d54f1e2a Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK
Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
2026-08-22 00:34:49 +01:00

822 lines
30 KiB
Go

package payments
import (
"context"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
)
type SavedCard struct {
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{}
func NewPaymentService() *PaymentService {
return &PaymentService{}
}
type PaymentRecord struct {
ID string
BookingID string
PaymentType string
PaymentMethod string
VendorCode *string
InvoiceNumber *int
Status string
Amount float64
CardLast4 string
IsVATApplicable bool
VATRate *float64
VATAmount *float64
NetAmount *float64
UserSavedCardID *string
SquarePaymentID *string
IdempotencyKey *string
Fees float64
CreatedAt time.Time
UpdatedAt time.Time
CreatedBy *string
GiftCardID *string
}
type RefundRecord struct {
ID string
PaymentID string
BookingID string
Amount float64
SquareRefundID *string
Status string
Reason string
Origin string // 'manual' (admin handler) or 'cancellation' (cancellation loop)
IdempotencyKey *string
CreatedBy *string
CreatedAt time.Time
}
type PaymentSummary struct {
TotalAmount float64
PaidAmount float64
RefundedAmount float64
RemainingAmount float64
TotalVATAmount float64
TotalNetAmount float64
Payments []PaymentRecord
Refunds []RefundRecord
}
func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
if method == "online" {
return float64((amount*14/1000)+25) / 100.0
}
return float64(amount*175/10000) / 100.0
}
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) {
return s.insertPaymentRecord(ctx, record, giftCardID, db.Conn)
}
// CreatePaymentRecordTx is identical to CreatePaymentRecord but accepts a
// pgx.Tx so the insert is part of an existing database transaction. This
// is used by CreateBookingPayment when inserting multiple split records
// from a single Square charge — wrapping both inserts in a transaction
// ensures atomicity (both succeed or both roll back).
func (s *PaymentService) CreatePaymentRecordTx(ctx context.Context, tx pgx.Tx, record PaymentRecord, giftCardID *string) (string, error) {
return s.insertPaymentRecord(ctx, record, giftCardID, tx)
}
// insertPaymentRecord holds the common INSERT logic. The querier parameter
// accepts either *pgxpool.Pool or pgx.Tx so callers can choose transactional
// or non-transactional insertion.
type querier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func (s *PaymentService) insertPaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string, q querier) (string, error) {
var bookingID *string
if record.BookingID != "" {
bookingID = &record.BookingID
}
var id string
err := q.QueryRow(ctx, `
INSERT INTO payments (
booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
gift_card_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id
`,
bookingID,
record.PaymentType,
record.PaymentMethod,
record.VendorCode,
record.InvoiceNumber,
record.Status,
record.Amount,
record.IsVATApplicable,
record.VATRate,
record.VATAmount,
record.NetAmount,
record.UserSavedCardID,
record.SquarePaymentID,
record.IdempotencyKey,
record.Fees,
record.CreatedAt,
record.UpdatedAt,
record.CreatedBy,
giftCardID,
).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRecord) (string, error) {
var id string
err := db.Conn.QueryRow(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)
RETURNING id
`,
record.PaymentID,
record.BookingID,
record.Amount,
record.SquareRefundID,
record.Status,
record.Reason,
record.IdempotencyKey,
record.CreatedBy,
record.CreatedAt,
record.Origin,
).Scan(&id)
if err != nil {
return "", err
}
return id, nil
}
func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID string) (*PaymentSummary, error) {
summary := &PaymentSummary{
Payments: []PaymentRecord{},
Refunds: []RefundRecord{},
}
var totalAmount float64
err := db.Conn.QueryRow(ctx, `
SELECT total_amount FROM bookings WHERE id = $1
`, bookingID).Scan(&totalAmount)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
summary.TotalAmount = totalAmount
rows, err := db.Conn.Query(ctx, `
SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number,
p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount,
p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by,
p.gift_card_id
FROM payments p
LEFT JOIN user_saved_cards usc ON p.user_saved_card_id = usc.id
WHERE p.booking_id = $1
ORDER BY p.created_at ASC
`, bookingID)
if err != nil {
return nil, err
}
defer rows.Close()
var paidAmount, totalVATAmount, totalNetAmount float64
for rows.Next() {
var p PaymentRecord
err := rows.Scan(
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
&p.Status, &p.Amount, &p.CardLast4, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
)
if err != nil {
return nil, err
}
summary.Payments = append(summary.Payments, p)
if p.Status == "completed" {
paidAmount += p.Amount
if p.VATAmount != nil {
totalVATAmount += *p.VATAmount
}
if p.NetAmount != nil {
totalNetAmount += *p.NetAmount
} else if p.VATAmount == nil {
// Only fallback to gross amount if no VAT was applied at all.
// When VAT is present, net_amount is always set by apply_vat_to_payment,
// so this fallback only applies to non-VAT payments where net == gross.
totalNetAmount += p.Amount
}
}
}
summary.PaidAmount = paidAmount
summary.TotalVATAmount = totalVATAmount
summary.TotalNetAmount = totalNetAmount
refundRows, err := db.Conn.Query(ctx, `
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, idempotency_key, created_by, created_at, origin
FROM refunds
WHERE booking_id = $1 AND status = 'completed'
ORDER BY created_at ASC
`, bookingID)
if err != nil {
return nil, err
}
defer refundRows.Close()
var refundedAmount float64
for refundRows.Next() {
var r RefundRecord
err := refundRows.Scan(
&r.ID, &r.PaymentID, &r.BookingID, &r.Amount, &r.SquareRefundID,
&r.Status, &r.Reason, &r.IdempotencyKey, &r.CreatedBy, &r.CreatedAt, &r.Origin,
)
if err != nil {
return nil, err
}
summary.Refunds = append(summary.Refunds, r)
refundedAmount += r.Amount
}
summary.RefundedAmount = refundedAmount
summary.RemainingAmount = totalAmount - paidAmount + refundedAmount
return summary, nil
}
func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempotencyKey string) (*PaymentRecord, error) {
var p PaymentRecord
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
gift_card_id
FROM payments
WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(
&p.ID, &p.BookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.GiftCardID,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) {
var p PaymentRecord
// booking_id and gift_card_id are NULL for gift-card purchases — scan into
// NullString to avoid "cannot scan NULL into *string".
var bookingID, giftCardID sql.NullString
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
gift_card_id
FROM payments
WHERE idempotency_key = $1
`, idempotencyKey).Scan(
&p.ID, &bookingID, &p.PaymentType, &p.PaymentMethod, &p.VendorCode, &p.InvoiceNumber,
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &giftCardID,
)
p.BookingID = bookingID.String
if giftCardID.Valid {
p.GiftCardID = &giftCardID.String
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (*PaymentRecord, error) {
var p PaymentRecord
// booking_id / vendor_code / gift_card_id / invoice_number are nullable
// (e.g. gift-card purchases have no booking). Scan into Null* and map so a
// NULL value doesn't 500 the scan (N-3: the same fix class as
// CheckIdempotencyByKey).
var bookingID, vendorCode, giftCardID sql.NullString
var invoiceNumber sql.NullInt64
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
gift_card_id
FROM payments
WHERE id = $1
`, paymentID).Scan(
&p.ID, &bookingID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
&p.Status, &p.Amount, &p.IsVATApplicable, &p.VATRate, &p.VATAmount, &p.NetAmount,
&p.UserSavedCardID, &p.SquarePaymentID, &p.IdempotencyKey, &p.Fees,
&p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &giftCardID,
)
if err != nil {
return nil, err
}
p.BookingID = bookingID.String
if vendorCode.Valid {
p.VendorCode = &vendorCode.String
}
if giftCardID.Valid {
p.GiftCardID = &giftCardID.String
}
if invoiceNumber.Valid {
n := int(invoiceNumber.Int64)
p.InvoiceNumber = &n
}
return &p, nil
}
// GetAlreadyRefundedAmount returns the total refunded amount (in pence) for a
// payment, counting both 'completed' and 'pending' refunds. Pending refunds are
// counted because a Square call may already be in flight for them — excluding
// them would let a concurrent refund over-refund the payment. 'failed' refunds
// are excluded: they were definitively rejected by Square and must not block
// future refund attempts.
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
var amount float64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM refunds
WHERE payment_id = $1 AND status IN ('completed', 'pending')
`, paymentID).Scan(&amount)
if err != nil {
return 0, err
}
return int64(math.Round(amount * 100)), nil
}
// HasCompletedPayment reports whether the booking has a completed non-tip
// payment. 'tip' is deliberately excluded: a tip-only booking (no deposit/full
// payment) must NOT be treated as "already paid" for the purposes of allowing a
// tip. Note buildSplitRecords' overflow-tip records are also invisible here —
// intended (a tip is never evidence of payment), but a caller must not assume
// this covers every payment_type.
func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) {
var count int
err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_type IN ('full', 'deposit', 'balance', 'partial')
`, bookingID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string) (string, error) {
var status string
err := db.Conn.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
if err != nil {
return "", err
}
return status, nil
}
// BookingPaymentInfo holds booking-level data needed for payment split decisions.
type BookingPaymentInfo struct {
StartTime time.Time
TotalAmount float64
TotalPaid float64
Status string
}
// GetBookingPaymentInfo fetches the booking start time, total service amount, and
// total net paid (completed payments minus completed/pending refunds) for a
// booking. Refunds are subtracted so cancellation refunds never double-refund
// money that has already been returned (e.g. via a manual admin refund).
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
var info BookingPaymentInfo
err := db.Conn.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(b.total_amount, 0),
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
FROM bookings b
LEFT JOIN (
SELECT booking_id, SUM(amount) AS total_paid
FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') GROUP BY booking_id
) pt ON b.id = pt.booking_id
LEFT JOIN (
SELECT p.booking_id, SUM(r.amount) AS total_refunded
FROM refunds r
JOIN payments p ON r.payment_id = p.id
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
GROUP BY p.booking_id
) rr ON b.id = rr.booking_id
WHERE b.id = $1
`, bookingID).Scan(&info.StartTime, &info.Status, &info.TotalAmount, &info.TotalPaid)
if err != nil {
return nil, err
}
return &info, nil
}
func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) {
var userID string
err := db.Conn.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID)
if err != nil {
return "", err
}
return userID, nil
}
func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) {
var remainingCents int64
err := db.Conn.QueryRow(ctx, `
WITH booking_total AS (
SELECT total_amount AS total_pounds FROM bookings WHERE id = $1
),
paid_total AS (
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
`, bookingID).Scan(&remainingCents)
if err != nil {
return 0, err
}
return remainingCents, nil
}
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
rows, err := db.Conn.Query(ctx, `
SELECT id, COALESCE(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
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
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, &c.SquareCustomerID)
if err != nil {
return nil, err
}
cards = append(cards, c)
}
if cards == nil {
cards = []SavedCard{}
}
return cards, nil
}
func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID string) error {
// Load the Square card id and disable the card at Square BEFORE the local
// soft-delete (R8). Without this the card stays ENABLED at Square and keeps
// accepting ccof: charges even though the user deleted it locally — the
// account-deletion path already calls DeleteCardOnFile; this mirrors it for
// single-card deletes. The Square call is best-effort: a local delete must
// never be blocked by a Square failure. A NOT_FOUND answer means Square no
// longer has the card (nothing to disable); any other error is logged and
// ignored so the local delete proceeds regardless.
var sqCardID sql.NullString
err := db.Conn.QueryRow(ctx, `
SELECT square_card_id FROM user_saved_cards
WHERE id = $1 AND user_id = $2
`, cardID, userID).Scan(&sqCardID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
// ErrNoRows: the card is not owned by this user — the soft-delete below is
// a silent no-op (matching the pre-R8 behaviour), so skip the Square call.
if sqCardID.Valid && sqCardID.String != "" {
if err := SquareClient.DeleteCardOnFile(ctx, sqCardID.String); err != nil {
msg := strings.ToUpper(err.Error())
if square.ErrorCode(err) == "NOT_FOUND" || strings.Contains(msg, "NOT_FOUND") || strings.Contains(msg, "NOT FOUND") {
// Square already removed/disabled the card — nothing to do.
} else {
// TokenPrefix redacts the ccof: card token — the full ID must
// never reach logs.
slog.Warn("failed to disable Square card on local delete — card may remain enabled at Square", "square_card_id", square.TokenPrefix(sqCardID.String), "err", err)
}
}
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
return err
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
retainedUntil := clock.Now().Add(7 * 365 * 24 * time.Hour)
_, err = tx.Exec(ctx, `
UPDATE user_saved_cards
SET deleted_at = NOW(), deleted_by = $1, retained_until = $2
WHERE id = $3 AND user_id = $1
`, userID, retainedUntil, cardID)
if err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err)
return err
}
return nil
}
func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userID, cardToken string) (*SavedCard, error) {
// 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.
//
// 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 (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, 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 (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, squareCustomerID).Scan(&savedCardID, &isDefault)
if err != nil {
return nil, fmt.Errorf("failed to save card: %w", err)
}
return &SavedCard{
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
}
// 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.
//
// R7: the created id is written back to the user's saved-card rows (when any
// exist) AND cached in a package-level map, so a second ensureSquareCustomer
// call in the same request flow (e.g. the handler's save-card branch followed
// by SaveCardForUser) never re-hits the DB and never re-mints a customer. The
// cache is process-local and dev-friendly; the row write makes it durable for
// the next process/request.
func (s *PaymentService) ensureSquareCustomer(ctx context.Context, userID string) (string, error) {
if v, ok := squareCustomerCache.Load(userID); ok {
return v.(string), nil
}
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 {
squareCustomerCache.Store(userID, customerID.String)
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)
}
// Persist the minted id so the NEXT process/request reuses it instead of
// re-running CreateCustomer (the cache above only serves this process).
// Best-effort: the cache covers the immediate double-ensure within one
// request, and the save-card INSERT below carries the id anyway.
if _, upErr := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_customer_id = $1
WHERE user_id = $2 AND square_customer_id IS NULL
`, customer.ID, userID); upErr != nil {
log.Printf("Failed to persist Square customer id for user %s (non-fatal): %v", userID, upErr)
}
squareCustomerCache.Store(userID, customer.ID)
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)
}
// EnsureSquareCustomerForSavedCard returns the Square customer id for a
// saved-card row, lazily provisioning + persisting one when the row predates
// P14 (square_customer_id empty). A ccof: source can NEVER be charged without
// a CustomerID — Square rejects the payment — so every saved-card charge path
// calls this before CreatePayment. Provisioning failure aborts the charge.
func (s *PaymentService) EnsureSquareCustomerForSavedCard(ctx context.Context, savedCardID, userID string) (string, error) {
var customerID sql.NullString
if err := db.Conn.QueryRow(ctx, `
SELECT square_customer_id FROM user_saved_cards
WHERE id = $1 AND user_id = $2
`, savedCardID, userID).Scan(&customerID); err != nil {
return "", err
}
if customerID.Valid && customerID.String != "" {
return customerID.String, nil
}
provisioned, err := s.EnsureSquareCustomer(ctx, userID)
if err != nil {
return "", err
}
if _, upErr := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_customer_id = $1
WHERE id = $2 AND user_id = $3
`, provisioned, savedCardID, userID); upErr != nil {
return "", upErr
}
return provisioned, nil
}
// SaveCardForUser persists a tokenized card as a saved card for the user.
// squareCustomerID is the user's provisioned Square customer profile id
// (P14) — the caller has already ensured it via EnsureSquareCustomer, so this
// method NEVER re-provisions (R7: a second ensureSquareCustomer would re-query
// the DB and, on a first-save flow, re-run CreateCustomer).
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCustomerID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
var id string
// ON CONFLICT (user_id, square_card_id) DO NOTHING: resolveChargeSource
// runs CreateCardOnFile (deterministic sha256 key) + SaveCardForUser on a
// same-key retry of a save_card=true charge. Square returns the SAME ccof:
// id on the retry, so a plain INSERT would violate the per-user UNIQUE
// constraint (N-8). Upsert instead so the retry returns the existing row;
// a distinct card id is a brand-new row, never a mutation of another
// user's card.
err := db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (
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())
ON CONFLICT (user_id, square_card_id) DO NOTHING
RETURNING id
`, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if err := db.Conn.QueryRow(ctx, `
SELECT id FROM user_saved_cards
WHERE user_id = $1 AND square_card_id = $2 AND deleted_at IS NULL
`, userID, squareCardID).Scan(&id); err != nil {
return "", err
}
return id, nil
}
return "", err
}
return id, nil
}
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
return s.GetCardByIDQuerier(ctx, db.Conn, cardID, userID)
}
// GetCardByIDQuerier is identical to GetCardByID but accepts a db.Querier
// so the lookup can be performed inside a transaction. Callers inside an
// existing transaction should pass their tx variable instead of db.Conn.
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
var c SavedCard
err := q.QueryRow(ctx, `
SELECT id, COALESCE(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, &c.SquareCustomerID)
if err != nil {
return nil, err
}
return &c, nil
}
// squareCustomerCache is a package-level process-local cache of
// userID → Square customer id, populated on the first successful provisioning
// (R7). It prevents a second ensureSquareCustomer call in the same request
// flow (or a rapid retry) from re-running the DB query and re-minting a
// customer. The durable record remains the user_saved_cards row.
var squareCustomerCache sync.Map
// InvalidateSquareCustomerCache drops a user's cached Square customer id,
// e.g. after GDPR erasure deletes the customer at Square and NULLs the DB
// columns. Without it, the process-local cache would keep serving the erased
// user's stale customer id on a later save-card flow — reusing a deleted
// Square customer id (fail-closed at Square, but incorrect) and letting the
// erased identity resurface in memory. Called by the erasure handlers
// (handlers/user, handlers/scheduling) after the local anonymization commits.
func InvalidateSquareCustomerCache(userID string) {
squareCustomerCache.Delete(userID)
}
var SquareClient square.SquareClient