One-off new-card charges now pass the cnon: nonce directly as source_id (no card-on-file, no customer). Save-card charges forward customer_id; legacy saved cards lazily provision a Square customer before charging (EnsureSquareCustomerForSavedCard). Terminal checkouts insert the terminal_checkouts row FIRST with a provisional tmp- id, then update with the real checkout_id, closing the crash window. GetDiscountPreviewHandler gains the fail-closed ownership check (IDOR). DeletePaymentMethod disables the card at Square before soft-delete. Sweep resolves provisional/tmp- terminal rows without a Square round-trip. GetCheckoutStatus rejects tmp- ids.
792 lines
28 KiB
Go
792 lines
28 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 {
|
|
slog.Warn("failed to disable Square card on local delete — card may remain enabled at Square", "square_card_id", 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
|
|
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())
|
|
RETURNING id
|
|
`, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
|
|
|
|
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
|
|
|
|
var SquareClient square.SquareClient
|