# Plans

List and manage club plans synchronized with a connected Stripe account.

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

Stripe provider failures return a masked `INTERNAL_SERVER_ERROR` with a
server-generated `extensions.requestId`. Use that ID to correlate the sanitized
server diagnostic. Expected business validation errors keep their public codes;
provider messages and payloads are never returned to clients.

The `plans` query returns active plans. Pass `clubId` to list only plans mapped to
the selected Stripe merchant for that club. Omitting it retains the global active
catalog used by the public application flow. Plans replaced by a [split](#split-a-plan)
are omitted unless `includeReplaced: true`; the back-office Plans page requests them.

```graphql
query Plans($clubId: ID) {
  plans(clubId: $clubId) {
    id
    name
    description
    amount
    currency
    interval
    type
    stripeId
    subscriberPrices {
      amount
      subscribers
    }
  }
}
```

The response is a non-null list with non-null entries:

```json
{
  "data": {
    "plans": [
      {
        "id": "60000000-0000-4000-8000-000000000001",
        "name": "Adhésion 51 IÉNA (2026) - Abonnement Membre 2026",
        "description": "Adhésion annuelle donnant accès aux services et événements réservés aux membres.",
        "amount": 180000,
        "currency": "eur",
        "interval": "year",
        "type": "subscription",
        "stripeId": "champagne_iena_membre_2026",
        "subscriberPrices": [
          { "amount": 150000, "subscribers": 4 },
          { "amount": 180000, "subscribers": 12 }
        ]
      }
    ]
  }
}
```

## Return fields [#return-fields]

| Field              | Type                     | Meaning                                                                                                                                                                                                                                                                        |
| ------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`               | `ID!`                    | Internal UUID for the plan and membership relationship.                                                                                                                                                                                                                        |
| `name`             | `String!`                | Display name.                                                                                                                                                                                                                                                                  |
| `description`      | `String`                 | Optional plan description.                                                                                                                                                                                                                                                     |
| `amount`           | `Int!`                   | Nonnegative price: currency minor units for memberships, credit hundredths for add-ons, euro cents for credit packs.                                                                                                                                                           |
| `currency`         | `String!`                | Lowercase three-letter currency code.                                                                                                                                                                                                                                          |
| `interval`         | `String!`                | Billing interval: `month` or `year`.                                                                                                                                                                                                                                           |
| `creditAmount`     | `Int`                    | Fixed pack quantity in hundredths of a credit; required for credit packs, null for other plans.                                                                                                                                                                                |
| `type`             | `PlanType!`              | `subscription`, `addon`, or `credit_pack`.                                                                                                                                                                                                                                     |
| `stripeId`         | `String`                 | Optional catalogue reference; not a universal connected Product/Price ID.                                                                                                                                                                                                      |
| `subscriberPrices` | `[PlanSubscriberPrice!]` | Memberships with a Stripe subscription and a running paid period on this plan, grouped by the `amount` they currently pay and ordered by amount. Populated by `plans` for staff only; `null` without a staff session, on plans returned by mutations or nested in memberships. |
| `replacedBy`       | `PlanReplacement`        | `{ id, name }` of the plan that replaced this one in a [split](#split-a-plan); `null` otherwise. Populated by `plans`.                                                                                                                                                         |

Stripe price references belong to memberships rather than plans. See the
[`person` query](/fr/graphql/people) for membership fields. The back office converts
`amount` from minor units for display. The back office always supplies its selected
club ID. The editor offers searchable, localized currency names from Stripe's card
presentment currency list; availability can still depend on the connected account
and payment method. It preserves the entered major-unit price when changing the
currency and rejects unsupported fractional amounts rather than rounding them.
For example, 1,200 JPY is sent as `amount: 1200`, while 12 EUR is sent as
`amount: 1200`. Stripe's ISK and UGX compatibility rules still use a factor of
100 but only permit whole major-unit amounts.

The back-office table can sort its returned plans by name, type, amount, or
billing interval in either direction. This sorts the loaded list (up to 100
plans); the GraphQL `plans` query still returns its default name/ID order.

## Create and update [#create-and-update]

```graphql
mutation CreatePlan($input: PlanInput!) {
  createPlan(input: $input) {
    id
    name
    amount
    currency
    interval
    type
  }
}
```

`PlanInput` contains `clubId`, a retry-stable UUID `requestId`, `name`, optional
`description`, nonnegative minor-unit `amount`, lowercase three-letter `currency`,
`interval` (`month` or `year`) and `type` (`subscription`, `addon`, or `credit_pack`). Names are
trimmed and limited to 200 characters; descriptions are limited to 1,000 characters;
amounts cannot exceed `100000000` minor units.

`createPlan` creates a Product and recurring Price on the club's selected connected
Stripe account before saving their local mapping. Retrying the same request uses the
same Stripe idempotency keys and local plan ID.

```graphql
mutation UpdatePlan($id: ID!, $input: PlanInput!) {
  updatePlan(id: $id, input: $input) {
    id
    name
    amount
    currency
    interval
  }
}
```

`updatePlan` updates the plan's name and description and its Stripe Product. A plan's
`currency`, `interval` and `type` are fixed once it exists, and so is a subscription's
`amount`: an update that changes any of them returns a `BAD_USER_INPUT` error before any
Stripe call, and the back-office editor only shows those fields when creating a plan. To
give a subscription a new price, [split it](#split-a-plan).

Add-ons and credit packs keep an editable `amount`. Stripe Prices are immutable, so a new
amount creates a replacement Price, updates the local mapping and archives the previous
Price. Subscriptions keep renewing on the Price they were created with: every Price created
for a plan is recorded, and renewals are validated against the Price the member is
subscribed to.

### Fields that cannot be updated [#fields-that-cannot-be-updated]

Configuration tools that compare a declared plan with the stored one cannot call
`updatePlan` when one of these fields changes:

| Field      | Subscription                    | Add-on, credit pack                      |
| ---------- | ------------------------------- | ---------------------------------------- |
| `amount`   | [Split the plan](#split-a-plan) | Updated in place, with a new `requestId` |
| `type`     | Delete, then create             | Delete, then create                      |
| `currency` | Delete, then create             | Delete, then create                      |
| `interval` | Delete, then create             | Delete, then create                      |

`name`, `description` and a credit pack's `creditAmount` are always updated in place. An
in-place `amount` change creates a Stripe Price under the update's `requestId`, so that id
must differ from the ones already used for the plan.

A subscription changes price with `splitPlan`, passing its current amount as
`subscriberAmount`: its subscribers keep the plan and the price they pay, and the new plan
takes its place for everyone else. A change of `type`, `currency` or `interval` needs the
plan deleted and created again, which [is refused](#delete) while members hold it.

## Read one plan [#read-one-plan]

```graphql
query Plan($clubId: ID!, $id: ID!) {
  plan(clubId: $clubId, id: $id) {
    id
    name
    description
    amount
    creditAmount
    currency
    interval
    type
  }
}
```

Returns one plan offered by the club's selected merchant, including a plan replaced by a
split, or null. Like `plans`, it is public. Both ids must be UUIDs. `subscriberPrices` is
null unless the caller manages the catalog of that club.

## Split a plan [#split-a-plan]

Splitting gives new and current subscribers different prices. The split plan keeps its
name, ID and memberships and is reserved to its current subscribers; a new plan with a
name chosen by staff takes its place for everyone else.

```graphql
mutation SplitPlan($input: SplitPlanInput!) {
  splitPlan(input: $input) {
    plan {
      id
      name
      amount
    }
    previous {
      id
      name
      amount
    }
    scheduled
  }
}
```

```json
{
  "input": {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "planId": "60000000-0000-4000-8000-000000000001",
    "requestId": "7f7a2b3c-1d2e-4f50-8a6b-9c0d1e2f3a4b",
    "name": "Adhésion 51 IÉNA (2027) - Abonnement Membre",
    "amount": 200000,
    "subscriberAmount": 190000
  }
}
```

`SplitPlanInput` contains `clubId`, the subscription `planId` to split, a retry-stable
UUID `requestId` (also the new plan's ID), the new plan's trimmed `name` (1–200
characters) and optional `description`, and two minor-unit prices (`0`–`100000000`):
`amount` for new subscribers and `subscriberAmount` for current subscribers. Currency
and interval are copied from the split plan. Only subscription plans can be split, and
a plan can be split once; retrying the same `requestId` returns the same result.

`splitPlan` returns the new `plan`, the retained `previous` plan and the number of
current subscribers whose price change was `scheduled`. It:

* creates a Stripe Product and Price for the new plan and maps it to the club's merchant;
* keeps the split plan's name, sets its `amount` to `subscriberAmount` and links it to
  the new plan through `replacedBy`. When `subscriberAmount` differs from its current
  amount, a new Price is created on the split plan's Product;
* replaces the split plan with the new plan in every `PLAN_SELECTION` block of the club's
  onboardings and increments their revision. Blocks offering all plans need no change;
* hides the split plan from `plans` (unless `includeReplaced: true`), from
  `membershipOffers`, from Checkout and from application plan selection;
* schedules the price change for current subscribers, as described below, and records
  `plan.split` in staff activity with `replacedBy`, `newSubscriberAmount`,
  `subscriberAmount`, `subscribersRepriced` and `subscriberPriceFrom`.

Current subscribers are memberships of the split plan with a Stripe subscription, a paid
period that has not ended, and a Price in the same currency and interval. Members already
paying `subscriberAmount` are left unchanged. The others get the new Price scheduled one
calendar month later and receive a `renewal` notification (in-app and email, per their
preferences) with the old and new amounts and the renewal date from which the new price
is charged.

The new price applies from the member's next payment, or one month after the split if
that payment is sooner. The notification states that date. A background worker switches
each subscription item to the scheduled Price once the notice month has passed:

* if the subscription has not renewed since the split, the switch uses
  `proration_behavior: "none"` and the next renewal charges the new price;
* if it renewed during the notice month (at the old price), the switch uses
  `proration_behavior: "always_invoice"`, so Stripe immediately invoices the prorated
  difference for the rest of the current period (a credit when the price goes down).
  Proration invoices do not change the membership's paid period.

Subscriptions that have ended in Stripe are dropped from the schedule.

Applications that already selected the split plan but have not paid can no longer check
out with it.

## Delete [#delete]

```graphql
mutation DeletePlan($id: ID!, $clubId: ID!) {
  deletePlan(id: $id, clubId: $clubId)
}
```

A plan members still hold cannot be deleted: the mutation answers `BAD_USER_INPUT` with
`Members still hold this plan. End or move their memberships before deleting it.` and
archives nothing. A member holds a plan while their membership has no end date, has not
expired, or is inside a paid period; ended memberships do not count. This applies to a plan
replaced by a split too, for as long as its subscribers stay on it.

Otherwise deletion archives the connected Price and Product, removes the merchant mapping and
hides the local plan. Historical plans referenced by memberships or checkout attempts
remain stored. If several clubs share the same merchant profile, they share its plan
catalog too.

## Ordering and limits [#ordering-and-limits]

Plans are ordered by name and then ID. The query returns at most 100 records and
does not accept sorting or pagination arguments. An empty catalog
returns `[]`.

The plan query is public for application flows. Plan mutations (`createPlan`, `updatePlan`,
`deletePlan` and `splitPlan`) require a staff session that administers the selected club or
holds its `catalog_manage` permission; see [staff permissions](/fr/graphql/auth#individual-staff-permissions).
Mutations require configured sandbox Stripe billing and a selected merchant. Checkout
resolves active subscription Product/Price mappings by club → selected merchant → local plan. Use
[`membershipOffers(clubId)`](/fr/graphql/billing) for purchasing readiness and offers.

## Club credits [#club-credits]

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

Credit packs require a positive EUR amount and create a one-time Stripe price.
Their stored `interval` is ignored for billing. Rate settings determine the
credits granted, snapshotted when Checkout starts. Add-ons are also purchased
once with credits; their interval is not a recurring charge.

Credit packs require `creditAmount` in `PlanInput` (1–100,000,000 hundredths). For example, `amount: 10000, creditAmount: 125000` sells 1,250 credits for €100. Changing the club rate does not change this quantity.
