# External reservations

Let non-members book exposed resources online with a temporary account and pay by card.

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

A reservation category can open some of its resources to people who are not club members.
On the public member site (`/book/<clubId>`), a visitor chooses a category, a resource, the
number of people (only for resources holding more than one) and a period, then either signs in
as a member or books without a membership. Booking without a
membership creates a **temporary account** that ends at the close of the reservation's last
day and pays the price and security deposit by card through Stripe Checkout.

```mermaid
sequenceDiagram
  participant V as Visitor
  participant A as Champagne API
  participant S as Stripe
  V->>A: externalReservationCatalog / externalReservationAvailability
  V->>A: createExternalReservation
  A-->>V: token + checkoutUrl (reservation booked)
  V->>S: Pay price + deposit
  S->>A: checkout.session.completed
  A-->>A: reservation confirmed, deposit marked paid
  Note over A: End of last day: temporary account deleted
```

Members who sign in on the same page book through [`createReservation`](/fr/graphql/reservations#request-a-reservation)
as usual: they pay with credits at their membership price, and still need an active membership.

## Configuration [#configuration]

* `ReservationCategoryInput.externalBookingEnabled` (default `false`) turns the module on. It
  cannot be combined with `conversationRequired` or `requiresSession`; the mutation returns
  `BAD_USER_INPUT` (`External booking cannot be combined with required conversations or sessions.`)
  and a database check also refuses the combination.
* `UpdateReservationResourceInput.externalBookable` exposes one resource. Omitting it keeps the
  current value.
* `UpdateReservationResourceInput.externalAmount` is the non-member price in cents per
  `priceUnit`, from 0 to 100,000,000. It is required when `externalBookable` is true
  (`BAD_USER_INPUT`, `Enter the price for non-members.`), because member prices are in credits.
  It is always written, so omitting it clears it, like `depositAmount`.

`ReservationCategory.externalBookingEnabled`, `ReservationResource.externalBookable` and
`ReservationResource.externalAmount` return the stored values.

External bookings follow the category's
[booking rules](/fr/graphql/reservations#booking-rules-and-time-slots): opening hours, booking window
and minimum notice. When the category sets `defaultDurationMinutes`, exposed in the catalog, the end
is the start plus that length and the booking page hides the end fields. Slot booking cannot be
combined with external booking.

## Clubs open to online booking [#clubs-open-to-online-booking]

```graphql
query ExternalBookingClubs {
  externalBookingClubs {
    id
    name
  }
}
```

The query is public. It lists, by name then ID, the clubs with at least one category open to
external booking that has an `externalBookable` resource with an `externalAmount`. The member
site's **Book online** navigation tab uses it.

## Catalog [#catalog]

```graphql
query ExternalCatalog($clubId: ID!) {
  externalReservationCatalog(clubId: $clubId) {
    clubId
    clubName
    timezone
    categories {
      id
      name
      description
      currency
      defaultDurationMinutes
      resources {
        id
        categoryId
        name
        amount
        priceUnit
        depositAmount
        maxOccupants
        cancellationNoticeDays
      }
    }
  }
}
```

The query is public. It returns `null` for an unknown club. Only categories with
`externalBookingEnabled` that have at least one `externalBookable` resource with an
`externalAmount` are listed, ordered by name then ID, and only those resources are included.
`amount` is the non-member price (`externalAmount`). `depositAmount` is the resource's security
deposit, set in credits, converted to euro cents at the club's `creditsPerEuro` rate.

## Availability [#availability]

```graphql
query ExternalAvailability($clubId: ID!, $categoryId: ID!, $from: String!, $to: String!) {
  externalReservationAvailability(clubId: $clubId, categoryId: $categoryId, from: $from, to: $to) {
    busyPeriods {
      resourceId
      startsAt
      endsAt
    }
    blocks {
      resourceId
      startsAt
      endsAt
    }
  }
}
```

The query is public. `from` and `to` are UTC ISO timestamps at most 62 days apart; other values
return `BAD_USER_INPUT`. A category that is not open to external booking returns empty lists.
Busy periods carry no customer information. A block with a null `resourceId` covers the whole
category.

## Book without a membership [#book-without-a-membership]

```graphql
mutation CreateExternalReservation($input: CreateExternalReservationInput!) {
  createExternalReservation(input: $input) {
    token
    personId
    reservationId
    checkoutUrl
    error
  }
}
```

```json
{
  "input": {
    "clubId": "51000000-0000-4000-8000-000000000051",
    "categoryId": "73000000-0000-4000-8000-000000000001",
    "resourceId": "79000000-0000-4000-8000-000000000001",
    "startsAt": "2026-11-02T14:00:00.000Z",
    "endsAt": "2026-11-04T10:00:00.000Z",
    "firstName": "Iris",
    "lastName": "Visitor",
    "email": "iris@example.com",
    "phoneNumber": "+33 6 12 34 56 78",
    "password": "a long password",
    "guests": 2
  }
}
```

| Argument      | Type      | Description                                                                           |
| ------------- | --------- | ------------------------------------------------------------------------------------- |
| `clubId`      | `ID!`     | Club of the category.                                                                 |
| `categoryId`  | `ID!`     | Category with `externalBookingEnabled`.                                               |
| `resourceId`  | `ID!`     | An `externalBookable` resource of that category.                                      |
| `startsAt`    | `String!` | UTC ISO start, in the future.                                                         |
| `endsAt`      | `String!` | UTC ISO end, after the start and at most 31 days later.                               |
| `firstName`   | `String!` | 1-100 characters after trimming.                                                      |
| `lastName`    | `String!` | 1-100 characters after trimming.                                                      |
| `email`       | `String!` | Trimmed and compared case-insensitively.                                              |
| `phoneNumber` | `String!` | 6-30 digits, spaces, `+`, `(`, `)`, `.` or `-`.                                       |
| `password`    | `String!` | 12-200 characters with a lowercase letter, an uppercase letter, a digit and a symbol. |
| `guests`      | `Int!`    | Number of people including the booker, default 1.                                     |

The mutation is public. Malformed IDs or timestamps return `BAD_USER_INPUT`. Business refusals
return a result with every other field null and a user-facing `error`, without creating anything:

* the category is not open to online booking, or the resource is not exposed;
* the period is in the past, inverted, longer than 31 days, blocked or already reserved;
* `guests` is above the resource's `maxOccupants` (`Too many people for this resource.`); values
  outside 1-500 return `BAD_USER_INPUT`;
* the email already has an account in this club (`This email already has an account in this club. Sign in instead.`).
  An expired temporary account with that email is deleted and replaced;
* the club has no Stripe merchant configured for payments.

On success, one transaction creates the person, its credential, a `guest_access` grant of kind
`external`, a session and the reservation, then Champagne opens a Stripe Checkout session for
`amount + depositAmount`. The reservation is `booked` until the payment completes, or
`confirmed` straight away when nothing is due. `token` signs the visitor in as the temporary
account. `checkoutUrl` is the Stripe page; it is null when nothing is due, or when Stripe could
not be reached, in which case `error` asks the visitor to pay from their account.

The reservation price uses the resource's non-member price and price unit. The deposit is the
resource's `depositAmount`, set in credits and converted to euro cents at the club's
`creditsPerEuro` rate when the booking is made. The booking snapshots the contact details in
`Reservation.externalCustomer` and sets `Reservation.source` to `external`.

## Card payment [#card-payment]

`createReservationCheckout(clubId, reservationId, requestId)` returns the open Stripe Checkout
session of a `booked` reservation for the signed-in temporary account that owns it, or creates a
new one. Regular members receive an error because they pay with credits. When the last checkout is
already paid but its `checkout.session.completed` webhook has not been processed yet, no new session
is created: the reservation is confirmed from the paid session, as the webhook would, and the
mutation returns the error `"Your payment was received. Your reservation is being confirmed."`. A checkout expires after
30 minutes. When Stripe reports `checkout.session.expired`, the still-unpaid reservation is
canceled and its period released. `checkout.session.completed` confirms it and marks the deposit
as paid. Canceling an unpaid reservation with `cancelReservation`, `updateReservation` or
`cancelMyReservation` expires
its open checkout; the cancellation still succeeds if Stripe cannot be reached. If the reservation
was canceled before the payment completed anyway, the webhook refunds the
price and deposit in full instead of confirming it, and records the payment and refund on the
canceled reservation.

Refunds go back to the card:

* `cancelMyReservation` by the temporary account refunds the price and deposit, or only the
  deposit once the cancellation notice has passed. It is refused once the reservation has started.
* Staff canceling with `cancelReservation` or `updateReservation` refund the price and deposit.
  Once the cancellation notice has passed, only the deposit is refunded unless they pass
  `refundLateCancellation: true`.
* After departure, staff refunding the deposit, or the automatic refund, return all or part of it
  (see [Departure and deposit refunds](/fr/graphql/reservations#departure-and-deposit-refunds)).

## Temporary accounts [#temporary-accounts]

`memberViewer.external` is `true` for temporary accounts and `guestExpiresAt` is the end of their
access: the next club-local midnight after the reservation ends. `PersonDetail.guestAccess.invitedBy.kind`
is `external`, with a null inviter. Like guests, temporary accounts cannot buy memberships or
add-ons. They also cannot buy or spend credits, create or change reservations, join waitlists,
invite people or open the payment-method portal (`UNAUTHENTICATED`). They can read their account
and reservations, download tickets, chat, cancel and pay by card.

Every five minutes a worker deletes temporary accounts whose access has ended. Their sessions,
credential, access grant and other personal records are deleted with the person. Unpaid
reservations are canceled first. Reservations keep `guestName`, `source` and the
`externalCustomer` contact details as the booking record. Three years after a reservation
ends, the worker anonymises it: `externalCustomer` becomes null and `guestName` becomes
`External customer`.
