# Sandbox membership billing

Club-scoped direct charges, hosted Checkout and session-specific confirmation.

Source: https://docs.champagne.dev-ukab.fr/en/graphql/billing

Billing uses the club's selected [merchant profile](/en/graphql/merchants) and its connected
Stripe account. Development, staging and production all use sandbox; live keys and events
are refused. Checkout requires an authorized member, club administrator or scoped
application access token, depending on the operation. Accepted Application/Onboarding payment steps reuse this same billing
contract and require the selected club's merchant to be ready. A club with the `billing` module
turned off refuses every Checkout and the payment portal with `BAD_USER_INPUT` ("Payments are
turned off for this club."); see [Clubs with billing turned off](/en/graphql/platform#clubs-with-billing-turned-off).

## Offers [#offers]

```graphql
query MembershipOffers($clubId: ID!) {
  membershipOffers(clubId: $clubId) {
    ready
    reason
    offers {
      planId
      name
      description
      amount
      currency
      interval
    }
  }
}
```

`clubId` is a required local club UUID. No filters, sorting or pagination arguments exist.
The non-null `MembershipOffers` response has `ready: Boolean!`, `reason: String` and
`offers: [MembershipOffer!]!`. An unconfigured merchant, inactive payments or unprepared
catalogue has explicit readiness feedback rather than a purchasable partial catalogue.
Payout readiness is displayed separately in Finance and is not the Checkout payment gate.

Ready offers retain non-null `planId: ID!`, `name: String!`, `description: String!`,
`amount: Int!`, `currency: String!`, `interval: String!`. The seeded catalog starts with
three annual euro plans, but club administrators can manage subscription plans and their
monthly/yearly pricing in the back office. Add-on plans remain in the plan catalog but are not
membership Checkout offers; they are sold through the add-on flow below. Products/Prices are resolved from
club → merchant → local plan mapping, never from a universal `plan.stripeId`.

## Add-ons use credits [#add-ons-use-credits]

List `plans(clubId)` and select type `addon`. These prices are in hundredths of a
club credit. `payAddonCredits(clubId, planId)` derives the signed-in member,
debits their balance atomically and grants the add-on. Retrying an already owned
add-on returns success without another debit. Use [Club credits](/en/graphql/credits)
for packs, exchange rates, top-up Checkout and balance/history queries.

The legacy `createAddonCheckout` mutation rejects new cash purchases. Existing
cash checkouts retain their status query and webhook fulfillment. Membership
subscriptions continue using the cash billing APIs below.

## Create Checkout [#create-checkout]

```graphql
mutation CreateMembershipCheckout($personId: ID!, $clubId: ID!, $planId: ID!, $requestId: ID!) {
  createMembershipCheckout(
    personId: $personId
    clubId: $clubId
    planId: $planId
    requestId: $requestId
  ) {
    url
    sessionId
  }
}
```

All four arguments are required UUIDs. The person must exist and belong to the requested
club. The plan must be one of the club's active subscription offers, the merchant and catalogue must
be ready, and existing Stripe membership restrictions still apply, including expired
Stripe-backed memberships. Invalid input or inconsistent associations reject the request.
Stripe and unexpected failures surface as GraphQL errors, not a successful redirect.

The return is `MembershipCheckout!`, containing `url: String!` (hosted HTTPS Stripe
Checkout) and `sessionId: String!`. Keep `requestId` stable when retrying the same person,
club and plan attempt after an uncertain response. An open session can be resumed; an
expired attempt permits a new UUID. The browser prevents duplicate pending submissions.
An expired retry returns `extensions.code: CHECKOUT_EXPIRED`; discard that attempt's
cached UUID and retry with a new one. Transient errors retain the same UUID. Selecting
a different plan expires the previous open session before reserving the new attempt.

The server persists the attempt with person, historical club, merchant/account, plan,
Price and session. Application attempts also persist the application ID, use
`/applications/<id>` success/cancel returns, and cannot be reused to complete another application.
Customer identity is person + connected account. Calls use that
connected account explicitly, with direct charges and no Champagne commission. The
session and subscription carry the reconciliation metadata. Changing the browser's club
does not change an already-created session's historical context.

## Confirm the returned session [#confirm-the-returned-session]

Member purchases return to the consumer's `/account/membership?club=<clubId>` tab (application
payments return to `/applications/<id>` on the club's hosted site when the application's
onboarding is published there, so the applicant keeps their session). The success URL includes `checkout=success` and
`checkout_session_id={CHECKOUT_SESSION_ID}`; add-on purchases also carry `kind=addon`.
The browser sends that exact session back with its person and club:

```graphql
query MembershipCheckoutStatus($personId: ID!, $clubId: ID!, $sessionId: String!) {
  membershipCheckoutStatus(personId: $personId, clubId: $clubId, sessionId: $sessionId) {
    status
  }
}
```

The non-null `MembershipCheckoutStatus` contains `status: String!`: `pending`, `confirmed`
or `expired`. The server verifies the stored attempt and its person/club, retrieves the
session in its connected account, checks its subscription and waits for that subscription's
paid projection. Another membership does not confirm this session. Invalid or mismatched
session context is an error. There are no filters, sorting or pagination arguments.

The consumer polls every two seconds while pending, stops at confirmed/expired, refreshes
the account on confirmation, and offers retry on error. A missing session ID is explicit.
The redirect never creates membership and pending/error states do not invite another payment.

## Application Checkout [#application-checkout]

After acceptance creates the applicant's person, a current Plan payment step uses the plan snapshot
already selected in the application:

```graphql
mutation CreateApplicationCheckout($applicationId: ID!, $revision: Int!, $requestId: ID!) {
  createApplicationCheckout(
    applicationId: $applicationId
    revision: $revision
    requestId: $requestId
  ) {
    url
    sessionId
  }
}
```

The application must be accepted, linked to its person, at a Plan payment step, and have a selected
plan. `revision` is the current optimistic revision and `requestId` has the same retry semantics as
account Checkout. The server derives person, club and plan from the application rather than trusting
them from the browser. Its Stripe success return includes the exact Checkout session ID.

```graphql
mutation CompleteApplicationPayment($applicationId: ID!, $revision: Int!, $sessionId: String!) {
  completeApplicationPayment(
    applicationId: $applicationId
    revision: $revision
    sessionId: $sessionId
  ) {
    application {
      id
      afterApprovalCurrentStep
      revision
    }
    error
  }
}
```

Completion verifies that the session belongs to this application and waits for the same paid
membership projection used by `membershipCheckoutStatus`. A browser redirect, completed Checkout,
unrelated membership, wrong application, or missing webhook cannot advance the step. Pending and
expired sessions return readable `error` values with the unchanged application; confirmed payment
adds an auditable after-approval answer and advances exactly once.

The signed `invoice.paid` webhook advances the Plan payment step itself, in the same transaction
that projects the paid membership, when the subscription comes from an application Checkout. The
applicant therefore does not need to return to the browser session that started Checkout. When the
step was already advanced this way, `completeApplicationPayment` returns the current application
with a null `error` for that confirmed session, so a late browser return completes normally.
Starting another Checkout for a person who already holds a Stripe membership fails with
`BAD_USER_INPUT` and the message "This membership is already paid. Reload the page to continue."

## Connect webhooks and fixtures [#connect-webhooks-and-fixtures]

`POST /api/webhooks/stripe` verifies the raw-body signature, refuses live events and requires
the connected `event.account`. The account must resolve to a registered local merchant;
the historical club comes from subscription/attempt context because merchants can be shared.
The server rereads subscription, invoice and lines in that account before projecting the
paid period. Duplicate and out-of-order deliveries cannot duplicate membership, shorten
access or extend it from an unpaid later period. Transient processing failures remain
retryable by Stripe; foreign events do not mutate local business data.

Subscription membership payment status is derived from this projected paid period, not
from person status. Once the paid-through date passes it reads as `unpaid`. A later valid
`invoice.paid` delivery advances the same membership's paid-through date, so its next read
returns `paid` automatically. Stripe renews subscriptions itself at each period end; renewals
are accepted on the membership's recorded Price, or its scheduled replacement Price, even after
the plan's amount changed or its Prices were archived (see
[plan splits](/en/graphql/plans#split-a-plan)). This never reactivates a banned, disabled or suspended person;
payment eligibility must combine membership billing state with the separate person status.

Local stacks use `stripe listen --forward-connect-to`, with isolated listener secrets.
Stripe sandbox resources themselves are shared, with no worktree namespace. Bootstrap
verifies/reuses the metadata-marked, platform-managed Ukab Clubs account, waits for its card-payment
capability, verifies its three Product/Price pairs, Maxime's connected Customer and paid
Cercle + Sport subscription, then publishes the validated non-secret `.local/` manifest atomically.
The same fixture manifest supports development, staging and production.
Without a manifest, seed creates clubs/plans/people but no fabricated Stripe memberships.
An invalid supplied manifest fails explicitly. Existing PostgreSQL merchants remain usable
without a manifest; Finance creations do not rewrite it. Platform resources remain intact.

See [Invoices](/en/graphql/invoices) for club-scoped projections, refresh and credit notes.

## Change a member's payment method [#change-a-members-payment-method]

An authenticated member can call `createPaymentMethodPortal(personId: ID!, clubId: ID!)`
and receive `{ url }`. The member and club must match the session; staff and applicant
access tokens cannot open this portal. The customer must already be associated with
the member and the club's connected Stripe account. Champagne never creates a new
customer for this action or accepts a customer/account/return URL from the browser.

```graphql
mutation PaymentSettings($personId: ID!, $clubId: ID!) {
  createPaymentMethodPortal(personId: $personId, clubId: $clubId) {
    url
  }
}
```

The consumer's **Change payment method** button opens Stripe's hosted payment-method
update flow. A dedicated connected-account portal configuration disables subscription
cancellation, plan changes, profile changes and invoice browsing. The return URL is
`CONSUMER_ORIGIN/account/membership?club=<clubId>`. Portal URLs are temporary credentials and must not be logged or stored.
A return to Champagne alone is not evidence that the card was changed. When Stripe cannot
open the portal, the mutation returns a generic retry message and the cause is logged with
the request ID.

Subscribe the connected-account webhook endpoint to `customer.updated` in addition to
its existing events. Local worktree listeners include this event. Stripe stores the
new customer default payment method. On a signed update of that default, Champagne
retrieves the current customer and makes its owned subscriptions in the same club
inherit that default, clearing subscription-specific payment overrides and disabling
saving invoice payments as subscription-specific defaults. Subscription payment-type
overrides are cleared so Stripe can use the newly selected method. Prices and renewal dates
are unchanged; no payment or retry of an outstanding invoice is explicitly requested.
Only future collections are covered; already-issued invoices can retain their own
payment settings. Subscription schedules managed outside Champagne may also override
payment settings later.

Live mode, missing customer associations, mismatched ownership and provider failures
fail closed. Webhook failures return an error so Stripe can retry; repeated or delayed
events use the current customer default rather than replaying an old card. Card data
is entered only on Stripe. This integration remains sandbox-only like existing billing.

Members can also view their [outstanding invoices](/en/graphql/invoices#member-outstanding-invoices)
and open the corresponding Stripe payment page from My account. This action is separate from
changing the default payment method and does not alter subscription or suspension policies.

## Club credits [#club-credits]

See [Club credits](/en/graphql/credits) for credit packs, balances, refill history,
credit-priced add-ons and reservations, and cancellation refunds. Membership
subscriptions stay billed in money.
