Champagne

Sandbox membership billing

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

Billing uses the club's selected merchant profile 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.

Offers

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

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

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

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:

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

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

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.

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

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). 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 for club-scoped projections, refresh and credit notes.

Change a member's 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.

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

See Club credits for credit packs, balances, refill history, credit-priced add-ons and reservations, and cancellation refunds. Membership subscriptions stay billed in money.

On this page