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 // 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*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, 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) 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 } // 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 } 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 // ON CONFLICT (user_id, square_card_id) DO UPDATE — two cases: // 1. Same-key retry of a save_card=true charge (resolveChargeSource runs // CreateCardOnFile with a deterministic sha256 key, so Square returns the // SAME ccof: id): a plain INSERT would violate the per-user UNIQUE // constraint (N-8) and DO NOTHING would 500 via the fallback select. // 2. A soft-deleted card (DeletePaymentMethod set deleted_at but the row // still occupies the UNIQUE slot): the DO UPDATE revives it // (deleted_at/retained_until = NULL), matching CreatePaymentMethodFromToken. // 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, 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()) ON CONFLICT (user_id, square_card_id) DO UPDATE SET square_customer_id = EXCLUDED.square_customer_id, brand = EXCLUDED.brand, last_4 = EXCLUDED.last_4, exp_month = EXCLUDED.exp_month, exp_year = EXCLUDED.exp_year, fingerprint = EXCLUDED.fingerprint, deleted_at = NULL, retained_until = NULL WHERE user_saved_cards.user_id = EXCLUDED.user_id 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 // 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