From 99fed946edd2be3548df7415d182a68d7702ea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A5rd=20Farstad?= Date: Thu, 24 Sep 2026 12:22:11 +0200 Subject: [PATCH 1/2] feat(skills): add bookable-resources skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bookable resources — products held for a window of time instead of counted in stock — had no coverage in any skill. Two storefront builds (Tools Universe rentals, and the rental work in Car Parts Universe) had to discover the whole surface from the schema, and three of its failure modes are silent. New skill `bookable-resources`, with references for the Core setup, the Shop API flow and modelling. Verified on 2026-09-24 against the live Core API and Shop API on the tools-universe tenant, including two test reservations taken and released. Confirmed directly: - Policy durations are seconds. `heavy-equipment` reads back as advanceWindow 10368000 = 120 days, cancellationWindow 172800 = 2 days, pendingHoldDuration 900 = 15 minutes. `humanized` gives the same values as { value, unit }, which is how you catch a wrong unit. - `bookingPolicies` is a connection, and `stats.referencingProductCount` is what blocks a delete. - A pool is units or capacity, never both; unit `meta` carries depot, serial and service data, and Discovery serves it back on the item. - `checkBooking` requires `language` and returns { ok, reason }, not a union. `bookSkuItem` returns Cart | ReservationConflict | NotBookable | InvalidRange | InvalidUnitId — refusals are results, not errors. - The reservation id is on the cart LINE's meta (`reservationIds`), alongside a `booking.window` the API writes itself. The line's unitId is not returned, so the caller has to store it. - `cancelReservation(cartId, reservationId)` applies the cancellation window to a hold that was never bought: a booking for tomorrow under a two-day window answers CancellationWindowClosed, so a shopper cannot empty their own basket. Re-hydrating without the line releases it, and the reservation then reads null. - `confirmCartBooking` answers Cart | NotPlaced | NothingToConfirm | ReservationNoLongerHeld. The one thing a storefront cannot get right by reading the schema is the double confirmation: `confirmCartBooking` writes `orderId` onto the reservations only if the cart already has one, and the cart is linked in the background a few hundred ms after `createFromCart` returns. A single confirm leaves { state: CONFIRMED, orderId: null } and the admin shows "No order — not checked out" for a booking that was paid for. The skill documents polling the cart and confirming again, as Tools Universe does. Version to 3.7.0 in the three manifests. mcp-servers/crystallize was left on 3.5.0 by #3 and is brought back into lockstep. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- use-crystallize/.claude-plugin/plugin.json | 2 +- .../mcp-servers/crystallize/package.json | 2 +- .../skills/bookable-resources/SKILL.md | 107 ++++++++++++ .../references/booking-flow.md | 163 ++++++++++++++++++ .../references/modelling.md | 109 ++++++++++++ .../references/policies-and-pools.md | 149 ++++++++++++++++ 8 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 use-crystallize/skills/bookable-resources/SKILL.md create mode 100644 use-crystallize/skills/bookable-resources/references/booking-flow.md create mode 100644 use-crystallize/skills/bookable-resources/references/modelling.md create mode 100644 use-crystallize/skills/bookable-resources/references/policies-and-pools.md 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..8eda171 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/SKILL.md @@ -0,0 +1,107 @@ +--- +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 is two calls, not one — 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 until it is published the Shop API answers NotBookable +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 twice: before the order, and again once the cart has its orderId +``` + +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? + +Ask for the policies. A tenant without the capability answers with an error rather than an empty list, +so match on the type, not on the message: + +```graphql +{ + bookingPolicies(first: 1) { + __typename + ... on BookingPolicyConnection { + totalCount + } + ... on BasicError { + errorName + } + } +} +``` + +`ExperimentalFeaturesNotAvailableError` means the tenant is not served for bookings. + +## 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 policy change has no effect on products | Products hold a `policySnapshot` taken when the pool was set | `reapplyBookablePolicy` | +| Every booking answers `NotBookable` on every product | The tenant is not served for bookings | Probe `bookingPolicies` first | +| Windows land a day off, or holds never expire | A duration was sent in minutes, hours or days | Every policy duration is **seconds**; read `humanized` back to check | +| `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` | The product already has the other kind of pool | `clearBookable` first, then set the new pool | +| `BookingPolicyInUseError` on delete | Products still reference the policy (`stats.referencingProductCount`) | Move those products to another policy first | + +## 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..1049118 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/booking-flow.md @@ -0,0 +1,163 @@ +# 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 { + 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** — the cart +does not return a line's unit, and you need it to re-hydrate the line without losing the hold. + +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, including its `unitId` meta. +- **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:)` is right when the shopper is outside the window, and +`rebookReservation(cartId:, reservationId:, newBooking:)` moves a hold to a new window atomically — +`ReservationConflict` is the only outcome worth retrying. + +## 4. Place, order, confirm — in that order, and confirm twice + +```text +place(id) re-checks every hold, extends to placedHoldDuration +confirmCartBooking(cartId) the holds still stand; safe to 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) again — this is what writes orderId onto the reservations +``` + +**`confirmCartBooking` writes `orderId` onto the reservations only if the cart already has one.** The +cart 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)); + } +} +``` + +Keep the first confirm as well: it is what proves the holds still stand before the shopper is charged. +`confirmCartBooking` answers `Cart`, `NotPlaced`, `NothingToConfirm` or `ReservationNoLongerHeld` — the +last one means a hold expired while payment was in flight, and the shopper has to pick another window. + +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 confirmed reservation on an ordered cart **cannot be cancelled through the cart**: "A reservation + cannot be cancelled through a cart that is no longer editable." The Shop API's `/booking/admin` + endpoint is where a sold booking is administered. +- A reservation's `state` is a string. The ones a storefront meets are `PENDING` before confirmation and + `CONFIRMED` after it; a hold that was never confirmed disappears when it expires. 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..16937f5 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/modelling.md @@ -0,0 +1,109 @@ +# 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. Group them with the booking line's `group` so the basket can show them together. 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..42cfb56 --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md @@ -0,0 +1,149 @@ +# 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`) | + +**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 language, one pool. + +```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. + +Switching a product from one kind to the other answers `BookablePoolKindChangeError`. `clearBookable` +removes the pool, `bookableProducts` lists every bookable product in the tenant. + +## 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` runs. Change the window, reapply, then check a product's +`policySnapshot.version` — this is the step that makes "we changed the cancellation window and nothing +happened" go away. + +## Publish + +A bookable product that is not published answers `NotBookable` on every Shop API call, with no hint that +publishing is what is missing. Publish per item and language with `publishItem` — see [[mutation]]. From e0db6523a02493551c04054ac2852a654cdd594f Mon Sep 17 00:00:00 2001 From: Vasil Dimitrov Date: Thu, 24 Sep 2026 13:58:04 +0300 Subject: [PATCH 2/2] fix(skills): align bookable-resources with the backend - No feature flag gates bookings; RBAC does. Replace the ExperimentalFeaturesNotAvailableError probe with the FORBIDDEN case. - setBookable, reapplyBookablePolicy and clearBookable write the draft; each needs a publish before the Shop sees it. - BookablePoolKindChangeError only fires capacity -> units while a capacity pool is published; document clear, publish, drain, set. - bookableProducts lists published configurations only; the pool is shared by every language of the product. - The first confirmCartBooking commits the slot (CONFIRMED never expires); mark it optional, place payment in the sequence, and show the /booking/admin cancel for a failed payment. - Expired holds become EXPIRED and are re-taken on hydrate; placed carts refuse hydrate, cancel and rebook. - Add the hydrate shape for a booking line (lineId, window, unit), the exact cancellation rule, why orderId must be polled, and that bookSkuItem ignores group and type. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../skills/bookable-resources/SKILL.md | 39 ++++---- .../references/booking-flow.md | 90 +++++++++++++++---- .../references/modelling.md | 5 +- .../references/policies-and-pools.md | 34 +++++-- 4 files changed, 122 insertions(+), 46 deletions(-) diff --git a/use-crystallize/skills/bookable-resources/SKILL.md b/use-crystallize/skills/bookable-resources/SKILL.md index 8eda171..244f2c3 100644 --- a/use-crystallize/skills/bookable-resources/SKILL.md +++ b/use-crystallize/skills/bookable-resources/SKILL.md @@ -36,7 +36,7 @@ be booked, and the Shop API holds them for a shopper while they shop, then hands | **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 is two calls, not one — see below | +| **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. @@ -46,12 +46,13 @@ weekend and the week is therefore one bookable product with three variants. ```text Core createBookingPolicy the rules — every duration in SECONDS Core setBookable(id, language) one pool per product: units[] OR capacity, never both -Core publishItem until it is published the Shop API answers NotBookable +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 twice: before the order, and again once the cart has its orderId +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, @@ -59,8 +60,8 @@ pools and publishing are admin-time work, never called from a storefront at runt ## Is this tenant bookable? -Ask for the policies. A tenant without the capability answers with an error rather than an empty list, -so match on the type, not on the message: +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 { @@ -71,28 +72,32 @@ so match on the type, not on the message: } ... on BasicError { errorName + message } } } ``` -`ExperimentalFeaturesNotAvailableError` means the tenant is not served for bookings. +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 policy change has no effect on products | Products hold a `policySnapshot` taken when the pool was set | `reapplyBookablePolicy` | -| Every booking answers `NotBookable` on every product | The tenant is not served for bookings | Probe `bookingPolicies` first | -| Windows land a day off, or holds never expire | A duration was sent in minutes, hours or days | Every policy duration is **seconds**; read `humanized` back to check | -| `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` | The product already has the other kind of pool | `clearBookable` first, then set the new pool | -| `BookingPolicyInUseError` on delete | Products still reference the policy (`stats.referencingProductCount`) | Move those products to another policy first | +| 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 diff --git a/use-crystallize/skills/bookable-resources/references/booking-flow.md b/use-crystallize/skills/bookable-resources/references/booking-flow.md index 1049118..175f631 100644 --- a/use-crystallize/skills/bookable-resources/references/booking-flow.md +++ b/use-crystallize/skills/bookable-resources/references/booking-flow.md @@ -42,6 +42,7 @@ mutation Book($id: UUID, $input: CartBookingItemInput!) { ... on Cart { id items { + lineId name meta } @@ -91,8 +92,12 @@ booking someone else may have taken that unit. Any other refusal is final — st "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** — the cart -does not return a line's unit, and you need it to re-hydrate the line without losing the hold. +`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 }`. @@ -104,28 +109,51 @@ 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, including its `unitId` meta. +- **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:)` is right when the shopper is outside the window, and -`rebookReservation(cartId:, reservationId:, newBooking:)` moves a hold to a new window atomically — -`ReservationConflict` is the only outcome worth retrying. +`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, order, confirm — in that order, and confirm twice +## 4. Place, pay, order, confirm — in that order ```text -place(id) re-checks every hold, extends to placedHoldDuration -confirmCartBooking(cartId) the holds still stand; safe to take payment +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) again — this is what writes orderId onto the reservations +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 -cart gets its `orderId` a few hundred milliseconds after `createFromCart` returns, so a single confirm +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. @@ -144,9 +172,15 @@ async function linkReservations(cartId: string) { } ``` -Keep the first confirm as well: it is what proves the holds still stand before the shopper is charged. -`confirmCartBooking` answers `Cart`, `NotPlaced`, `NothingToConfirm` or `ReservationNoLongerHeld` — the -last one means a hold expired while payment was in flight, and the shopper has to pick another window. +**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. @@ -156,8 +190,26 @@ cart id. ## After the order -- A confirmed reservation on an ordered cart **cannot be cancelled through the cart**: "A reservation - cannot be cancelled through a cart that is no longer editable." The Shop API's `/booking/admin` - endpoint is where a sold booking is administered. -- A reservation's `state` is a string. The ones a storefront meets are `PENDING` before confirmation and - `CONFIRMED` after it; a hold that was never confirmed disappears when it expires. +- 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 index 16937f5..5403663 100644 --- a/use-crystallize/skills/bookable-resources/references/modelling.md +++ b/use-crystallize/skills/bookable-resources/references/modelling.md @@ -105,5 +105,6 @@ 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. Group them with the booking line's `group` so the basket can show them together. +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 index 42cfb56..10731c8 100644 --- a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md +++ b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md @@ -57,6 +57,9 @@ mutation CreatePolicy($input: CreateBookingPolicyInput!) { | `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. @@ -69,7 +72,9 @@ it, and `deleteBookingPolicy` refuses with `BookingPolicyInUseError` while that ## The pool -`setBookable` attaches a policy and says what can be booked. One product, one language, one 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!) { @@ -99,8 +104,14 @@ Two kinds, and a product has exactly one of them: storefront reads it back from Discovery and can show "the machine in Oslo". There is no other place to put it. -Switching a product from one kind to the other answers `BookablePoolKindChangeError`. `clearBookable` -removes the pool, `bookableProducts` lists every bookable product in the tenant. +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 @@ -139,11 +150,18 @@ removes the pool, `bookableProducts` lists every bookable product in the tenant. ``` **Editing a policy does not reach the products that use it.** They keep their snapshot until -`reapplyBookablePolicy` runs. Change the window, reapply, then check a product's -`policySnapshot.version` — this is the step that makes "we changed the cancellation window and nothing -happened" go away. +`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 -A bookable product that is not published answers `NotBookable` on every Shop API call, with no hint that -publishing is what is missing. Publish per item and language with `publishItem` — see [[mutation]]. +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]].