Files
Crussell/backend/handlers/payments/service.go
T
popertots 3866cc5963 fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation
Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet
tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

909 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// Payment-fee formulas used by CalculateFees (integer pence math):
// - online: amount×feeRatePencePerPound/feeDenominator + minFeePence → 1.4% + 25p
// - in-person (terminal/cash): amount×terminalFeeRatePencePerPound/terminalFeeDenominator → 1.75%
const (
feeRatePencePerPound = 14
feeDenominator = 1000
minFeePence = 25
terminalFeeRatePencePerPound = 175
terminalFeeDenominator = 10000
)
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
// SquareSourceID is the exact source_id (cnon: nonce or ccof: card id) sent
// in the CreatePayment call, stored on the pending row so the sweep can
// replay the charge with an IDENTICAL request body under the same
// idempotency key.
SquareSourceID *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*feeRatePencePerPound/feeDenominator)+minFeePence) / 100.0
}
return float64(amount*terminalFeeRatePencePerPound/terminalFeeDenominator) / 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, square_source_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
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,
record.SquareSourceID,
).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)
// A tip is money paid beyond the booking total — gratuity, not payment
// toward the booking. It must not reduce the balance owed: excluding it
// here keeps paid_amount / remaining_amount aligned with the
// authoritative tip-excluded GetBookingRemainingBalancePence (a tip
// row would otherwise understate the remaining balance and an admin
// relying on the summary could under-collect). Discount rows stay
// counted (real value applied toward the booking), matching the VAT
// ledger tests.
if p.Status == "completed" && p.PaymentType != "tip" {
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
// RemainingAmount is routed through the authoritative tip-excluded balance
// (GetBookingRemainingBalancePence — the same value the charge guards use,
// including pending refunds and the LEAST/GREATEST clamps) instead of the
// naive total - paid + refunded: the naive sum counts tips as "paid", so a
// booking with a £20 tip would report £30 remaining where the authoritative
// balance is £50 — an admin relying on the summary could under-collect
// (finding 4). Falls back to the legacy formula only when the authoritative
// read fails (e.g. a missing booking).
if remaining, remErr := s.GetBookingRemainingBalancePence(ctx, bookingID); remErr == nil {
summary.RemainingAmount = float64(remaining) / 100.0
} else {
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
}
// GetBookingRefundableAmountPence returns the total refundable amount (in
// pence) for a booking: the sum of completed non-tip payments minus already
// refunded (completed + pending). Tips are excluded — they are gratuity above
// the booking total and are not refundable via the admin refund endpoint.
func (s *PaymentService) GetBookingRefundableAmountPence(ctx context.Context, bookingID string) (int64, error) {
var amount float64
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(p.amount), 0) - COALESCE((
SELECT SUM(r.amount)
FROM refunds r
JOIN payments p2 ON r.payment_id = p2.id
WHERE p2.booking_id = $1 AND r.status IN ('completed', 'pending')
AND p2.payment_type <> 'tip'
), 0)
FROM payments p
WHERE p.booking_id = $1 AND p.status = 'completed' AND p.payment_type <> 'tip'
AND p.payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).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')
AND payment_type <> 'tip'
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')
-- Tips are excluded from TotalPaid above, so a tip refund must
-- equally be excluded here — otherwise a refunded tip would
-- subtract from the paid total and re-open booking capacity.
AND p.payment_type <> 'tip'
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) GetBookingRemainingBalancePence(ctx context.Context, bookingID string) (int64, error) {
var remainingPence 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'
),
refunded_total AS (
SELECT COALESCE(SUM(r.amount), 0) AS refunded_pounds
FROM refunds r
JOIN payments p ON r.payment_id = p.id
-- Pending refunds count too (matching GetBookingPaymentInfo): a
-- refund in flight is money that will come back, so the remaining
-- capacity must not be understated while it settles — understating
-- it blocks a legitimate retry (finding 7). A tip refund returns
-- gratuity, not booking money — it must not re-open booking charge
-- capacity (mirror of the paid_total tip exclusion above).
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
AND p.payment_type <> 'tip'
)
-- Money-safety (M-cap): refunds return money, so they re-open booking
-- capacity — remaining = total - paid + refunded. LEAST clamps the cap
-- at the booking total so the M-cap can never allow a charge beyond the
-- booking's full value even in the pathological case where completed
-- refunds exceed payments, and GREATEST floors at 0 so a fully-paid
-- (or over-paid) booking can never be charged again.
SELECT GREATEST(0, ROUND(LEAST(bt.total_pounds - pt.paid_pounds + rt.refunded_pounds, bt.total_pounds) * 100))::bigint
FROM booking_total bt, paid_total pt, refunded_total rt
`, bookingID).Scan(&remainingPence)
if err != nil {
return 0, err
}
return remainingPence, 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
}
// upsertUserSavedCard persists a tokenized card as a saved card for the user,
// shared by CreatePaymentMethodFromToken and SaveCardForUser so the
// ON CONFLICT upsert semantics cannot drift between the two save paths.
// ON CONFLICT (user_id, square_card_id) DO UPDATE handles the two cases a
// plain INSERT cannot: a response-lost retry re-tokenizing the SAME card for
// the SAME user (CreateCardOnFile's deterministic key returns the same ccof:
// id), and a soft-deleted card still occupying the per-user UNIQUE slot (the
// DO UPDATE revives it). 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. defaultIfFirst marks the user's FIRST card as
// default (NOT EXISTS) — true for CreatePaymentMethodFromToken (the save-card
// endpoint makes the first card default), false for SaveCardForUser (a card
// saved during a charge is never made default). Returns the row id and whether
// the row is default.
func upsertUserSavedCard(ctx context.Context, q querier, userID, squareCustomerID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string, defaultIfFirst bool) (id string, isDefault bool, err error) {
err = q.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,
CASE WHEN $9 THEN NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL) ELSE false END
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, squareCardID, brand, last4, expMonth, expYear, fingerprint, squareCustomerID, defaultIfFirst).Scan(&id, &isDefault)
if err != nil {
return "", false, err
}
return id, isDefault, 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
// The shared upsert handles the ON CONFLICT cases a plain INSERT cannot
// (response-lost retry re-tokenizing the same card, and soft-deleted rows
// still occupying the per-user UNIQUE slot). The first card a user saves
// becomes default (defaultIfFirst=true).
savedCardID, isDefault, err = upsertUserSavedCard(ctx, db.Conn, userID, squareCustomerID, cardOnFile.CardID, cardOnFile.Brand, cardOnFile.Last4, cardOnFile.ExpMonth, cardOnFile.ExpYear, cardOnFile.Fingerprint, true)
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) {
// The shared upsert handles the ON CONFLICT cases a plain INSERT cannot:
// a same-key retry of a save_card=true charge (resolveChargeSource runs
// CreateCardOnFile with a deterministic sha256 key, so Square returns the
// SAME ccof: id), and a soft-deleted card still occupying the per-user
// UNIQUE slot (the DO UPDATE revives it). A card saved during a charge is
// never made default (defaultIfFirst=false).
id, _, err := upsertUserSavedCard(ctx, db.Conn, userID, squareCustomerID, squareCardID, brand, last4, expMonth, expYear, fingerprint, false)
if err != 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