Champagne

Stripe Connect

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

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 and billing contracts.

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

  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:

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:

# 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 steps 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

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:
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 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

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

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, Connect event scopes, and thin event names.

Staging and production delivery

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

Terraform inputRuntime result
stripe_environment = "sandbox"STRIPE_ENVIRONMENT
stripe_platform_account_idSTRIPE_PLATFORM_ACCOUNT_ID, same platform in both environments
stripe_secret_keySecret STRIPE_SECRET_KEY, sandbox only
stripe_webhook_secretSecret STRIPE_WEBHOOK_SECRET, specific to this deployment's endpoint
stripe_accounts_webhook_secretOptional secret STRIPE_ACCOUNTS_WEBHOOK_SECRET, separate thin destination
app_domain, consumer_domainHTTPS WEB_ORIGIN, CONSUMER_ORIGIN
demo_seed = trueDEMO_SEED=true
stripe_fixture_manifest_pathExplicit 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, API version 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

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.

Sur cette page