diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8b0938d..d7e208e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "use-crystallize", "source": "./use-crystallize", "description": "Everything you need to use Crystallize with your agent, skills, agents, commands, hooks and MCP servers.", - "version": "3.6.1", + "version": "3.7.0", "category": "commerce", "tags": ["commerce", "headless", "skills", "mcp", "crystallize"] } diff --git a/README.md b/README.md index fc160fc..89035a6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ bun type-check # TypeScript type checking Skills are plain markdown files in `use-crystallize/skills/` — no build step required. Each skill has a `SKILL.md` with YAML frontmatter and an optional `references/` directory with supporting docs. -Available skills: `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. +Available skills: `bookable-resources`, `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. ## Using the Claude Plugin diff --git a/use-crystallize/.claude-plugin/plugin.json b/use-crystallize/.claude-plugin/plugin.json index 8e65f2d..8be7f14 100644 --- a/use-crystallize/.claude-plugin/plugin.json +++ b/use-crystallize/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "use-crystallize", - "version": "3.6.1", + "version": "3.7.0", "description": "Everything you need to use Crystallize with your agent, skills, agents, commands, hooks and MCP servers.", "author": { "name": "Crystallize", diff --git a/use-crystallize/mcp-servers/crystallize/package.json b/use-crystallize/mcp-servers/crystallize/package.json index 6ed8a62..5bb866d 100644 --- a/use-crystallize/mcp-servers/crystallize/package.json +++ b/use-crystallize/mcp-servers/crystallize/package.json @@ -1,6 +1,6 @@ { "name": "crystallize-mcp-server", - "version": "3.5.0", + "version": "3.7.0", "private": true, "type": "module", "scripts": { diff --git a/use-crystallize/skills/bookable-resources/SKILL.md b/use-crystallize/skills/bookable-resources/SKILL.md new file mode 100644 index 0000000..244f2c3 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/SKILL.md @@ -0,0 +1,112 @@ +--- +name: bookable-resources +description: > + Sell Crystallize products that are held in time rather than counted in stock — rentals, services, + rooms, courses, equipment, appointments. Covers booking policies and bookable pools on the Core API, + and the booking flow on the Shop API /cart endpoint: availability, holds, checkout and confirmation. + Use when the user wants to rent something out, take bookings or reservations, sell time slots or + by the day/weekend/week, show a booking calendar, manage a fleet of machines, rooms or seats, or + handle cancellations. Trigger on "bookable", "booking", "reservation", "rental", "rent", "hire", + "availability", "time slot", "calendar", "hold", "setBookable", "bookSkuItem", + "createBookingPolicy", "confirmCartBooking", "cancelReservation", "rebookReservation", + "nearestAvailability", "checkBooking", "booking policy", "cancellation window", "pool", "units". +metadata: + author: Crystallize + version: "1.0" +--- + +# Crystallize Bookable Resources + +A bookable product is not counted down like stock — it is **held for a window of time and given back**. +A digger rented Friday to Monday, a meeting room at 09:00, a photographer for an afternoon. The same +product sells again and again; what is scarce is the calendar. + +Crystallize serves this natively: a **policy** carries the rules, a **pool** carries the things that can +be booked, and the Shop API holds them for a shopper while they shop, then hands them to the order. + +> Verified on 2026-09-24 against the live Core API and Shop API on a tenant with bookable resources, +> including two test reservations taken and released. Where a claim comes from one storefront build +> rather than from the API, this skill says so. + +## Five concepts + +| Concept | What it is | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| **Policy** | The rules, all durations in **seconds**: how far ahead, buffers, how long a hold lives, cancellation window | +| **Pool** | What can be booked on one product: **named units** (machine 1, machine 2) **or** a plain **capacity** | +| **Reservation** | One hold on one window, in one cart line. It expires unless it is confirmed | +| **Window** | `start` and `end`, absolute times. Availability is asked and holds are taken per window | +| **Confirmation** | What turns a hold into a booking that survives. It must run after the cart has its `orderId` — see below | + +Booking sits on the **product**; the price comes from the **variant**. A rental sold by the day, the +weekend and the week is therefore one bookable product with three variants. + +## The pipeline + +```text +Core createBookingPolicy the rules — every duration in SECONDS +Core setBookable(id, language) one pool per product: units[] OR capacity, never both +Core publishItem the Shop API reads published data only — publish after EVERY + setBookable, reapplyBookablePolicy or clearBookable +Shop availability / checkBooking what is free, and would this exact booking be taken +Shop bookSkuItem a hold on the cart, in state PENDING +Shop place re-checks the holds and extends them to placedHoldDuration +Shop createFromCart the order. It only snapshots the reservations +Shop confirmCartBooking once the cart has its orderId (optionally also before payment) +``` + +Everything a storefront does is on Discovery and the Shop API. **Core is for setup only** — policies, +pools and publishing are admin-time work, never called from a storefront at runtime. + +## Is this tenant bookable? + +There is no feature flag: every tenant has booking policies, and **role permissions are the gate**. Ask +for the policies to find out whether this session can manage them: + +```graphql +{ + bookingPolicies(first: 1) { + __typename + ... on BookingPolicyConnection { + totalCount + } + ... on BasicError { + errorName + message + } + } +} +``` + +A connection means yes. `FORBIDDEN` means the role lacks the `bookingPolicies` permission. That +usually happens on a custom role created before bookings existed, and it is fixed on the role, not in +the query. + +## Failure modes + +The first three produce no error at all — they are the reason this skill exists. + +| Symptom | Cause | Fix | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Admin shows "No order — not checked out" on a paid booking | `confirmCartBooking` ran before the cart had its `orderId` | Confirm **again** after `createFromCart` — see [booking-flow](references/booking-flow.md) | +| `NotBookable` on a product you just made bookable | The item is not published, or the query's `language` is wrong | Publish it; pass the language the item exists in | +| A pool or policy change has no effect in the Shop | Bookable edits are drafts; products keep the `policySnapshot` they were set with | `reapplyBookablePolicy` for a policy change, then **publish** the product | +| `FORBIDDEN` from `createBookingPolicy` | The role has no `bookingPolicies` permission | Grant it on the role | +| Holds vanish within seconds, or `InvalidRange` on every date | A duration was sent in minutes, hours or days | Every policy duration is **seconds**; read `humanized` back to check | +| `hydrate` throws "A placed cart cannot be hydrated" | The cart is placed; its contents are frozen | Change bookings before `place`, or through `/booking/admin` after | +| `ReservationConflict` on a unit that looked free | Someone took it between the availability query and the booking | Walk the other `freeUnitIds` and retry | +| `CancellationWindowClosed` when removing a basket line | The window applies to holds that were never bought | Re-hydrate the cart without that line instead | +| `BookablePoolKindChangeError` | Capacity → units while a capacity pool is published | `clearBookable`, publish, cancel or wait out open reservations, then set units | +| `BookingPolicyInUseError` on delete | Products still reference the policy (`stats.referencingProductCount`) | Move those products to another policy and publish them | + +## References + +- [references/policies-and-pools.md](references/policies-and-pools.md) — Core: policies, unit and + capacity pools, snapshots and reapplying, deleting and clearing. +- [references/booking-flow.md](references/booking-flow.md) — Shop API `/cart`: availability, holds, + checkout, the double confirmation, cancelling and moving a booking. +- [references/modelling.md](references/modelling.md) — periods as variants, units as real machines, + what Discovery serves, and how a rental relates to the product it is a rental of. + +Related: [[mutation]] for the Core API mutations themselves, [[query]] for Discovery and the Shop API +query surface, [[pricing]] for what a booked variant costs. diff --git a/use-crystallize/skills/bookable-resources/references/booking-flow.md b/use-crystallize/skills/bookable-resources/references/booking-flow.md new file mode 100644 index 0000000..175f631 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/booking-flow.md @@ -0,0 +1,215 @@ +# The booking flow (Shop API `/cart`) + +Everything the storefront does. All of it is on the `/cart` endpoint except the order itself, which is +`/order`. See [[mutation]] for tokens and endpoints. + +## 1. What is free + +```graphql +query Availability($productId: String!, $sku: String!, $range: CartTimeRangeInput!, $language: String!) { + availability(productId: $productId, sku: $sku, range: $range, granularitySec: 86400, language: $language) { + start + end + free + freeUnitIds + bookable + reason + } +} +``` + +One slot per `granularitySec` across the range: `86400` for a day view, `3600` for hours. `free` is how +many units are open, `freeUnitIds` names them, and `reason` says why a slot is closed. + +- **`language` is required and it must be a language the item exists in.** The wrong one reads as + `NotBookable` rather than as an error. +- Ask for the range the calendar shows, plus enough tail that the longest bookable period starting on the + last visible day still fits. + +`nearestAvailability(productId, sku, around, durationSec, n, language)` answers "the next `n` windows of +this length near this time" — the right query behind a "next free slot" button. + +`checkBooking(input: CartCheckBookingInput!)` is a dry run of one exact booking and returns +`{ ok, reason }`, not a union. It takes `productId`, `sku`, `start`, `end`, `language`, and optionally +`unitId` and `quantity`. Debounce it: it runs the same gates the booking itself does. + +## 2. Hold it + +```graphql +mutation Book($id: UUID, $input: CartBookingItemInput!) { + bookSkuItem(id: $id, input: $input) { + __typename + ... on Cart { + id + items { + lineId + name + meta + } + } + ... on ReservationConflict { + message + } + ... on NotBookable { + message + } + ... on InvalidRange { + message + } + ... on InvalidUnitId { + message + } + } +} +``` + +```json +{ + "input": { + "sku": "RENT-GAS55-DAY", + "quantity": 1, + "booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" }, + "meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }] + } +} +``` + +`bookSkuItem` is `addSkuItem` with a window: the line is priced from the SKU like any other line. + +**Check `__typename`.** The four refusals are results, not GraphQL errors, so a client that only looks at +`errors` treats a refused booking as a success. + +**Put the customer on the cart before booking.** The reservation records who holds it at +`bookSkuItem`/`hydrate` time, and only when the cart's customer has an identifier. Nothing sets it later. +Create the cart with `hydrate(input: { customer, items: [] })`, or `setCustomer` before the first booking. + +**On `ReservationConflict`, walk the other `freeUnitIds`.** Between the availability query and the +booking someone else may have taken that unit. Any other refusal is final — stop and tell the shopper. + +**The reservation id comes back on the line's `meta`**, not on the cart's: + +```json +"meta": { "reservationIds": "18a6b9d4-…", "booking.window": "2026-10-01T08:00:00.000Z/2026-10-01T16:00:00.000Z" } +``` + +`booking.window` is written for you. **Write the `unitId` into the line's `meta` yourself.** `CartItem` +has no booking field, and a pinned line re-hydrated without its `unitId` does not match its hold: it +is rebooked, possibly onto another unit. `reservation(cartId:, id:) { unitId }` can recover a lost one. + +`bookSkuItem` ignores `group` and `type` on a new line. To group a booking with its services, set +`group` when you next `hydrate`. + +Read one hold with `reservation(cartId:, id:)`: +`{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }`. + +## 3. Keep it while they shop + +**`hydrate` is the whole cart.** A booking line you leave out is cancelled with it, and re-hydrating with +the line slides its expiry forward. There is no "extend the hold" mutation. + +That cuts both ways, and the second half is the useful one: + +- **To keep a booking**, send every line on every hydrate, with its window and unit: + + ```json + { + "sku": "RENT-GAS55-DAY", + "quantity": 1, + "lineId": "…", + "booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" }, + "meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }] + } + ``` + + A line is matched to its hold by window and unit, so resend both unchanged. `lineId` (selectable on + `CartItem`) is the line's server-minted handle; send it back when two lines share a SKU. + +- **To remove one from the basket**, re-hydrate without it. Do **not** use `cancelReservation`: the + policy's cancellation window applies to a hold that was never bought, so a rental starting tomorrow + under a two-day window answers `CancellationWindowClosed` and the shopper cannot empty their own + basket. Verified against a live tenant. + +`cancelReservation(cartId:, reservationId:)` succeeds only while the start is at least +`cancellationWindow` seconds away. It is right for a line that is far enough out, and +`rebookReservation(cartId:, reservationId:, newBooking:)` moves a hold to a new window atomically. It +moves every reservation on that line, so a quantity-3 line moves as one. `ReservationConflict` is the +only outcome worth retrying. + +**All of this is for a draft cart.** Once the cart is placed, `hydrate` throws "A placed cart cannot be +hydrated", and `cancelReservation`/`rebookReservation` throw `InvalidStateError`. Finish every change to +the bookings before `place`. + +## 4. Place, pay, order, confirm — in that order + +```text +place(id) re-checks every hold, extends to placedHoldDuration (0 = pendingHoldDuration) +confirmCartBooking(cartId) OPTIONAL: the holds still stand, and are now committed — see below +… take payment … +createFromCart(id, input) the order (on /order). It snapshots the reservations +… wait for cart.orderId … the cart is linked in the background, ~500 ms +confirmCartBooking(cartId) REQUIRED: this is what writes orderId onto the reservations +``` + +**`confirmCartBooking` writes `orderId` onto the reservations only if the cart already has one.** The +order id is the cart id, but `createFromCart` returns before it links the cart: it saves the order +and stamps `orderId` on the cart in the background. The cart therefore gets its `orderId` a few +hundred milliseconds after `createFromCart` returns, so a single confirm +before the order leaves every reservation `{ state: CONFIRMED, orderId: null }`, and the admin shows +"No order — not checked out" for a booking that was paid for. + +Poll the cart, then confirm again. It accepts an ordered cart and rows that are already confirmed: + +```ts +async function linkReservations(cartId: string) { + for (let i = 0; i < 20; i++) { + const { cart } = await shop(`query($id: UUID!) { cart(id: $id) { orderId } }`, { id: cartId }); + if (cart?.orderId) { + await shop(`mutation($id: UUID!) { confirmCartBooking(cartId: $id) { __typename } }`, { id: cartId }); + return; + } + await new Promise((r) => setTimeout(r, 250)); + } +} +``` + +**The first confirm commits the slot before the money moves.** A `CONFIRMED` reservation never expires: +it blocks the calendar until its window ends. It is what proves the holds still stand before the +shopper is charged. If you keep it, cancel the reservations through `/booking/admin` when the payment +fails, or the machine stays blocked with no order behind it. If you drop it, `place` is still the +last check before payment, and the holds live for `placedHoldDuration` while payment runs. +`confirmCartBooking` answers `Cart`, `NotPlaced`, `NothingToConfirm` or `ReservationNoLongerHeld`. The +last one means a hold expired while payment was in flight; select its `missing` field for the ids. It +is all or nothing: the surviving holds stay `PENDING`, and the shopper has to pick another window for +the lost one. + +When payment is confirmed server-side by a gateway webhook, run the confirmation there rather than in the +browser round trip. + +**Do not call `fulfill`.** `createFromCart` already moves the cart to `ordered`, and the order id is the +cart id. + +## After the order + +- A reservation on a placed or ordered cart **cannot be cancelled through the cart**: "A reservation + cannot be cancelled through a cart that is no longer editable." Cancel it on the Shop API's + `/booking/admin` endpoint, which needs a token with both the `booking` and `booking:admin` scopes and + ignores the cancellation window. Run it server-side only: + + ```graphql + mutation { + cancel(id: "18a6b9d4-…", reason: "payment failed") { + id + state + } + } + ``` + + `bulkCancel(ids:, reason:)` takes up to 100 ids and answers per id; it is not atomic. + +- **A hold lost after `place` cannot be re-picked on that cart.** A placed cart cannot be hydrated or + rebooked. On `ReservationNoLongerHeld`, cancel the survivors (above), refund if the money has + moved, and start a new cart for the new window. +- A reservation's `state` is a string: `PENDING`, `CONFIRMED`, `COMPLETED`, `CANCELLED` or `EXPIRED`. A + hold that runs out becomes `EXPIRED`, up to a minute late, while its line stays in the cart. The next + `hydrate` takes it again if the window is still free and throws `BookingNoLongerAvailable` if not; + `place` refuses it with `HoldNoLongerHeld`. diff --git a/use-crystallize/skills/bookable-resources/references/modelling.md b/use-crystallize/skills/bookable-resources/references/modelling.md new file mode 100644 index 0000000..5403663 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/modelling.md @@ -0,0 +1,110 @@ +# Modelling a bookable catalogue + +## Booking is on the product, price is on the variant + +`setBookable` takes an item id, so the calendar belongs to the product. The money belongs to the +variant. That one split decides the model: **sell time as variants.** + +```text +Product Bosch GAS 55 M dust extractor — rental bookable: 5 units, policy "heavy-equipment" + ├─ RENT-GAS55-DAY 1 day attributes: { period: day } + ├─ RENT-GAS55-WEEKEND weekend attributes: { period: weekend } + ├─ RENT-GAS55-WEEK 1 week attributes: { period: week } + └─ RENT-GAS55-4WEEKS 4 weeks attributes: { period: 4-weeks } +``` + +The shopper picks a period and a start; the storefront turns that into `start`/`end` and books the +matching SKU. Keep the length of each period on the variant — a numeric `duration-hours`, or an +attribute — so the window is computed from data rather than from a hardcoded table. A weekend is rarely +48 hours: Friday 12:00 to Monday 08:00 is 68. + +Price tiers per period, currency per market and VAT all work as they do for any other variant — see +[[pricing]]. + +## Units are the real things + +A unit id should be the thing in the world: an asset tag, a room number, a registration number. Put +everything that distinguishes it in `meta`: + +```json +{ + "id": "GAS55-OSL-1", + "meta": [ + { "key": "depot", "value": "osl" }, + { "key": "serial", "value": "TU316652" }, + { "key": "hours", "value": "257" }, + { "key": "lastService", "value": "2026-06-07" } + ] +} +``` + +That is what lets a storefront filter by location ("available in Oslo this weekend") without another +data source: read the pool from Discovery, group the `freeUnitIds` from `availability` by their depot +meta, and show one line per location. + +Use a **capacity** pool instead when the units are interchangeable and nobody needs to know which one +they got — seats on a course, bikes in a rack. You lose per-unit meta, so if the storefront must say +_which_ one, use units. + +## What Discovery serves, and what it does not + +Discovery carries the **static** bookable configuration on the item: + +```graphql +{ + browse { + rental(language: en, pagination: { limit: 24 }) { + hits { + itemId + name + path + bookable { + poolSize + pool { + __typename + ... on BookableUnitPool { + units { + id + meta { + key + value + } + } + } + ... on BookableCapacityPool { + capacity + } + } + } + variants { + sku + attributes + defaultPrice + } + } + } + } +} +``` + +**Discovery never serves availability.** Listing pages can say "5 machines, 3 depots" from `poolSize` and +the pool; anything about a date is a Shop API call. Design the page so the calendar loads after the +product, not as part of the listing query — one `availability` call per visible card is a lot of calls. + +## Rentals next to the thing being rented + +A rental item and the product it is a rental of are two catalogue items. Relate them both ways: an item +relation from the rental to the tool, and one back from the tool to its rental. The tool's page can then +offer "rent this instead", and the rental page can show the tool's specifications without duplicating +them. See [[content-model]] for the relation component and [[information-architecture]] for where the +rental folder sits. + +Mark the rental folder with an `externalReference` (`folder:/rental`) so the storefront can recognise a +rental listing without matching on the path in four languages. + +## Services around a booking + +Delivery, damage waiver, cleaning, an operator: sell them as ordinary products and add them to the cart +as normal lines, or as `type: service` lines. They are not bookable themselves; they follow the booking +they belong to. Give them and the booking line the same `group` so the basket can show them together. +Set `group` and `type` through `hydrate`: `bookSkuItem` ignores both on a new line. diff --git a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md new file mode 100644 index 0000000..10731c8 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md @@ -0,0 +1,167 @@ +# Booking policies and pools (Core API) + +Setup for bookable products. Admin-time work: a storefront never calls these. + +## The policy + +A policy is a named set of rules shared by many products. **Every duration is a number of seconds.** + +```graphql +mutation CreatePolicy($input: CreateBookingPolicyInput!) { + createBookingPolicy(input: $input) { + __typename + ... on BookingPolicy { + id + name + version + humanized { + advanceWindow { + value + unit + } + cancellationWindow { + value + unit + } + } + } + ... on BasicError { + errorName + message + } + } +} +``` + +```json +{ + "input": { + "name": "heavy-equipment", + "advanceWindow": 10368000, + "bufferBefore": 0, + "bufferAfter": 14400, + "cancellationWindow": 172800, + "pendingHoldDuration": 900, + "placedHoldDuration": 86400 + } +} +``` + +| Field | Required | Meaning | +| --------------------- | -------- | ------------------------------------------------------------------------------- | +| `name` | yes | Unique per tenant — `BookingPolicyNameTakenError` otherwise | +| `advanceWindow` | yes | How far into the future a booking may be made (120 days = `10368000`) | +| `bufferBefore` | yes | Dead time reserved before each booking | +| `bufferAfter` | yes | Dead time after — cleaning, charging, travel (4 hours = `14400`) | +| `cancellationWindow` | yes | How close to the start a booking may still be cancelled (2 days = `172800`) | +| `pendingHoldDuration` | yes | How long a hold in a live cart survives (15 minutes = `900`) | +| `placedHoldDuration` | no | How long a hold survives after `place`, while payment happens (1 day = `86400`) | + +A `placedHoldDuration` of `0`, or none, gives a placed cart a fresh `pendingHoldDuration`. Set it +when payment takes longer than a live hold, such as an invoice or a bank transfer. + +**Read `humanized` back after writing.** It returns the same values as `{ value, unit }` in days, hours, +minutes or seconds, which is the cheapest way to catch a duration that was sent in the wrong unit. The +mistake is silent otherwise: a `cancellationWindow` of `2` is two seconds, not two days. + +`updateBookingPolicy(id, input)` takes the same fields, all optional, and bumps `version`. + +`bookingPolicies(first:)` returns a **connection** (`edges { node { … } }`), not a list. +`bookingPolicy(id:)` reads one. `BookingPolicy.stats.referencingProductCount` says how many products use +it, and `deleteBookingPolicy` refuses with `BookingPolicyInUseError` while that count is above zero. + +## The pool + +`setBookable` attaches a policy and says what can be booked. One product, one pool. `language` is +required, but the pool is not per language: every language of the product shares it, so set it and +reapply it once per product. + +```graphql +mutation SetBookable($id: String!, $language: String!, $input: GraphqlBookableInputInput!) { + setBookable(id: $id, language: $language, input: $input) { + __typename + ... on BasicError { + errorName + message + } + } +} +``` + +Two kinds, and a product has exactly one of them: + +```json +{ "input": { "policyId": "6ab3…", "units": [{ "id": "GAS55-OSL-1", "meta": [{ "key": "depot", "value": "osl" }] }] } } +{ "input": { "policyId": "6ab3…", "capacity": 8 } } +``` + +| Pool | Use it for | What the shopper books | +| ---------- | --------------------------------------------------------------- | --------------------------- | +| `units` | Real, distinguishable things: machine 1, room A, instructor Ada | One named unit, by `unitId` | +| `capacity` | Interchangeable seats: 8 places on a course, 20 bikes in a pile | One of N, no identity | + +**A unit's `meta` is where its identity lives.** Depot, serial number, running hours, last service — the +storefront reads it back from Discovery and can show "the machine in Oslo". There is no other place to +put it. + +Units → capacity is allowed. Capacity → units answers `BookablePoolKindChangeError` while the +**published** pool is a capacity pool, because capacity-era reservations carry no unit. Clear it with +`clearBookable`, publish that, cancel or wait out the open reservations, then set the units. + +`clearBookable(id, language)` removes the pool, but only from the draft: the Shop keeps taking bookings +until the product is published again. Unpublish to stop bookings at once. `bookableProducts` lists +products with a **published** bookable configuration only. A product configured but never published +is not in it. + +## The snapshot + +`setBookable` stamps the policy onto the product as a `policySnapshot` with its `version`: + +```graphql +{ + item(id: "6ab3…", language: "en") { + ... on Product { + bookable { + policyId + poolSize + policySnapshot { + version + cancellationWindow + } + pool { + __typename + ... on BookableUnitPool { + units { + id + meta { + key + value + } + } + } + ... on BookableCapacityPool { + capacity + } + } + } + } + } +} +``` + +**Editing a policy does not reach the products that use it.** They keep their snapshot until +`reapplyBookablePolicy(id, language)` runs on each one. It re-freezes the policy's current terms and +leaves the pool untouched, so do not re-run `setBookable` for this. Change the window, reapply, check +the product's `policySnapshot`, then **publish**. Until the publish, the Shop still books under the old +terms. This is the step that makes "we changed the cancellation window and nothing happened" go away. + +Reservations already taken keep the terms they were admitted under. A policy edit never changes a +booking a shopper already holds. + +## Publish + +The Shop reads the **published** bookable configuration only. A bookable product that is not +published answers `NotBookable` on every Shop API call, with no hint that publishing is what is +missing. `setBookable`, `reapplyBookablePolicy` and `clearBookable` all write to the draft, so each one +needs a publish before the Shop sees it. Publish per item and language with `publishItem`. See +[[mutation]].