# Club credits

Credit settings, one-time refills, wallets and purchases.

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

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.

Club memberships remain billed in money. Reservation and add-on `amount` fields
are prices in hundredths of a club credit; credit-pack `amount` fields are euro
cents. `credit_pack` plans are one-time top-ups, never subscriptions. Configure
packs in Plans and the credit name and conversion rate in club settings.

A club with the `billing` module turned off refuses top-ups, credit settings and every credit
debit with `BAD_USER_INPUT` ("Payments are turned off for this club."). Balances, history and
refunds keep working; see [Clubs with billing turned off](/fr/graphql/platform#clubs-with-billing-turned-off).

All credit amounts are integers in hundredths, including balances and the
`creditsPerEuro` setting. For example, `creditsPerEuro: 1000` means €1 buys 10
credits at the standard rate. Every pack sets its own `creditAmount`: a €100 pack
(`amount: 10000`) can grant 1,250 credits (`creditAmount: 125000`). Pack quantities
must be integers from 1 through 100,000,000 units. The consumer displays savings
against the standard rate: `max(0, floor(100 - amount * creditsPerEuro / creditAmount))`.
This pack saves 20%. The frontend calculates this display only; the server snapshots
and grants the exact stored pack quantity. Rate changes never alter pack quantities.

## Settings [#settings]

`creditSettings(clubId)` returns `creditName`, `creditsPerEuro` and
`lowBalanceThreshold`. Members can read settings for their club, as can every staff account of
the club; `configureCredits` requires `settings_manage` (club administrators included; see
[staff permissions](/fr/graphql/auth#individual-staff-permissions)).
`configureCredits` accepts a trimmed name of 1–40 characters without control
characters, an integer rate of 1–1,000,000 (0.01–10,000 credits per euro), and a
`lowBalanceThreshold` in euro cents from 0 through 100,000,000 (default `1000`, €10).

## Low balance alerts [#low-balance-alerts]

When a debit (reservation, deposit or add-on) moves a wallet from at or above the
threshold to below it, the member receives a `credits` notification. The euro
threshold is converted at the club's current rate: the balance is low when
`balance * 100 < lowBalanceThreshold * creditsPerEuro`. One alert is queued per
crossing; further debits below the threshold do not repeat it, and a refill above
the threshold rearms it. `0` disables alerts. The email is skipped if the balance
is no longer low when the worker claims it, and the member's `credits`
notification preferences apply.

```graphql
mutation {
  configureCredits(
    clubId: "51000000-0000-4000-8000-000000000051"
    creditName: "Stars"
    creditsPerEuro: 1000
    lowBalanceThreshold: 1000
  ) {
    creditName
    creditsPerEuro
    lowBalanceThreshold
  }
}
```

## Balance and history [#balance-and-history]

`creditWallet(clubId, personId, page: 1)` returns `balance`, `creditName`,
`creditsPerEuro`, `entries` and `hasMore`. Only the person or staff with `members_read` in that
person's club (club administrators included) can read it. The person must belong to the
requested club. New wallets have a zero balance.

History pages have 20 entries; `page` is 1–10,000. Ordering is newest `createdAt`
first, then descending `id`. There are no filter or sort arguments. Each entry
has `id`, signed `amount`, resulting `balance`, `kind` (`refill`, `reservation`, `deposit`,
`refund`, `refund-deposit`, `addon`; `refund-deposit` is a returned reservation deposit, `refund`
a refunded price), `description`, `paidAmount` (euro cents for refills, otherwise
null), the name used for that transaction (`creditName`), and ISO `createdAt`.

```graphql
query {
  creditWallet(
    clubId: "51000000-0000-4000-8000-000000000051"
    personId: "51000000-0000-4000-8000-000000000013"
    page: 1
  ) {
    balance
    creditName
    hasMore
    entries {
      id
      kind
      amount
      balance
      paidAmount
      description
      creditName
      createdAt
    }
  }
}
```

## One-time top-ups [#one-time-top-ups]

`createCreditCheckout(clubId, personId, planId, requestId)` returns a `CreditCheckout`
with `url: String` and `sessionId: String!`. IDs must be UUIDs. Choose an active
`credit_pack` from `plans(clubId)`. The pack must be priced in EUR with a positive
amount and credit quantity and belong to the club's configured merchant. The person
must be active and belong to the club. Members can purchase only for themselves.
Guests and temporary accounts from [external reservations](/fr/graphql/external-reservations)
receive `UNAUTHENTICATED`, and the purchase itself refuses any person with a guest access
grant (`Guests cannot purchase credits.`).

Keep the same request ID when retrying an uncertain request. The server snapshots
the pack, euro price, credit name and credit quantity when reserving the purchase.
Settings, quantity or price changes do not change an existing checkout.
The server reuses an open session or replaces an expired session with an idempotent
replacement key. Canceling an unpaid checkout grants no credits.

When Checkout is complete or the purchase is already credited, `url` is `null`:
do not open another payment. Query `myCreditTopUpStatus` for that request instead.
Checkout completion alone does not prove that the wallet has been credited. When the
complete session is paid but its webhook was missed, this call credits the purchase
itself, exactly once.

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

A signed webhook and a verified, paid Stripe Checkout session from the expected
connected account grant the refill. The session must match the snapshotted amount,
EUR currency and purchase metadata. Its payment intent ID is retained without a
second PaymentIntent retrieval. Returning from Checkout does not itself grant
credits. Duplicate webhooks cannot credit a purchase twice.

The member UI retains each pending `requestId` per club, member and pack in the
browser session and sends one checkout mutation per attempt. It removes the saved
request only after that purchase is confirmed as credited, allowing a new purchase.

The success return URL includes the original `requestId`. Members query
`myCreditTopUpStatus(clubId, requestId)` for that exact purchase. Both IDs must be UUIDs;
the authenticated member and club scope are enforced. An unknown or other member's request
returns `null`. The result exposes `completed`, `credits` (hundredths) and `creditName`.
`completed` becomes true only when fulfillment has atomically credited the wallet.
The member UI polls until completion, shows &#x2A;*Credits added.**, and refreshes the wallet.
A different refill or refund never marks this request as completed.

```graphql
query TopUpStatus($clubId: ID!, $requestId: ID!) {
  myCreditTopUpStatus(clubId: $clubId, requestId: $requestId) {
    completed
    credits
    creditName
  }
}
```

Account eligibility for credit purchases, reservation payments and add-on purchases
uses the effective status on today’s UTC date. Future and elapsed suspensions do not
block these operations; a suspension blocks them on both boundary dates and every
day between. Banned and disabled accounts remain blocked. This does not change the
processing of an already paid top-up or reservation refunds.

## Spend and cancel [#spend-and-cancel]

`payReservationCredits(clubId, reservationId, paymentVersion)` and
`payAddonCredits(clubId, planId)` return `true`. Both derive the person from the
signed-in member, enforce club ownership and require an active account.

If the wallet cannot cover the debit, these mutations return a GraphQL error with
`extensions.code: "INSUFFICIENT_CREDITS"`. Clients should select their localized
top-up guidance using this code, never by comparing `message`. Other credit
validation errors retain `BAD_USER_INPUT`; unexpected technical errors remain
masked. A rejected debit leaves the wallet unchanged.

Reservations must be booked and awaiting payment. Send the `paymentVersion` UUID from the
displayed reservation. A changed version or outstanding proposal is rejected before any debit;
refresh and ask for explicit payment again. Successful retries remain idempotent. The transaction debits the
quoted reservation price and security deposit together, confirms the reservation and queues its existing
confirmation notification. Price and deposit are separate ledger entries (`reservation` and `deposit`) in the same transaction. Insufficient funds for their total rolls back both debits. A zero-price booking still requires payment if it has a deposit. An already paid reservation is idempotent. Add-ons
use the current active club plan price and grant the existing membership record.
Already owned add-ons do not debit again. Balances cannot go negative, including
when two different purchases run concurrently.

Cancellation through the existing member or staff reservation mutation, including
`cancelReservationSession` for all bookings in an occurrence, refunds exactly the
credits debited, once. A member cancellation within the resource's
[cancellation notice](/fr/graphql/reservations#cancellation-notice) refunds only the collected deposit. Exchange-rate or credit-name changes do not
alter that refund. After departure, the collected deposit is refunded once, in full or in part
(see [Departure and deposit refunds](/fr/graphql/reservations#departure-and-deposit-refunds)); the price remains charged. Pending or unpaid reservations do not produce a refund. All refunds appear as `refund` entries in credit history.
Changing the window, resource, or products of a paid reservation through `updateReservation` or
`updateMyReservation` debits or refunds the price difference as a single entry described as
`<resource> · Reservation change` (or `· Products` when only products changed); see
[Rescheduling a paid reservation](/fr/graphql/reservations#rescheduling-a-paid-reservation). Cancellation
then refunds the net amount debited.
`createReservationCheckout` and `createAddonCheckout` reject new cash purchases
from members and direct them to credits. Only temporary accounts created by
[external reservations](/fr/graphql/external-reservations) pay their booking by card. Existing cash-payment webhook
and refund processing remains available for outstanding payments.
