# Stripe Connect

Dev and deployment sandbox merchants, bootstrap fixtures, isolated listeners and deployment.

Source: https://docs.champagne.dev-ukab.fr/en/stripe-connect

## Finance and Consumer [#finance-and-consumer]

Open Settings at `/settings/payments?club=<clubId>`. A club can select a registered merchant or create one through
Stripe Accounts v2, complete activation, refresh its capabilities and prepare its catalog. The same
merchant can be selected by several clubs. Incomplete activation is visible; Checkout requires
payments readiness and a valid catalog. Payout readiness is displayed separately.

IÉNA has three annual offers at €1,800, €4,500 and €6,300, without additional tax. Payments are
direct charges in the connected account, with no Champagne commission. Each person has a Customer
association for that account. A Checkout attempt fixes the historical club, account, plan and Price.
The Consumer return includes `checkout_session_id` and waits for the subscription's paid projection;
only the signed Connect `invoice.paid` webhook grants or extends the membership.
An accepted application with a Plan payment step uses the same connected merchant, selected plan,
hosted Checkout and signed-webhook confirmation before onboarding can continue.

Finance reads PostgreSQL projections. Each associated merchant account is scanned once, including
shared merchants, with separate durable cursors and jobs. Invoice ownership comes from subscription
metadata and local historical associations, not account sharing or the person's current club.
Full credits use `credit_amount` (customer balance), without a bank refund or email.
Club administrators authenticate before accessing finance; member checkout operations verify ownership.
See the [invoice](/en/graphql/invoices) and [billing](/en/graphql/billing) contracts.

## Dev and deployment sandboxes [#dev-and-deployment-sandboxes]

Local development uses Champagne Dev Sandbox (`acct_1UIssNI56SIBRnby`); staging and production
retain Champagne Prod Sandbox (`acct_1UIYvTEk8EhumAbT`) and its existing Ukab Clubs fixture.
Runtime refuses live keys and events. The pinned SDK/API version is `2026-08-26.dahlia`.
Worktrees isolate files, databases, listeners and signing secrets; they do not introduce Stripe
resource namespaces within Dev. Stripe's built-in Test mode remains separate and unchanged.

The shared demo merchant is Ukab Clubs, `contact@ukab.fr`, a French individual fixture. It has no
Stripe dashboard; the platform collects its requirements and is responsible for fees and losses.
This deterministic platform-managed model is restricted to the metadata-marked sandbox fixture.
Merchants created through the application still use a full Stripe dashboard with Stripe collecting
requirements and bearing losses. The club ID is `51000000-0000-4000-8000-000000000051`. Vosges
and Astel initially have no merchant. Later business
edits and merchant selections survive.

## Local configuration and bootstrap [#local-configuration-and-bootstrap]

1. Run `make dev` in a fresh checkout. On first interactive startup, paste the Dev sandbox's
   `sk_test_` key when prompted; the startup verifies `acct_1UIssNI56SIBRnby` and saves the key
   to an ignored local `.env`. It also creates its own Connect listener and signing secret.
2. For `make worktree-up`, provision the Dev sandbox key in that worktree's own `.env` or inject it
   into the command environment. `STRIPE_PLATFORM_ACCOUNT_ID` defaults to the Dev sandbox in
   `config.mk`; use `config.local.mk` only when intentionally targeting a different sandbox.
   `STRIPE_ENVIRONMENT` must be `sandbox`.
3. Set the sandbox account's default webhook API version to `2026-08-26.dahlia`.
4. Run `make worktree-up`. Compose derives both app origins and starts an isolated Connect listener.

The listener uses `--forward-connect-to http://api:3000/api/webhooks/stripe` and the
`checkout.session.completed` plus invoice, credit-note and invoice-payment event set.
`checkout.session.completed` fulfills one-time add-on purchases; the invoice events project
subscription periods. It does not forward platform events. The pinned CLI has
no arbitrary `listen` API-version flag, so readiness verifies the advertised account-default version.
Do not use `--latest`. Startup output containing the signing secret stays in the listener container;
the private runtime environment files are not printed or copied from another checkout.

`make dev` and `make worktree-up` automatically refresh the fixture and restart the API with its
validated manifest. To verify idempotence manually, run bootstrap twice through that API's environment:

```sh
bun --no-env-file scripts/dev-compose.ts exec -T -w /app api env -u STRIPE_FIXTURE_MANIFEST bun --no-env-file scripts/stripe-catalog.ts
bun --no-env-file scripts/dev-compose.ts exec -T -w /app api env -u STRIPE_FIXTURE_MANIFEST bun --no-env-file scripts/stripe-catalog.ts
```

The script verifies the platform and sandbox, finds or creates the merchant, attaches its French test
bank account and waits for Stripe's asynchronous capability activation. It then prepares the three
Products/Prices and verifies Maxime Borde's connected Customer, Cercle + Sport subscription and paid
€6,300 invoice. Partial runs are recoverable; ambiguous matches fail rather than selecting
arbitrarily. Only a fully validated fixture is published atomically to
`.local/stripe-connect-manifest.json`. No host Stripe login or hosted onboarding is required.

For a custom external bootstrap manifest, explicitly configure seed restoration:

```make
# config.local.mk
STRIPE_PLATFORM_ACCOUNT_ID = acct_REPLACE_WITH_SANDBOX_PLATFORM
STRIPE_FIXTURE_MANIFEST = .local/stripe-connect-manifest.json
```

`make worktree-up` copies the specified nonsecret file to `.local/worktree-stripe-manifest.json`
and sets runtime `STRIPE_FIXTURE_MANIFEST=/app/.local/worktree-stripe-manifest.json`. An absolute
source path can reuse an explicitly selected manifest elsewhere; the source is not modified.
If replacing an already configured manifest's contents, run `worktree-up`, then
`bun --no-env-file scripts/dev-compose.ts restart api` to rerun the seed.

Ordinary local startup uses the freshly validated fixture automatically; without a key, it skips
Stripe fixtures. A supplied unreadable, invalid or contradictory custom manifest fails startup. The seed makes no Stripe API
calls and preserves edited business records. The manifest supports bootstrap and seeding; registered
merchants continue working without it. Finance-created merchants do not rewrite it.

## Identity verification webhooks [#identity-verification-webhooks]

[Identity verification steps](/en/graphql/applications#identity-verification) create Stripe Identity
sessions on the platform account. Create another snapshot event destination in the **same sandbox
platform**:

* URL: `https://<app_domain>/api/webhooks/stripe/identity` (POST).
* Source: **Your account**, not Connected accounts.
* Secret: this destination's own `whsec_...` in `STRIPE_IDENTITY_WEBHOOK_SECRET`.
* Events: `identity.verification_session.processing`, `identity.verification_session.verified`,
  `identity.verification_session.requires_input` and `identity.verification_session.canceled`.

Create a restricted key with the **Identity verification results** and **Recent detailed
verification results** read permissions and store it as `STRIPE_IDENTITY_RESULTS_KEY`; only such a
key can read the date of birth used by minimum-age checks. Identity must also be activated for the
account in the Dashboard. Local stacks forward these events with `stripe listen --forward-to` and
write the listener secret as `STRIPE_IDENTITY_WEBHOOK_SECRET`; add `STRIPE_IDENTITY_RESULTS_KEY` to
the worktree's own `.env`.

## Merchant status webhooks [#merchant-status-webhooks]

Accounts v2 uses **thin notifications**, not v1 `account.updated` snapshots. Keep the invoice
endpoint unchanged. Create another event destination in the **same sandbox platform**:

* URL: `https://<app_domain>/api/webhooks/stripe/accounts` (POST).
* Source: **Your account**, API `events_from: ["@self"]`, **not Connected accounts** (`@accounts`).
* Payload: **Thin** (`event_payload: "thin"`). The SDK retrieves versioned events and accounts
  using the pinned `2026-08-26.dahlia` API; notification payloads are unversioned.
* Secret: this destination's own `whsec_...` in `STRIPE_ACCOUNTS_WEBHOOK_SECRET`.
* Subscribe to these exact event names:

```text
v2.core.account.updated
v2.core.account[configuration.merchant].updated
v2.core.account[configuration.merchant].capability_status_updated
v2.core.account[requirements].updated
v2.core.account[identity].updated
v2.core.account[defaults].updated
```

The handler verifies the raw-body signature with Stripe's asynchronous thin-notification parser,
checks sandbox/platform context, verifies the API key's platform, and fetches the v2 event with
its `Stripe-Context`. Only a locally registered account matching the configured sandbox/platform
is re-retrieved, with merchant configuration, defaults, identity and requirements included.
The account ID, country/entity type, sandbox mode, dashboard and fee/loss responsibilities are
validated before atomically persisting `configurationStatus`, `paymentsEnabled`, `payoutsEnabled`
and `checkedAt`. Payment readiness still means active card payments; payouts are independent.
This never creates accounts, changes Stripe data, prepares catalogs or alters club selections.

Concurrent webhook reconciliations lock the local merchant before retrieving its latest state.
Duplicate and out-of-order deliveries re-read current Stripe state instead of replaying old event
values; readiness is idempotent and `checkedAt` advances on successful checks. There is no event
deduplication ledger or background queue for these small reconciliations. HTTP 200 follows the
database commit; provider/database failures return 500 for Stripe retries. Invalid signatures
return 400. Unsupported event types, live/foreign-context notifications, platform-account events
and unknown local merchants are acknowledged without state changes. Incompatible retrieved
accounts fail validation and retain the last stored state; closure/deletion and organization-wide
destinations are not supported by this handler.

Platform-scoped events can omit `context` in the notification and return `context: null` from
the event retrieval API. Both mean the authenticated platform's own scope, not a foreign account;
no `Stripe-Context` header is sent for these events. An explicit context must still match the
configured platform and agree between the notification and retrieved event.

Without the optional secret, this endpoint returns 503; existing invoice webhooks and manual
merchant refresh continue working. Existing deployments therefore do not need a new secret until
they enable automatic merchant updates. A missed event (including one delivered before a new
merchant was saved locally) is recovered by automatic synchronization while Settings is open. The selected merchant
is synchronized on opening, on tab focus, and every minute while visible.

Settings polls the stored GraphQL merchant list every 3 seconds while any merchant's payment or
payout capability is pending, otherwise every 30 seconds, and every 10 seconds after a query error.
Polling pauses in background tabs; mounting and returning focus always refresh. Cached merchant
details remain visible on failure with a retry error. Polling does **not** call Stripe. Both Stripe
onboarding return and refresh URLs now target `/settings/payments?club=<clubId>`; neither a
redirect nor its query parameters mark a merchant ready. An expired link can be renewed with
**Complete Stripe activation**.

Initial loading and manual Stripe refresh show a centered spinner. Background polling keeps
the merchant readable and shows &#x2A;*Checking for updates…** beside the last-checked timestamp;
it never blocks activation while waiting for Stripe. The idle polling label describes the
browser's checks, not webhook delivery health. **Last checked** advances only when the backend
successfully retrieves Stripe state, not on every browser poll. Query failures retain the
cached merchant and show **Automatic updates interrupted** with a retry action.

### Local thin listener [#local-thin-listener]

Keep the worktree's existing automatic invoice listener running. Start a **second** Stripe CLI
listener authenticated to the same sandbox platform (replace the URL for another worktree):

```sh
stripe listen --thin-events 'v2.core.account.updated,v2.core.account[configuration.merchant].updated,v2.core.account[configuration.merchant].capability_status_updated,v2.core.account[requirements].updated,v2.core.account[identity].updated,v2.core.account[defaults].updated' \
  --forward-thin-to http://settings-merchants-f8fde1ca0c6f.localhost:33910/api/webhooks/stripe/accounts
```

Use `--forward-thin-to`, **not** `--forward-thin-connect-to`. Put this new listener's signing
secret in the current worktree's own `.env` as `STRIPE_ACCOUNTS_WEBHOOK_SECRET`; do not copy
another checkout's credentials. Run `make worktree-up` to inject it into the private billing
runtime environment and recreate the API if needed. The helper preserves the separate invoice
listener secret. The thin listener is operator-managed and must remain running while testing;
if its secret changes, update the setting and rerun `make worktree-up`.

For the primary checkout (`make dev`), forward the second listener to
`http://localhost:<port>/api/webhooks/stripe/accounts`, using the port printed by `make dev`.
Store its secret as `STRIPE_ACCOUNTS_WEBHOOK_SECRET` in the ignored `.env.local` (mode `0600`),
then rerun `make dev` to recreate the API. Keep the invoice listener and its
`STRIPE_WEBHOOK_SECRET` unchanged: an invoice-only listener cannot update merchant readiness.
Verify the thin listener reports HTTP 200; a missing secret returns 503 and a reconciliation
failure returns 500. Merely seeing the page poll does not prove Stripe delivery is configured.

Finish activation or change a capability in Stripe's sandbox, then verify the event delivery is
200 and Settings updates without pressing refresh. Replay a delivery and verify no extra local
merchant appears. No real Stripe mutations are required by automated tests: they use signed
fixtures, the real SDK with an isolated mock HTTP transport, and a disposable PostgreSQL database.

Official references: [webhooks](https://docs.stripe.com/webhooks),
[Connect event scopes](https://docs.stripe.com/connect/webhooks?accounts-namespace=v2), and
[thin event names](https://docs.stripe.com/api/v2/core/events/event-types).

## Staging and production delivery [#staging-and-production-delivery]

The existing `infra/scaleway` module handles both environments with separate state. Configure:

| Terraform input                  | Runtime result                                                              |
| -------------------------------- | --------------------------------------------------------------------------- |
| `stripe_environment = "sandbox"` | `STRIPE_ENVIRONMENT`                                                        |
| `stripe_platform_account_id`     | `STRIPE_PLATFORM_ACCOUNT_ID`, same platform in both environments            |
| `stripe_secret_key`              | Secret `STRIPE_SECRET_KEY`, sandbox only                                    |
| `stripe_webhook_secret`          | Secret `STRIPE_WEBHOOK_SECRET`, specific to this deployment's endpoint      |
| `stripe_accounts_webhook_secret` | Optional secret `STRIPE_ACCOUNTS_WEBHOOK_SECRET`, separate thin destination |
| `app_domain`, `consumer_domain`  | HTTPS `WEB_ORIGIN`, `CONSUMER_ORIGIN`                                       |
| `demo_seed = true`               | `DEMO_SEED=true`                                                            |
| `stripe_fixture_manifest_path`   | Explicit local bootstrap JSON file read by Terraform                        |

The provider supports environment and command injection, not arbitrary config-file mounts.
Terraform encodes the nonsecret file as `STRIPE_FIXTURE_MANIFEST_JSON`. Its command wrapper
(`infra/scaleway/start-runtime.sh`) atomically writes `/tmp/champagne-stripe-connect-manifest.json`,
unsets the transport variable and executes the existing production supervisor. The backend receives
`STRIPE_FIXTURE_MANIFEST` pointing to that file. This happens before schema push and seeding, on every
cold start, without an image rebuild, bucket or new secret-management infrastructure.

Use the same validated manifest for staging and production. Empty `stripe_fixture_manifest_path`
means no fixture file or fixture seed. Supplying one requires Stripe and `demo_seed=true`; Terraform
checks its format, sandbox and platform, and the backend validates the full manifest.
Infrastructure configuration requires the normal Terraform plan/apply; image-only CD preserves it.

Register a persistent sandbox **connected-account** snapshot webhook endpoint for each deployment:
`https://<app_domain>/api/webhooks/stripe&#x60;, API version &#x2A;*`2026-08-26.dahlia`**. Subscribe to
`invoice.created`, `invoice.finalized`, `invoice.updated`, `invoice.deleted`, `invoice.paid`,
`invoice.payment_failed`, `invoice.payment_succeeded`, `invoice.voided`,
`invoice.marked_uncollectible`, `credit_note.created`, `credit_note.updated`, `credit_note.voided`
and `invoice_payment.paid`. Supply its own signing secret, not a local `stripe listen` secret.
The application verifies signatures over the raw body, requires `event.account`, rejects live,
acknowledges foreign events and keeps transient failures retryable.

## Verification [#verification]

After bootstrap twice and seed, open Ukab Clubs' Finance page and verify its merchant, catalog,
Maxime's paid membership and projected invoice. Complete another eligible person's Consumer
Checkout with Stripe's `4242 4242 4242 4242` test card; the return must wait for webhook projection.
Verify invoice details and a full customer-balance credit. Select the same merchant for a second
club and verify invoice separation, then exercise creation and activation of another merchant.
