Skip to content

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-accounts entry 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):

StatusMeaningCan take a live charge?
NONENo Stripe account connectedNo
CONNECTEDLinked, onboarding not finishedNo
VERIFIEDdetails_submitted + charges_enabledYes
RESTRICTEDLinked 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.

MethodPathAuthPurpose
GET/api/v1/merchant/payment-accountOwner/AdminCurrent payment-account status for the tenant (connected / verified / restricted + masked account id).
POST/api/v1/merchant/payment-account/connectOwner/AdminBegin onboarding — returns the Stripe OAuth authorize URL. Responds 503 if Connect is not configured in this environment.
POST/api/v1/merchant/payment-account/refreshOwner/AdminRe-derive status from Stripe on demand (returns 200).
GET/api/v1/connect/stripe/callbackPublicStripe 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-Account header (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 clientSecret you receive from the payment-session API is already minted on the right account — you just pass the connected account to loadStripe/Stripe(...) as stripeAccount.

Platform vs connected (BYO delta)

Platform account (legacy / test)Connected account (live)
Charge accountplatformtenant's connected account (Stripe-Account)
Merchant of record— (never for live tenant sales)the tenant
Stripe.js initpublishable key onlypublishable key + connected account
Settlement / payouts / disputesthe 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 against metadata.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.