Champagne

Terraform provider

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

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: 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

A club administrator creates an API token 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 officeResources and data sources
Club settingschampagne_club_settings, data.champagne_club (read is enough for the data source)
Planschampagne_plan
Reservation catalogchampagne_reservation_category, champagne_reservation_category_links, champagne_reservation_resource, champagne_reservation_session, data.champagne_staff_member
Floor planschampagne_floor_plan
Housekeepingchampagne_housekeeping_checklist
Attributes and tagschampagne_person_attribute, champagne_person_tag
Webhookschampagne_webhook
Newsletter senderchampagne_newsletter_sender

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

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

provider "champagne" {
  endpoint = "https://club.example.com/api/graphql"
  token    = var.champagne_token
}
ArgumentEnvironment variableDescription
endpointCHAMPAGNE_ENDPOINTURL of the GraphQL API of the environment.
tokenCHAMPAGNE_TOKENClub 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.

Resources

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

champagne_club_settings

The settings of the club, one resource per club.

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 },
    ]
  }
}
ArgumentLeft outNotes
descriptionClearedAt most 2,000 characters.
hosted_slugCleared3 to 63 lowercase letters, digits or hyphens; unique across clubs.
addressClearedline1, postal_code, city, country, optional line2, state.
timezoneKeeps the club's valueIANA timezone.
reservation_guests_require_accountKeeps the club's valueBoolean.
deposit_refund_daysKeeps the club's value0 to 365.
creditsNot managedname, per_euro, low_balance_threshold; needs billing enabled.
no_show_sanctionsNot managedreset_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

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.

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:

ArgumentChange
name, descriptionUpdated in place.
credit_amountUpdated in place.
amount of an add-on or a packUpdated in place, with a new Stripe price.
amount of a subscriptionSplits the plan, which keeps its subscribers.
type, currency, intervalDeletes 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

A category of things members book.

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 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:

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.

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.

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

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

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:

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

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

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

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.

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 argumentNotes
id, space_id, kind, labelRequired. kind is a shape such as rectangle, ellipse, pottedPlant, parasol.
x, y, width, heightRequired, in plan units.
rotation, lockedDefault to 0 and false.
resource_idResource of the category booked through this element; each resource is placed once.
object_idCustom 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_ratioOptional appearance.
chairs, chair_positions, points, bookableOptional 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

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

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

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 for the events and their signature.

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

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

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

A custom field the club keeps on each person.

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.

champagne_person_tag

A staff-only label grouping people of the club.

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. Terraform manages the tag, never who carries it. Destroying a tag removes it from everyone carrying it.

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.

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

champagne_space

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

champagne_club

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

data "champagne_club" "current" {}

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

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

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

TargetDoes
make provider-buildBuilds the provider into terraform-provider/bin.
make provider-fixFormats the Go code and the examples.
make provider-checkChecks formatting, runs go vet and the tests against an in-memory API, validates the example.
make provider-testaccRuns the same tests against the running stack, in a club created for the run.
make provider-exampleApplies the example to a club created for the run, then requires an empty plan.
make provider-tryRuns 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:

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.

Sur cette page