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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user