# Terraform provider

Manage a club's configuration as code with the champagne Terraform provider.

Source: https://docs.champagne.dev-ukab.fr/fr/terraform

Clubs that run several environments keep their configuration in Terraform and promote it
from one environment to the next. The `champagne` provider manages the configuration of one
club through the public [GraphQL API](/fr/graphql): its settings, its plans, its reservation
catalog with opening hours, recurring sessions, staff in charge, floor plans and
housekeeping checklists, the attributes and tags it keeps on people, its webhook endpoint
and its newsletter sender. It never manages people or activity
(members, applications, reservations, invoices, who carries a tag).

Terraform is the source of truth for what it manages: a change made in the back office shows
up as drift at the next `terraform plan` and is reverted by the next `terraform apply`.

## Authenticate [#authenticate]

A club administrator creates an [API token](/fr/graphql/api-tokens) in the back office, under
**Settings**, **Integrations**, **API tokens**, and grants it **Read and write** on each area
the configuration manages. The token names the club: the provider takes no club id, and one
token never reaches another club.

| Area in the back office | Resources and data sources                                                                                                                                                 |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Club settings           | `champagne_club_settings`, `data.champagne_club` (read is enough for the data source)                                                                                      |
| Plans                   | `champagne_plan`                                                                                                                                                           |
| Reservation catalog     | `champagne_reservation_category`, `champagne_reservation_category_links`, `champagne_reservation_resource`, `champagne_reservation_session`, `data.champagne_staff_member` |
| Floor plans             | `champagne_floor_plan`                                                                                                                                                     |
| Housekeeping            | `champagne_housekeeping_checklist`                                                                                                                                         |
| Attributes and tags     | `champagne_person_attribute`, `champagne_person_tag`                                                                                                                       |
| Webhooks                | `champagne_webhook`                                                                                                                                                        |
| Newsletter sender       | `champagne_newsletter_sender`                                                                                                                                              |

A resource whose area the token lacks fails with
`This API token lacks the permission for this operation.`

```hcl
terraform {
  required_providers {
    champagne = {
      source = "ukab-labs/champagne"
    }
  }
}

provider "champagne" {
  endpoint = "https://club.example.com/api/graphql"
  token    = var.champagne_token
}
```

| Argument   | Environment variable | Description                                                  |
| ---------- | -------------------- | ------------------------------------------------------------ |
| `endpoint` | `CHAMPAGNE_ENDPOINT` | URL of the GraphQL API of the environment.                   |
| `token`    | `CHAMPAGNE_TOKEN`    | Club API token with `write` on the managed areas. Sensitive. |

Use one state per environment: each environment has its own endpoint, its own token and its
own ids. A blocked, expired or deleted token, or a token of a suspended club, is refused when
the provider starts.

Every resource below is managed with the token of its club, except `champagne_club` and
`champagne_space`, which take a [platform token](/fr/graphql/api-tokens#platform-tokens).

## Resources [#resources]

Every resource is importable with `terraform import <address> <id>`.

### champagne\_club\_settings [#champagne_club_settings]

The settings of the club, one resource per club.

```hcl
resource "champagne_club_settings" "club" {
  description = "A club for content creators."
  timezone    = "Europe/Paris"
  hosted_slug = "astel"

  address = {
    line1       = "1 rue de la Paix"
    postal_code = "75002"
    city        = "Paris"
    country     = "France"
  }

  deposit_refund_days = 7

  no_show_sanctions = {
    reset_after_days = 90
    rules = [
      { no_shows = 2, action = "warn" },
      { no_shows = 4, action = "suspend", suspension_days = 14 },
    ]
  }
}
```

| Argument                             | Left out               | Notes                                                                 |
| ------------------------------------ | ---------------------- | --------------------------------------------------------------------- |
| `description`                        | Cleared                | At most 2,000 characters.                                             |
| `hosted_slug`                        | Cleared                | 3 to 63 lowercase letters, digits or hyphens; unique across clubs.    |
| `address`                            | Cleared                | `line1`, `postal_code`, `city`, `country`, optional `line2`, `state`. |
| `timezone`                           | Keeps the club's value | IANA timezone.                                                        |
| `reservation_guests_require_account` | Keeps the club's value | Boolean.                                                              |
| `deposit_refund_days`                | Keeps the club's value | 0 to 365.                                                             |
| `credits`                            | Not managed            | `name`, `per_euro`, `low_balance_threshold`; needs billing enabled.   |
| `no_show_sanctions`                  | Not managed            | `reset_after_days` and up to 10 `rules`.                              |

The club name is not a setting: only platform administrators rename a club. Destroying the
resource removes it from the state and leaves the club's settings as they are. The import id
is ignored, since the club is the one of the token.

### champagne\_plan [#champagne_plan]

A membership plan, an add-on or a credit pack. Plans create Stripe products and prices: the
club needs billing enabled and an active Stripe merchant.

```hcl
resource "champagne_plan" "member" {
  name     = "Member"
  amount   = 180000 # cents
  interval = "year"
}

resource "champagne_plan" "locker" {
  name     = "Locker"
  type     = "addon"
  amount   = 1500
  interval = "month"
}
```

Stripe prices are immutable, so a change is applied in one of three ways:

| Argument                        | Change                                                                       |
| ------------------------------- | ---------------------------------------------------------------------------- |
| `name`, `description`           | Updated in place.                                                            |
| `credit_amount`                 | Updated in place.                                                            |
| `amount` of an add-on or a pack | Updated in place, with a new Stripe price.                                   |
| `amount` of a subscription      | **Splits the plan**, which keeps its subscribers.                            |
| `type`, `currency`, `interval`  | **Deletes the plan and creates another one**; refused while members hold it. |

A subscription changing price is split, like in the back office. Terraform shows an update
in place whose `id` is known after apply: the resource then follows a new plan at the new
price, offered from now on. The previous plan is kept as replaced, reserved to the members
who hold it, at the price they pay; Terraform no longer tracks it, and destroying the
resource leaves it to them.

Changing `type`, `currency` or `interval` cannot be a split: Terraform deletes the plan and
creates another one. The API refuses to delete a plan members still hold, so the apply fails
with `Members still hold this plan. End or move their memberships before deleting it.` and
nothing changes. The same refusal protects `terraform destroy`.

`currency` defaults to `eur`, `interval` to `year` and `type` to `subscription`.
`stripe_product_id` is exported.

### champagne\_reservation\_category [#champagne_reservation_category]

A category of things members book.

```hcl
resource "champagne_reservation_category" "visit" {
  name                     = "Discovery visit"
  onboarding_only          = true
  waitlist_enabled         = false
  slot_booking             = true
  default_duration_minutes = 60
  slot_interval_minutes    = 60
  min_notice_minutes       = 120
  booking_window_days      = 30
}
```

`name` is unique in the club, ignoring case. The flags `conversation_required`,
`create_ticket`, `requires_session`, `visualization_enabled`, `housekeeping_enabled`,
`customer_sheets_enabled`, `external_booking_enabled`, `onboarding_only` and `slot_booking`
default to `false`, `waitlist_enabled` to `true`, `slot_interval_minutes` to `30` and
`currency` to `eur`. `default_duration_minutes`, `booking_window_days` and
`min_notice_minutes` mean "no limit" when left out. See
[booking rules](/fr/graphql/reservations#booking-rules-and-time-slots) for what each setting
does. A category holding reservations cannot be destroyed.

`opening_hours` lists when slots can be booked, as windows of one day in the club's
timezone; left out, the category has no opening hours:

```hcl
resource "champagne_reservation_category" "restaurant" {
  name                     = "Restaurant"
  slot_booking             = true
  default_duration_minutes = 120

  opening_hours = [
    { weekday = 2, start_minutes = 720, end_minutes = 870 },   # Tuesday 12:00-14:30
    { weekday = 2, start_minutes = 1140, end_minutes = 1380 }, # Tuesday 19:00-23:00
  ]
}
```

`weekday` runs from 0 (Sunday) to 6 (Saturday). Windows of one day must not overlap.

### champagne\_reservation\_category\_links [#champagne_reservation_category_links]

The categories a reservation of one category can be moved to, such as the two rooms of a
restaurant. Links point one way; they are a resource of their own so two categories can link
to each other.

```hcl
resource "champagne_reservation_category_links" "restaurant" {
  category_id         = champagne_reservation_category.restaurant.id
  linked_category_ids = [champagne_reservation_category.rooftop.id]
}

resource "champagne_reservation_category_links" "rooftop" {
  category_id         = champagne_reservation_category.rooftop.id
  linked_category_ids = [champagne_reservation_category.restaurant.id]
}
```

Declare at most one per category, with up to 50 other categories of the club. Destroying it
removes the links. The import id is the category id.

### champagne\_reservation\_resource [#champagne_reservation_resource]

One bookable thing of a category: a room, a table, a coach.

```hcl
resource "champagne_reservation_resource" "podcast" {
  category_id   = champagne_reservation_category.studios.id
  name          = "Podcast studio"
  amount        = 2500 # cents per price_unit
  price_unit    = "hour"
  max_occupants = 4
}
```

`name` is unique in its category. `amount` defaults to `0`, `price_unit` (`once`, `hour`,
`day` or `night`) to `once`, `max_occupants` to `1` and `external_bookable` to `false`.
`deposit_amount`, `default_check_in_minutes`, `default_check_out_minutes`,
`cancellation_notice_days` and `external_amount` are cleared when left out. Changing
`category_id` replaces the resource. A resource holding reservations cannot be destroyed.

Categories managed by Terraform leave their resources to this resource: adding, renaming or
removing a resource never rewrites the category.

`staff_user_ids` assigns the staff in charge of the resource, such as its coach. Staff
accounts are people: they are invited from the back office and their ids differ between
environments, so look them up by email with `champagne_staff_member`:

```hcl
data "champagne_staff_member" "julien" {
  email = "julien@example.com"
}

resource "champagne_reservation_resource" "tennis" {
  category_id    = champagne_reservation_category.lessons.id
  name           = "Tennis with Julien"
  staff_user_ids = [data.champagne_staff_member.julien.id]
}
```

Left out, the assignment is not managed; an empty set removes everyone. The API refuses to
remove a staff member whose upcoming bookings would be left without anyone in charge.

### champagne\_reservation\_session [#champagne_reservation_session]

A recurring session of a category with `requires_session`: a class members book a seat in.

```hcl
resource "champagne_reservation_session" "pilates" {
  category_id      = champagne_reservation_category.classes.id
  resource_id      = champagne_reservation_resource.studio.id
  title            = "Pilates"
  weekday          = 1   # Monday
  start_minutes    = 900 # 15:00
  duration_minutes = 60
  capacity         = 8
}
```

`kind` is `weekly` (the default, with a `weekday`) or `daily` (without one). `capacity` is
left out for unlimited sessions. `reservation_required = false` describes a session members
attend without booking; it takes no `capacity`. `guest_plan_ids` restricts the session to the
members of some plans.

The API cannot edit a recurring session in place, so **any change replaces the session**:
Terraform deletes it and creates another one. A session with bookings or a waitlist cannot be
deleted, and therefore cannot be changed until they are cancelled. Dated one-off sessions are
events rather than configuration: create them in the back office. The import id is
`<category id>/<session id>`.

### champagne\_floor\_plan [#champagne_floor_plan]

The floor plan of a category with `visualization_enabled`: its spaces and the elements drawn
in them. Members pick the resource they book by selecting its element.

```hcl
resource "champagne_floor_plan" "pool" {
  category_id = champagne_reservation_category.pool.id

  spaces = [{
    id      = "7b000000-0000-4000-8000-000000000001"
    name    = "Outdoor pool"
    surface = "terrace"
    palette = "lagoon"
  }]

  elements = [
    {
      id       = "7b000000-0000-4000-8000-000000000101"
      space_id = "7b000000-0000-4000-8000-000000000001"
      kind     = "swimmingPool"
      label    = "Pool"
      x        = 100
      y        = 80
      width    = 400
      height   = 200
      locked   = true
    },
    {
      id          = "7b000000-0000-4000-8000-000000000102"
      space_id    = "7b000000-0000-4000-8000-000000000001"
      kind        = "rectangle"
      label       = champagne_reservation_resource.lounger.name
      resource_id = champagne_reservation_resource.lounger.id
      x           = 120
      y           = 320
      width       = 60
      height      = 120
      rotation    = 90
    },
  ]
}
```

The ids of spaces and elements are UUIDs written in the configuration, so they are the same
in every environment. A plan has 1 to 12 `spaces` (`surface` is `terrace`, `interior` or
`garden`; `palette` and `color` are optional) and at most 200 `elements`, drawn in order.

| Element argument                                                                              | Notes                                                                                   |
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `id`, `space_id`, `kind`, `label`                                                             | Required. `kind` is a shape such as `rectangle`, `ellipse`, `pottedPlant`, `parasol`.   |
| `x`, `y`, `width`, `height`                                                                   | Required, in plan units.                                                                |
| `rotation`, `locked`                                                                          | Default to `0` and `false`.                                                             |
| `resource_id`                                                                                 | Resource of the category booked through this element; each resource is placed once.     |
| `object_id`                                                                                   | Custom object of the category's library. Objects carry artwork made in the back office. |
| `color`, `stroke_color`, `stroke_width`, `outline_only`, `label_visible`, `keep_aspect_ratio` | Optional appearance.                                                                    |
| `chairs`, `chair_positions`, `points`, `bookable`                                             | Optional geometry of tables, polygons and decoration.                                   |

The API names an element placing a resource after that resource: set its `label` to the
resource's `name`, as above, or the next plan reports a change. Such an element is a
`rectangle` or an `ellipse`. `bookable = true` without a `resource_id` is refused, because the
API would create a resource Terraform does not manage.

The plan is saved over the revision currently stored: if someone edits it in the back office
during an apply, the apply fails and can be run again. Destroying the resource removes every
element and keeps the spaces, since a stored plan always has one. The import id is the
category id.

### champagne\_housekeeping\_checklist [#champagne_housekeeping_checklist]

The housekeeping checklist of one resource, in a category with `housekeeping_enabled`.

```hcl
resource "champagne_housekeeping_checklist" "garden" {
  category_id = champagne_reservation_category.suites.id
  resource_id = champagne_reservation_resource.garden.id

  items = [
    { category = "daily", label = "Air the room" },
    { category = "daily", label = "Restock towels" },
    { category = "after_reservation", label = "Change the sheets" },
  ]
}
```

`items` holds at most 100 items in display order. `category` is `daily` (every day) or
`after_reservation` (each departure); labels are distinct within a category. An item is
identified by its category and label: reordering keeps the ticks staff recorded, rewording an
item replaces it and drops them. Destroying the resource empties the checklist. Reading a
checklist needs the club's housekeeping module. The import id is
`<category id>/<resource id>`.

### champagne\_webhook [#champagne_webhook]

One webhook endpoint of the club. A club has up to 10 of them, each with its own URL, events
and signing secret. See [webhooks](/fr/webhooks) for the events and their signature.

```hcl
resource "champagne_webhook" "website" {
  url            = "https://hooks.example.com/champagne"
  enabled_events = ["reservation.created", "application.status_changed"]
}

resource "champagne_webhook" "accounting" {
  url            = "https://accounting.example.com/hooks"
  enabled_events = ["invoice.issued", "invoice.paid", "invoice.credited"]
  enabled        = false
}

output "website_signing_secret" {
  value     = champagne_webhook.website.signing_secret
  sensitive = true
}
```

`url` is a public HTTPS URL, unique in the club. `enabled_events` lists the event types to
send; left out, the endpoint keeps its selection, which starts with every event. `enabled`
defaults to `true`; set it to `false` to pause deliveries, knowing that events that happen
meanwhile are not sent later. `signing_secret` is exported and sensitive: it is read when the
resource is created or imported, then kept in the state, so store the state accordingly.
After rotating a secret in the back office, remove the resource from the state and import it
again to read the new one. Destroying the resource deletes the endpoint and its secret.
Import with the endpoint id.

### champagne\_newsletter\_sender [#champagne_newsletter_sender]

The address the club sends its newsletters from, one resource per club. It needs newsletter
sending configured on the platform.

```hcl
resource "champagne_newsletter_sender" "club" {
  domain      = "news.example.com"
  sender_name = "Astel"
}

output "newsletter_dns_records" {
  value = champagne_newsletter_sender.club.dns_records
}
```

`local_part` defaults to `newsletter`. `sender_email`, `status` (`pending`, `verified` or
`failed`) and `dns_records` (`type`, `name`, `value`) are exported: publish the records in
the DNS zone of the domain, with Terraform or by hand, and the status becomes `verified`
once they are found. The API cannot remove a sender: destroying the resource only removes it
from the state. The import id is ignored.

### champagne\_person\_attribute [#champagne_person_attribute]

A custom field the club keeps on each person.

```hcl
resource "champagne_person_attribute" "language" {
  key     = "preferred_language"
  label   = "Preferred language"
  type    = "choice"
  options = ["French", "English", "German"]
}
```

`key` is the stable identifier of the attribute, unique in the club: a lowercase letter
followed by up to 39 lowercase letters, digits or underscores. `type` is `text` (the
default), `number`, `date`, `boolean` or `choice`; `options` lists the 2 to 50 choices of a
`choice` attribute and is left out otherwise. `label` and `options` are updated in place.
Changing `key` or `type` **replaces the attribute**, which erases the values people hold for
it. An attribute used by an onboarding question cannot be destroyed. See
[member attributes](/fr/graphql/person-attributes).

### champagne\_person\_tag [#champagne_person_tag]

A staff-only label grouping people of the club.

```hcl
resource "champagne_person_tag" "founder" {
  label = "Founding member"
  color = "amber"
}
```

`label` is unique in the club, ignoring case, and holds at most 24 characters. `color`
defaults to `gray`; the palette is listed with [member tags](/fr/graphql/person-tags). Terraform
manages the tag, never who carries it. Destroying a tag removes it from everyone carrying it.

### champagne\_club [#champagne_club]

A club of a customer space. It is the one resource managed with a **platform** token, created
by a platform admin in the **Platform** section under **API tokens**: configure a provider
alias for it, next to the provider of each club.

```hcl
provider "champagne" {
  alias = "platform"
  token = var.champagne_platform_token
}

data "champagne_space" "customer" {
  provider = champagne.platform
  name     = "NJJ Hospitality"
}

resource "champagne_club" "astel" {
  provider          = champagne.platform
  space_id          = data.champagne_space.customer.id
  name              = "Astel"
  timezone          = "Europe/Paris"
  disabled_features = ["billing"]
}
```

`name` is unique in its space, ignoring case, and starts with a capital letter. `timezone`
defaults to `Europe/Paris`. `disabled_features` turns modules off for the club:
`reservations`, `availability`, `customer_chats`, `housekeeping`, `newsletters`,
`applications`, `billing`. `hosted_slug` can be set here or in `champagne_club_settings`, not
in both. `status` is exported. Changing `space_id` replaces the club.

The platform never deletes a club: **destroying the resource archives it**, which closes it
to its members and staff and keeps its data. An archived club keeps its name, so a club
destroyed this way cannot be created again under the same name in that space; restore it
from the back office and import it instead.

A new club has no API token: a club administrator creates one in the back office before the
club's own configuration can be applied. Spaces and their owners are created in the back
office too.

## Data sources [#data-sources]

### champagne\_space [#champagne_space]

A customer space found by `name` (ignoring case), with a platform token: `id`.

### champagne\_club [#champagne_club-1]

The club of the token: `id`, `name`, `description`, `timezone`, `hosted_slug` and
`billing_enabled`.

```hcl
data "champagne_club" "current" {}
```

### champagne\_staff\_member [#champagne_staff_member]

A staff member of the club found by `email` (ignoring case): `id` and `name`. Fails when
nobody in the club's team has this email.

## Example [#example]

`terraform-provider/examples/astel` in the repository configures the Astel club like the
demo seed does: its settings, four categories of spaces with their resources, the discovery
visit booked during onboarding and the attribute staff fill in for each creator.

## Develop the provider [#develop-the-provider]

The provider lives in `terraform-provider/` and only talks to the GraphQL API. Go and
Terraform run in Docker, behind `make` targets:

| Target                  | Does                                                                                                                                                                                         |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `make provider-build`   | Builds the provider into `terraform-provider/bin`.                                                                                                                                           |
| `make provider-fix`     | Formats the Go code and the examples.                                                                                                                                                        |
| `make provider-check`   | Checks formatting, runs `go vet` and the tests against an in-memory API, validates the example.                                                                                              |
| `make provider-testacc` | Runs the same tests against the running stack, in a club created for the run.                                                                                                                |
| `make provider-example` | Applies the example to a club created for the run, then requires an empty plan.                                                                                                              |
| `make provider-try`     | Runs Terraform on a copy of the example in `.local/provider-try` with the token in `CHAMPAGNE_TOKEN`: shows the plan and asks before applying. `TF=plan` and `TF=destroy` run those instead. |

`provider-testacc`, `provider-example` and `provider-try` need a stack started with `make worktree-up` or
`make dev`: they sign in with the seeded demo accounts to create a club and its token. The
plan tests need Stripe and are skipped unless run against a club with an active merchant:

```bash
make provider-testacc CLUB_ID=51000000-0000-4000-8000-000000000051 CHAMPAGNE_TEST_PLANS=1 ARGS="-run Plan"
```

The `provider` job of the CI runs `provider-check`, starts the Compose stack, then runs
`provider-example` and `provider-testacc` against it, so every pull request applies the
example to a real API and requires its second plan to be empty.
