Tenant payment accounts (Connect)
Each tenant sells under its own legal entity and is the merchant of record (MoR) for its sales. litecommerce is never the MoR and never collects-and-pays-out a tenant's customer funds. To make that real, every tenant connects its own Stripe account (Stripe Connect, Standard) and charges run directly on that account — the tenant owns settlement, payouts, disputes, and tax liability.
This guide is the contract: how a tenant connects, the capability gate that decides when it can take real money, and what changes (and what doesn't) for BYO and hosted checkout once a connected account is in play. Design rationale lives in ADR-008 (tax posture), ADR-009 (funds-flow), and ADR-010 (Connect build).
Status. Connected-account payments are complete on staging and gated from real-money launch by the M3.5 launch-readiness pass. Track the
tenant-payment-accountsentry in the feature manifest; no surface should present connected-account payments as launch-ready before that gate clears.
Why connected accounts
litecommerce uses direct charges on the tenant's connected account — the only
Stripe Connect funds-flow where the platform never becomes the merchant of record
or holds the tenant's money (destination charges and separate-charges-plus-transfers
are rejected for that reason; ADR-009). There is no application_fee at launch
— monetization is via SaaS plans, not a cut of tenant GMV — so direct charges keep
the platform entirely out of the funds path.
ADR-006's server-authoritative model is unchanged: litecommerce still computes the binding total and creates the PaymentIntent for that exact amount. The only thing that moves is the account context the intent is created in.
The capability gate
Organization.paymentAccountStatus drives whether a tenant can take real money. It
is derived from the connected account's details_submitted / charges_enabled
flags (set at the OAuth callback, refreshed by account.updated webhooks):
| Status | Meaning | Can take a live charge? |
|---|---|---|
NONE | No Stripe account connected | No |
CONNECTED | Linked, onboarding not finished | No |
VERIFIED | details_submitted + charges_enabled | Yes |
RESTRICTED | Linked but charges disabled (more info needed) | No |
A live charge is allowed only when VERIFIED. Otherwise the charge boundary
fails closed — no PaymentIntent, no SetupIntent, no order. The status is read
in the charge path, not merely stored (the #862 lesson: a status the guard
chain never reads is not a gate).
Onboarding routes
All paths are under /api/v1. The merchant routes require an OWNER or ADMIN
membership; the callback is public.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/v1/merchant/payment-account | Owner/Admin | Current payment-account status for the tenant (connected / verified / restricted + masked account id). |
POST | /api/v1/merchant/payment-account/connect | Owner/Admin | Begin onboarding — returns the Stripe OAuth authorize URL. Responds 503 if Connect is not configured in this environment. |
POST | /api/v1/merchant/payment-account/refresh | Owner/Admin | Re-derive status from Stripe on demand (returns 200). |
GET | /api/v1/connect/stripe/callback | Public | Stripe OAuth redirect target. Bound to the initiating org by a single-use, expiring, hashed CSRF state; rejects replay / mismatch. |
Onboarding is self-serve: the merchant clicks "Connect Stripe account" in admin,
authorizes on Stripe, and the callback stores the connected stripeAccountId and
derives the status. Platform admins get read-only payment-account status per
tenant for support and launch-readiness.
The charge contract (what changes)
The single payment path (ADR-006) is preserved — only the account context changes:
- Server. PaymentIntent / SetupIntent / Customer are created on the connected
account via the
Stripe-Accountheader (the{ stripeAccount }request option). The platform-account path remains only for Stripe test/sandbox. - Client (hosted and BYO). Initialize Stripe.js with the platform
publishable key plus the connected account, so the Payment Element renders
against the connected-account intent. The
clientSecretyou receive from the payment-session API is already minted on the right account — you just pass the connected account toloadStripe/Stripe(...)asstripeAccount.
Platform vs connected (BYO delta)
| Platform account (legacy / test) | Connected account (live) | |
|---|---|---|
| Charge account | platform | tenant's connected account (Stripe-Account) |
| Merchant of record | — (never for live tenant sales) | the tenant |
| Stripe.js init | publishable key only | publishable key + connected account |
| Settlement / payouts / disputes | — | the tenant |
If you build BYO checkout, the only delta from the BYO checkout API guide is passing the connected account to Stripe.js. Everything else — server-authoritative totals, the webhook-confirmed order — is identical.
Everything else is account-scoped too
- Tax (ADR-008): computed and recorded on the tenant's connected account (its registrations / merchant-of-record status). Fail-closed when a VERIFIED tenant's Stripe Tax settings aren't active — it does not silently fall back to platform-registration tax.
- Refunds: issued on the order's connected account; legacy platform-account orders still refund on the platform account.
- Saved cards: the Stripe Customer and saved payment methods live on the connected account and are scoped by account — a legacy platform-account card is never offered or used for a connected-account charge.
- Webhooks: connected-account events arrive with
event.account, which resolves the owning tenant authoritatively (cross-checked againstmetadata.organizationId).
The platform-account backstop
The capability gate above is the primary enforcement. A separate, permanent
backstop — STRIPE_PLATFORM_ACCOUNT_CHARGES (ADR-009) — additionally refuses any
live-mode charge that would run on the platform account, regardless of tenant
status. It is defense-in-depth the capability gate does not replace: it catches a
charge with no connected account, a legacy attempt re-served, or a future code path
that forgets to thread the connected account. It stays blocked by default and is
not a path for any tenant's live customer funds.