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.
909 lines
35 KiB
Go
909 lines
35 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"
|
||
)
|
||
|
||
// 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
|