Club credits
Credit settings, one-time refills, wallets and purchases.
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.
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
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).
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
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.
mutation {
configureCredits(
clubId: "51000000-0000-4000-8000-000000000051"
creditName: "Stars"
creditsPerEuro: 1000
lowBalanceThreshold: 1000
) {
creditName
creditsPerEuro
lowBalanceThreshold
}
}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.
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
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
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.
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 Credits added., and refreshes the wallet.
A different refill or refund never marks this request as completed.
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
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 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); 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. 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 pay their booking by card. Existing cash-payment webhook
and refund processing remains available for outstanding payments.