Fix booking, tip, and terminal payment paths: cnon-direct charges, ccof customer_id, provisional terminal rows

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.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent e02567564e
commit b6f07fe6e8
3 changed files with 435 additions and 85 deletions
+96 -8
View File
@@ -11,6 +11,8 @@ import (
"log"
"log/slog"
"math"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
@@ -493,7 +495,7 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
rows, err := db.Conn.Query(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '')
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
@@ -522,6 +524,35 @@ func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID strin
}
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)
@@ -618,7 +649,18 @@ func (s *PaymentService) CreatePaymentMethodFromToken(ctx context.Context, userI
// 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
@@ -627,6 +669,7 @@ func (s *PaymentService) ensureSquareCustomer(ctx context.Context, userID string
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) {
@@ -644,6 +687,18 @@ func (s *PaymentService) ensureSquareCustomer(ctx context.Context, userID string
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
}
@@ -656,17 +711,43 @@ func (s *PaymentService) EnsureSquareCustomer(ctx context.Context, userID string
return s.ensureSquareCustomer(ctx, userID)
}
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
// P14: SaveCardForUser is only ever called in save-card flows, so lazily
// ensure the Square customer exists and persist its id on the saved-card
// row for reuse by subsequent card saves from the same user.
squareCustomerID, err := s.ensureSquareCustomer(ctx, userID)
// 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, `
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())
@@ -689,7 +770,7 @@ func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string)
func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, cardID, userID string) (*SavedCard, error) {
var c SavedCard
err := q.QueryRow(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, COALESCE(square_customer_id, '')
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)
@@ -700,4 +781,11 @@ func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, c
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