Documentation
¶
Overview ¶
Package paymentmethods is the customer-facing view of payment methods.
payment_methods rows are the canonical many-to-one store (multiple cards per customer, one flagged default). The existing customer_payment_setups table stays as the 1:1 denorm summary that billing reads for the "one default card" path — this package writes both on every mutation.
The operator routes run under API-key auth: tenantID comes from the auth ctx and customerID from the URL path; both are pushed into BeginTx.
Index ¶
- type AuditWriter
- type CardMetadata
- type CooldownGate
- type CustomerLookup
- type CustomerStripeLink
- type Handler
- type PaymentMethod
- type PostgresStore
- func (s *PostgresStore) Detach(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
- func (s *PostgresStore) DetachAndRebalance(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, *PaymentMethod, error)
- func (s *PostgresStore) Get(ctx context.Context, tenantID, pmID string) (PaymentMethod, error)
- func (s *PostgresStore) List(ctx context.Context, tenantID, customerID string) ([]PaymentMethod, error)
- func (s *PostgresStore) SetDefault(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
- func (s *PostgresStore) Upsert(ctx context.Context, tenantID string, pm PaymentMethod) (PaymentMethod, error)
- type Service
- func (s *Service) AttachForWebhook(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) error
- func (s *Service) AttachFromSetupIntent(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) (PaymentMethod, error)
- func (s *Service) CreateSetupIntent(ctx context.Context, tenantID, customerID string) (clientSecret, setupIntentID string, err error)
- func (s *Service) CreateSetupSession(ctx context.Context, tenantID, customerID, returnURL string) (checkoutURL, sessionID string, err error)
- func (s *Service) Detach(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
- func (s *Service) List(ctx context.Context, tenantID, customerID string) ([]PaymentMethod, error)
- func (s *Service) SetAuditLogger(a AuditWriter)
- func (s *Service) SetDefault(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
- func (s *Service) SetPortalBaseURL(u string)
- type SetupLinkEmailer
- type Store
- type StripeAPI
- type StripeAdapter
- func (a *StripeAdapter) CreateSetupCheckoutSession(ctx context.Context, stripeCustomerID, successURL, cancelURL string, ...) (string, string, error)
- func (a *StripeAdapter) CreateSetupIntent(ctx context.Context, stripeCustomerID string, metadata map[string]string) (string, string, error)
- func (a *StripeAdapter) DetachPaymentMethod(ctx context.Context, stripePaymentMethodID string) error
- func (a *StripeAdapter) EnsureStripeCustomer(ctx context.Context, tenantID, customerID string) (string, error)
- func (a *StripeAdapter) FetchPaymentMethodCard(ctx context.Context, stripePaymentMethodID string) (CardMetadata, error)
- func (a *StripeAdapter) SetDefaultPaymentMethod(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) error
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type AuditWriter ¶
type AuditWriter interface {
Log(ctx context.Context, tenantID, action, resourceType, resourceID, resourceLabel string, metadata map[string]any) error
}
AuditWriter is the narrow audit surface paymentmethods needs. Declared here (not imported from internal/audit) so the package stays decoupled and testable with a fake. Production wires *audit.Logger via SetAuditLogger in router.go.
type CardMetadata ¶
CardMetadata bundles the card facts the Stripe adapter returns at attach time. Fingerprint is the dedupe key — Stripe's stable hash of the card number (CVC + expiry don't affect it). Empty when the PM isn't a card type (legacy / bank account / etc.).
type CooldownGate ¶
type CooldownGate interface {
AllowKey(ctx context.Context, key string) (remaining int, resetAt time.Time, allowed bool)
}
CooldownGate enforces a per-customer minimum interval between send-setup-email calls. Defends against operator double-click and abusive automation. Satisfied by *middleware.RateLimiter via its AllowKey method. Optional: nil = no cooldown (local dev / tests).
type CustomerLookup ¶
type CustomerLookup interface {
GetForSetupLink(ctx context.Context, tenantID, customerID string) (email, displayName string, err error)
}
CustomerLookup resolves the recipient's email + display name for the operator "send setup email" flow. Implemented by *customer.PostgresStore via a thin adapter in router.go.
type CustomerStripeLink ¶
type CustomerStripeLink interface {
Get(ctx context.Context, tenantID, customerID string) (domain.Customer, error)
SetStripeCustomerID(ctx context.Context, tenantID, customerID, stripeCustomerID string) error
// GetBillingProfile returns the billing profile (legal_name, phone,
// address, tax_status) so EnsureStripeCustomer can pre-populate
// the Stripe Customer object at create time instead of leaving
// email/name/address null. ErrNotFound = customer doesn't have a
// profile yet — adapter passes only the Customer-level fields.
GetBillingProfile(ctx context.Context, tenantID, customerID string) (domain.CustomerBillingProfile, error)
}
CustomerStripeLink is the narrow surface the adapter uses to read/write the Stripe Customer ID mapping. customers.stripe_customer_id has been the canonical home since migration 0096; this interface abstracts the customer store so the adapter doesn't gain a hard dependency on internal/customer (which would create a cycle).
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler exposes the operator-side payment-method surface under /v1/customers/{customer_id}/payment-methods: list / set-default / detach, plus a "send setup link" affordance (email or copy-link) that mints a Stripe Checkout setup URL the operator hands to the customer — card data goes browser → Stripe, never the operator dashboard.
The customer lookup (resolves the recipient email + display name) and the email sender are optional: a Handler without them refuses the email path with 503 and still serves list/setDefault/detach.
func NewHandler ¶
func (*Handler) OperatorRoutes ¶
OperatorRoutes mounts the operator-side PM surface under /v1/customers/{customer_id}/payment-methods. Same Service backs both surfaces — the only difference is where customer_id comes from (portal session ctx vs URL path) and how auth is gated (portal session vs operator API key). Industry parity: Chargebee, Lago, Orb all expose operator-side list/setDefault/detach + a "send setup link" affordance. Card-data entry stays customer-side via Stripe Checkout, keeping every tenant in PCI SAQ-A scope. See docs/adr/ for the PCI-scope rationale.
func (*Handler) SetAuditLogger ¶
func (h *Handler) SetAuditLogger(a AuditWriter)
SetAuditLogger wires audit on operator-initiated actions.
func (*Handler) SetCooldown ¶
func (h *Handler) SetCooldown(c CooldownGate)
SetCooldown wires the per-customer cooldown gate on send-setup-email. Without it, an operator double-click could send two emails ~30s apart. Optional in tests / local dev.
func (*Handler) SetCustomerLookup ¶
func (h *Handler) SetCustomerLookup(c CustomerLookup)
SetCustomerLookup wires the customer-lookup dependency. Required for the operator send-setup-email endpoint; nil = endpoint refuses with 503.
func (*Handler) SetEmailer ¶
func (h *Handler) SetEmailer(e SetupLinkEmailer)
SetEmailer wires the email sender. Required for operator send-setup-email; nil = endpoint refuses with 503.
type PaymentMethod ¶
type PaymentMethod struct {
ID string
TenantID string
Livemode bool
CustomerID string
StripePaymentMethodID string
Type string // "card" for now; other Stripe PM types later
CardBrand string
CardLast4 string
CardExpMonth int
CardExpYear int
// CardFingerprint is Stripe's stable hash of the card number
// (CVC + expiry don't affect it). Same physical card → same
// fingerprint across re-tokenizations. Used by Upsert to dedupe:
// if a customer re-runs Add and produces a PM with the same
// fingerprint as an existing active row, the old row is detached
// and the new one inherits its is_default flag. Empty for legacy
// rows attached before the fingerprint plumbing existed (will
// re-collapse the next time the customer re-attaches the card).
CardFingerprint string
IsDefault bool
DetachedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
PaymentMethod mirrors one row in payment_methods.
func (PaymentMethod) IsActive ¶
func (p PaymentMethod) IsActive() bool
IsActive — convenience for "attached and usable".
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
func NewPostgresStore ¶
func NewPostgresStore(db *postgres.DB) *PostgresStore
func (*PostgresStore) Detach ¶
func (s *PostgresStore) Detach(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
Detach is idempotent by design. Re-detaching a row is a no-op write (detached_at is kept at its original timestamp).
func (*PostgresStore) DetachAndRebalance ¶
func (s *PostgresStore) DetachAndRebalance(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, *PaymentMethod, error)
DetachAndRebalance does the detach and the replacement-default promotion in one transaction so the invariant "≥1 active PM ⇒ exactly one default" holds across the whole operation — no committed window with active cards but no default. See the Store interface for the contract.
func (*PostgresStore) Get ¶
func (s *PostgresStore) Get(ctx context.Context, tenantID, pmID string) (PaymentMethod, error)
func (*PostgresStore) List ¶
func (s *PostgresStore) List(ctx context.Context, tenantID, customerID string) ([]PaymentMethod, error)
func (*PostgresStore) SetDefault ¶
func (s *PostgresStore) SetDefault(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
SetDefault does the atomic swap inside one tx so RLS + partial unique index see both writes consistently.
func (*PostgresStore) Upsert ¶
func (s *PostgresStore) Upsert(ctx context.Context, tenantID string, pm PaymentMethod) (PaymentMethod, error)
Upsert inserts a row keyed by (tenant_id, livemode, stripe_payment_method_id). On conflict we refresh card metadata but leave is_default alone — the customer's default choice shouldn't flip just because Stripe resent the webhook. If no active default exists for the customer, the new row is promoted to default in the same tx (enforces "first PM is default").
Dedupe-by-fingerprint (ADR-0099): when a customer re-runs Add and produces a new pm_xxx with a fingerprint that already exists as an active row for this customer, the old row is detached in the same transaction and the new row inherits its is_default flag. Industry standard — Stripe explicitly recommends this pattern because each Checkout completion mints a fresh PaymentMethod even for the same physical card. Without dedupe, the customer sees "Visa ····4242" twice (or more) and the default-PM semantics drift across the duplicates. Skipped when fingerprint is empty (legacy rows / non-card types) — those keep current behavior.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
func NewService ¶
NewService — `summary` parameter kept for back-compat with the existing router.go wiring (passes customerStore which used to satisfy PaymentSetupSummaryWriter). Ignored. Remove this parameter once the writer interface is fully retired across all builders.
func (*Service) AttachForWebhook ¶
func (s *Service) AttachForWebhook(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) error
AttachForWebhook is the error-only variant of AttachFromSetupIntent, used by payment.Stripe.HandleWebhook which doesn't need the PM row. Keeps the webhook-facing signature narrow so payment/ doesn't have to know about PaymentMethod.
func (*Service) AttachFromSetupIntent ¶
func (s *Service) AttachFromSetupIntent(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) (PaymentMethod, error)
AttachFromSetupIntent is the entry point the P5 webhook handler uses: after setup_intent.succeeded, we know the PM and customer, and we persist the row here. Called with an RLS ctx already staged to the right tenant+livemode by the webhook handler.
func (*Service) CreateSetupIntent ¶
func (s *Service) CreateSetupIntent(ctx context.Context, tenantID, customerID string) (clientSecret, setupIntentID string, err error)
CreateSetupIntent returns the client_secret a browser needs to run stripe.confirmCardSetup(). The actual payment_methods row is written by the webhook handler once Stripe confirms the setup — we don't trust the browser's "success" callback.
func (*Service) CreateSetupSession ¶
func (s *Service) CreateSetupSession(ctx context.Context, tenantID, customerID, returnURL string) (checkoutURL, sessionID string, err error)
CreateSetupSession returns a hosted Stripe Checkout URL the customer can be redirected to for adding a new card without being charged. On success Stripe fires setup_intent.succeeded, which the webhook handler turns into a payment_methods row via AttachForWebhook.
func (*Service) Detach ¶
func (s *Service) Detach(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
Detach marks the PM detached both in Stripe and locally and, if it was the default, promotes the newest remaining active PM to default — the detach + promote run in a single store transaction so there is never a committed "active cards but no default" window. The promoted default is then best-effort synced to Stripe for display parity.
func (*Service) List ¶
List returns active PMs for (tenantID, customerID). Ordered default first for UI convenience.
func (*Service) SetAuditLogger ¶
func (s *Service) SetAuditLogger(a AuditWriter)
SetAuditLogger wires the audit-log writer. Without it, paymentmethods mutations (attach via webhook, set-default, detach, setup-session creation) succeed silently — operator Activity feed and AuditLog page would miss every card-on-file change. Optional: nil = no audit row written, all other behavior unchanged.
func (*Service) SetDefault ¶
func (s *Service) SetDefault(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
SetDefault flips is_default atomically in payment_methods AND refreshes the customer_payment_setups summary row so billing sees the new card.
func (*Service) SetPortalBaseURL ¶
SetPortalBaseURL sets the SPA base URL used to compose Stripe Checkout return URLs. Production wires CUSTOMER_PORTAL_URL via router.go; local dev falls through to http://localhost:5173 when unset. Without this, Stripe redirects the customer to a hardcoded default that may not match the deployment's SPA host.
type SetupLinkEmailer ¶
type SetupLinkEmailer interface {
SendPaymentSetupLink(ctx context.Context, tenantID, to, customerName, operatorNote, setupURL string) error
}
SetupLinkEmailer dispatches the operator-initiated "add a payment method" email. Satisfied by *email.Sender. operatorNote may be empty — the template falls back to a default body when so.
type Store ¶
type Store interface {
List(ctx context.Context, tenantID, customerID string) ([]PaymentMethod, error)
Get(ctx context.Context, tenantID, pmID string) (PaymentMethod, error)
// Upsert writes a payment_methods row keyed by stripe_payment_method_id.
// Webhooks can fire more than once for the same setup intent (Stripe
// retries on 5xx), so the webhook path must be idempotent. If first is
// true and no existing active default for the customer, the new row is
// promoted to default atomically in the same tx.
Upsert(ctx context.Context, tenantID string, pm PaymentMethod) (PaymentMethod, error)
// SetDefault atomically clears any existing default for (customerID)
// and flags pmID as the new default. Fails with ErrNotFound if pmID is
// detached or not owned by customerID.
SetDefault(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
// Detach marks the PM as detached_at = now(). Idempotent — a second
// call on an already-detached row is a no-op and returns the row.
Detach(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, error)
// DetachAndRebalance detaches pmID and, in the SAME transaction,
// promotes the newest remaining active PM to default when the detached
// card was the default — so there is never a committed "active cards
// but no default" window (a transient failure on the promote rolls the
// detach back too). Returns the detached row plus the promoted default
// (nil when the detached card wasn't the default, or no active PM
// remained to promote). Idempotent: re-detaching an already-detached
// row promotes nothing (a detached row is never the default).
DetachAndRebalance(ctx context.Context, tenantID, customerID, pmID string) (PaymentMethod, *PaymentMethod, error)
}
Store is the persistence contract. Kept narrow — each operation maps to exactly one handler action so we don't grow a god-store.
type StripeAPI ¶
type StripeAPI interface {
// CreateSetupIntent makes a SetupIntent for the Stripe customer and
// returns the client_secret the frontend needs for confirmCardSetup().
// Used by integrations that build an inline Stripe Elements UI.
CreateSetupIntent(ctx context.Context, stripeCustomerID string, metadata map[string]string) (clientSecret, setupIntentID string, err error)
// CreateSetupCheckoutSession creates a Stripe Checkout Session in
// setup mode and returns a hosted URL the customer can redirect to.
// successURL and cancelURL are separate so the SPA can render
// different copy based on the outcome (?status=success vs cancel).
// Used by the default web-v2 portal UI, which redirects rather than
// embedding Stripe Elements.
CreateSetupCheckoutSession(ctx context.Context, stripeCustomerID, successURL, cancelURL string, metadata map[string]string) (checkoutURL, sessionID string, err error)
// EnsureStripeCustomer returns the existing Stripe customer ID from
// customer_payment_setups, or creates one if absent and writes it back
// to the setup row. Needed because a customer might not have a Stripe
// customer yet when they first land on the portal.
EnsureStripeCustomer(ctx context.Context, tenantID, customerID string) (string, error)
// DetachPaymentMethod calls Stripe's detach endpoint. Best-effort — if
// Stripe has already detached (e.g. card expired and Stripe removed
// it), we still want to mark the local row detached.
DetachPaymentMethod(ctx context.Context, stripePaymentMethodID string) error
// SetDefaultPaymentMethod updates the Stripe Customer's
// invoice_settings.default_payment_method. Required so any Stripe-side
// off-session auto-charge uses the operator's chosen card, not the one
// Stripe last had on file. Pre-2026-05-29 SetDefault flipped the local
// row only; Stripe's default stayed stale. The adapter looks up the
// Stripe Customer ID via its customerLink (same pattern as
// EnsureStripeCustomer). Best-effort: returns error if Stripe is
// unreachable; caller logs + audits and the operator action still
// commits (local-wins, per Lago / Recurly / Chargebee).
SetDefaultPaymentMethod(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) error
// FetchPaymentMethodCard looks up card metadata for a Stripe PM —
// brand / last4 / exp / fingerprint. Used by the webhook handler
// when persisting a newly attached PM. Fingerprint drives
// dedupe-on-attach (see PostgresStore.Upsert).
FetchPaymentMethodCard(ctx context.Context, stripePaymentMethodID string) (CardMetadata, error)
}
StripeAPI is the narrow subset of Stripe we need. Declared here instead of depending on internal/payment so the paymentmethods package can be tested with a fake and so internal/payment doesn't gain a reverse dependency.
type StripeAdapter ¶
type StripeAdapter struct {
// contains filtered or unexported fields
}
StripeAdapter wires the payment-methods Service to the existing payment.StripeClients pool. Mode-aware — ForCtx(ctx) returns the right client for the caller's livemode, set from the auth ctx.
func NewStripeAdapter ¶
func NewStripeAdapter(clients *payment.StripeClients, customerLink CustomerStripeLink) *StripeAdapter
func (*StripeAdapter) CreateSetupCheckoutSession ¶
func (a *StripeAdapter) CreateSetupCheckoutSession(ctx context.Context, stripeCustomerID, successURL, cancelURL string, metadata map[string]string) (string, string, error)
CreateSetupCheckoutSession creates a Checkout Session in setup mode so the customer can be redirected to Stripe's hosted UI to enter card details without being charged. Mirrors payment.PortalHandler but for the self-serve /me path — the metadata we attach here is what the setup_intent.succeeded webhook routes back to the right customer.
func (*StripeAdapter) CreateSetupIntent ¶
func (*StripeAdapter) DetachPaymentMethod ¶
func (a *StripeAdapter) DetachPaymentMethod(ctx context.Context, stripePaymentMethodID string) error
func (*StripeAdapter) EnsureStripeCustomer ¶
func (a *StripeAdapter) EnsureStripeCustomer(ctx context.Context, tenantID, customerID string) (string, error)
EnsureStripeCustomer resolves (or lazily creates) the Stripe customer for this Velox customer. Single source of truth: customers.stripe_customer_id (migration 0096). A Velox customer without a Stripe Customer record gets one created here on first PM action; subsequent calls short-circuit on the persisted ID.
Stripe client lookup uses explicit (tenantID, livemode) rather than `ForCtx` so this method works on BOTH authenticated portal requests (auth.TenantID populated) and public token-authenticated requests (hosted-invoice Pay flow — no auth ctx, tenantID comes from the public_token row, livemode pinned by hostedinvoice.resolveInvoice).
func (*StripeAdapter) FetchPaymentMethodCard ¶
func (a *StripeAdapter) FetchPaymentMethodCard(ctx context.Context, stripePaymentMethodID string) (CardMetadata, error)
func (*StripeAdapter) SetDefaultPaymentMethod ¶
func (a *StripeAdapter) SetDefaultPaymentMethod(ctx context.Context, tenantID, customerID, stripePaymentMethodID string) error
SetDefaultPaymentMethod points the Stripe Customer's `invoice_settings.default_payment_method` at the given PM. Resolves the Stripe Customer ID via the customerLink — the local PM row was already updated, the Stripe Customer must already exist (the PM couldn't have been attached otherwise). Returns errs.ErrNotFound if the Velox customer has no linked Stripe customer (out-of-band data drift); the Service treats that as a soft skip + audit row entry, not a fail, because the local default is still authoritative.