Skip to content

Commit f539cbf

Browse files
oskarbrueningclaude
andcommitted
feat: add pricing engine + overrides resource
Wrap the Peek pricing primitives behind a new PricingService: create/ update/delete pricing engines and upsert/clear pricing overrides. The service is a thin, faithful pass-through — callers build the clean UpsertOverridesInput (segmentation, override order, and spotsTaken bounds stay the caller's job) and the service validates and sends it, mapping the typed-error unions to thrown Errors. deleteEngine is idempotent. Also expose each activity's currency on the clean Product model (empty for add-ons) so consumers can build fixed-price overrides. Docs: add the consumer guide docs/external/pricing-api.md, document the resource in ARCHITECTURE.md, and update llms.txt (pricing surface, the Product currency field, and the previously-missing CNG/ACME accessors). CLAUDE.md now instructs keeping the external guides in sync. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca5ced0 commit f539cbf

18 files changed

Lines changed: 1998 additions & 5 deletions

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ out, and the clean data models — never raw GraphQL.
1212
- Before making any changes, review `docs/internal/ARCHITECTURE.md`.
1313
- Once you've made all the code changes, update `docs/internal/ARCHITECTURE.md` to reflect
1414
major changes (new resources, new triads, changed public surface).
15+
- **Keep the external guides in sync.** If you change the pricing engine/override
16+
surface (`PricingService`, the `src/models/peek/pricing.ts` models, or the
17+
products `currency` field), update `docs/external/pricing-api.md` — it documents
18+
that API for consumers and must not drift. Likewise `docs/webhooks.md` for the
19+
webhook surface and `llms.txt` for any change to the public entry points.
1520
- Ensure test coverage remains above 95% (the Vitest gate enforces this on
1621
lines/functions/branches/statements).
1722
- Unless told otherwise, after everything is done, run the linter and fix any
@@ -97,3 +102,5 @@ install-script spawn — use `npm install --ignore-scripts`. If the
97102
- Review the new code for obvious duplication; simplify with helper functions.
98103
- Run the linter, the type checker, and the unit tests (with coverage).
99104
- Update `docs/internal/ARCHITECTURE.md` if the public surface, resources, or build changed.
105+
- Update `docs/external/pricing-api.md` if the pricing surface changed, and
106+
`llms.txt` if the public entry points changed.

docs/external/pricing-api.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Pricing overrides & engines
2+
3+
A guide for adjusting ticket prices in Peek Pro from your own app using
4+
`@peektravel/app-utilities`. It assumes you have **never seen this API before**
5+
and walks you from zero to a working price override, then covers how to manage
6+
overrides afterward.
7+
8+
Everything here goes through `PeekAccessService.getPricingService()`.
9+
10+
---
11+
12+
## 1. The mental model (read this first)
13+
14+
Peek Pro applies price adjustments at checkout through a **pricing engine**. You
15+
don't edit product prices directly — instead you:
16+
17+
1. Create a **pricing engine** once. It's just a named container. Peek gives you
18+
back an **engine id** — save it; you reuse it forever.
19+
2. Push **overrides** onto that engine for a **date range** and an **activity**.
20+
An override says "for this activity, on these dates, adjust these tickets'
21+
prices — optionally only when N spots are already taken and/or within a
22+
start-time window."
23+
24+
```
25+
Pricing engine (created once, identified by engineId)
26+
└── Overrides, upserted per (date range × activity)
27+
└── one or more override entries, each:
28+
├── resourceOptions[] ← which tickets, and the fixed/percentage adjustment
29+
└── filters[] ← optional: spotsTaken and/or startTimeRange gates
30+
```
31+
32+
Three things are **your job**, not the API's — this wrapper is deliberately a
33+
thin, faithful pass-through (it does **not** know about your templates,
34+
calendars, or business rules):
35+
36+
- **Deciding what the overrides are** — which tickets, what price, what dates.
37+
- **Ordering** the override entries (`order` field — see §5).
38+
- **Computing `spotsTaken` bounds** from ticket counts (the off-by-one — see §6).
39+
40+
The service validates your input and faithfully sends it. That's the deal.
41+
42+
---
43+
44+
## 2. What you need before you start
45+
46+
| You need | How to get it |
47+
| --- | --- |
48+
| A configured `PeekAccessService` | See the package README / `llms.txt` "Entry point". |
49+
| The **activity id** you want to adjust | `getProductService().getAllActivities()``product.productId` |
50+
| The **ticket (resourceOption) ids** under it | the same product's `tickets[].id` |
51+
| The activity's **currency** (for fixed prices) | the same product's `currency` (e.g. `"USD"`) |
52+
53+
```ts
54+
import { PeekAccessService } from "@peektravel/app-utilities";
55+
56+
const peek = new PeekAccessService({ /* installId, jwtSecret, issuer, appId, gatewayKey */ });
57+
58+
const activities = await peek.getProductService().getAllActivities();
59+
const activity = activities[0]!;
60+
// activity.productId → the activityId
61+
// activity.tickets → [{ id, name }, ...] (the resourceOption ids)
62+
// activity.currency → "USD" (use this for fixed-price overrides)
63+
```
64+
65+
> **Money and percentages are strings, not numbers.** `"80.00"`, `"-25"` — never
66+
> `80` or `-25`. The API rejects floats, and strings avoid precision loss.
67+
68+
---
69+
70+
## 3. Order of operations (the whole lifecycle)
71+
72+
```
73+
① create engine ──> save engineId
74+
75+
76+
② build UpsertOverridesInput (you decide the overrides)
77+
78+
79+
③ upsertOverrides(input) ──> Peek stores them, echoes the resolved state
80+
81+
82+
… time passes; guests book; you change your mind …
83+
84+
├──> ③ upsertOverrides(...) re-send to CHANGE overrides (full replace per date+activity)
85+
├──> ④ clearOverrides(...) remove overrides for a date range
86+
├──> ⑤ updateEngine(...) rename or re-scope the engine
87+
└──> ⑥ deleteEngine(id) tear the whole engine down (idempotent)
88+
```
89+
90+
You create the engine **once** (step ①) and then live in steps ③–⑥.
91+
92+
---
93+
94+
## 4. Create your first override (end to end)
95+
96+
### Step 1 — Create the engine (once)
97+
98+
```ts
99+
const pricing = peek.getPricingService();
100+
101+
const { id: engineId } = await pricing.createEngine({
102+
name: "Summer promo schedule",
103+
// optional: activityIds: ["act-xyz"] ← scope the engine to specific activities
104+
});
105+
// → Persist engineId in your own storage. You'll reuse it for every upsert.
106+
```
107+
108+
Do this **lazily** — only when you're about to push your first override — and
109+
store the returned `engineId` against whatever your app calls a "schedule". If
110+
you omit `activityIds`, the engine can carry overrides for any activity.
111+
112+
### Step 2 — Build the override input
113+
114+
Here's a complete, realistic example: activity `act-xyz`, on July 4th, with a
115+
tiered discount on the adult ticket — **$100 normally, but $80 once 4 spots are
116+
taken** — only for the 9–11am window.
117+
118+
```ts
119+
import type { UpsertOverridesInput } from "@peektravel/app-utilities";
120+
121+
const input: UpsertOverridesInput = {
122+
engineId,
123+
dateRange: "[2025-07-04,2025-07-04]", // single day: both ends equal (see §7)
124+
activities: [
125+
{
126+
activityId: "act-xyz",
127+
overrides: [
128+
{
129+
order: 0, // evaluated first — the higher-spots, deeper-discount tier
130+
resourceOptions: [
131+
{ id: "ticket-adult", mode: "fixed", price: { amount: "80.00", currency: "USD" } },
132+
],
133+
filters: [
134+
{ spotsTaken: { minSpots: 4 } }, // "once 4 spots are taken" (§6)
135+
{ startTimeRange: "[09:00:00,11:00:00]" }, // only the morning window
136+
],
137+
},
138+
{
139+
order: 1, // the base tier
140+
resourceOptions: [
141+
{ id: "ticket-adult", mode: "fixed", price: { amount: "100.00", currency: "USD" } },
142+
],
143+
filters: [
144+
{ startTimeRange: "[09:00:00,11:00:00]" },
145+
],
146+
},
147+
],
148+
},
149+
],
150+
};
151+
```
152+
153+
Prefer a **percentage** adjustment instead of a fixed price? Swap the
154+
`resourceOptions` entry:
155+
156+
```ts
157+
{ id: "ticket-adult", mode: "percentage", percentageAdjustment: "-25" } // 25% off; must be > -100
158+
```
159+
160+
### Step 3 — Send it
161+
162+
```ts
163+
const result = await pricing.upsertOverrides(input);
164+
// result.activityContexts → the fully resolved state Peek stored (good to log/persist as an audit trail)
165+
```
166+
167+
That's it — the override is live. `upsertOverrides` returns the resolved
168+
`activityContexts` echoed by Peek (dates, activity, engine, and every override
169+
with its resolved prices and filters). Store it if you want an audit record of
170+
exactly what Peek accepted.
171+
172+
---
173+
174+
## 5. `order` — how override entries are ranked
175+
176+
Within one activity you often have **tiers** (base price, then a deeper discount
177+
once the group is larger). Peek evaluates entries by their `order` integer,
178+
**lowest first**. So the most specific / deepest-discount tier gets the lowest
179+
number.
180+
181+
Convention (and what the sibling `pricing-schedules` app does): sort your entries
182+
**descending by `minSpots`**, then assign `order = 0, 1, 2, …`. The "5+ tickets"
183+
tier (`minSpots: 4`) sorts before the "1+ tickets" base tier, so it gets
184+
`order: 0`.
185+
186+
You assign `order` yourself — the service does not reorder for you.
187+
188+
---
189+
190+
## 6. `spotsTaken` — mind the off-by-one
191+
192+
`spotsTaken` filters an override by **how many spots are already taken**, which
193+
is **zero-indexed** — it is *not* the ticket-count. Convert your ticket-count
194+
ranges like this:
195+
196+
| You mean | `spotsTaken` |
197+
| --- | --- |
198+
| Any group size (1+) | *omit the filter entirely* |
199+
| 1–4 tickets | `{ maxSpots: 4 }` |
200+
| 5–9 tickets | `{ minSpots: 4, maxSpots: 9 }` |
201+
| 10+ tickets | `{ minSpots: 9 }` |
202+
203+
Rule: for a half-open ticket range `[L, U)``minSpots = L - 1` (clamped to 0,
204+
omit when 0), `maxSpots = U - 1` (omit when unbounded). "Applies from the 5th
205+
ticket onward" is `minSpots: 4`.
206+
207+
The **`startTimeRange`** filter is simpler — a Postgres-style inclusive range
208+
string `"[HH:MM:SS,HH:MM:SS]"`. Omit it for all-day overrides. An entry can carry
209+
zero, one, or both filters.
210+
211+
---
212+
213+
## 7. `dateRange` format
214+
215+
Both `upsertOverrides` and `clearOverrides` take `dateRange` as a **PostgreSQL
216+
inclusive range string**:
217+
218+
- Single day: `"[2025-07-04,2025-07-04]"` — both ends the same.
219+
- A span: `"[2025-07-04,2025-07-06]"` — applies to the 4th, 5th, and 6th.
220+
221+
---
222+
223+
## 8. Managing overrides afterward
224+
225+
### Change an override → just upsert again
226+
227+
`upsertOverrides` is a **full replace for that `(dateRange, activity)`**. To
228+
change prices, rebuild the input and send it again — you don't diff or patch.
229+
Whatever you send becomes the complete set of overrides for those dates.
230+
231+
### Remove overrides → `clearOverrides` (never "send nothing")
232+
233+
To take a date back to normal pricing, send `clearOverrides`. Under the hood this
234+
is an upsert with **empty** overrides for each activity — which is the **only**
235+
correct way to clear:
236+
237+
```ts
238+
await pricing.clearOverrides({
239+
engineId,
240+
dateRange: "[2025-07-04,2025-07-04]",
241+
activityIds: ["act-xyz"], // every activity you previously wrote on these dates
242+
});
243+
```
244+
245+
> ⚠️ **You must name the activities to clear.** Clearing works by sending
246+
> `overrides: []` *for each activity id*. Sending an empty `activities` array
247+
> clears **nothing**. Recover the activity ids from whatever you upserted before
248+
> (e.g. the `activityContexts` you persisted, or your own records of what you
249+
> pushed onto those dates).
250+
251+
### Rename or re-scope the engine → `updateEngine`
252+
253+
```ts
254+
await pricing.updateEngine({
255+
engineId,
256+
name: "Renamed schedule",
257+
activityIds: ["act-xyz", "act-abc"], // set the engine's activity scope…
258+
// …or omit / pass [] to CLEAR the scope (engine applies to all activities)
259+
});
260+
```
261+
262+
### Tear it all down → `deleteEngine`
263+
264+
```ts
265+
await pricing.deleteEngine(engineId); // idempotent
266+
```
267+
268+
`deleteEngine` is **idempotent**: if the engine is already gone it still
269+
resolves, so it's safe to call more than once. After deleting, discard the stored
270+
`engineId` and create a fresh engine next time you need one.
271+
272+
---
273+
274+
## 9. Method reference
275+
276+
| Method | Does | Returns |
277+
| --- | --- | --- |
278+
| `createEngine({ name, activityIds? })` | Create an engine (once). | `{ id }` — store it |
279+
| `updateEngine({ engineId, name, activityIds? })` | Rename / re-scope. Empty `activityIds` clears the scope. | `{ id, name }` |
280+
| `deleteEngine(engineId)` | Delete the engine. Idempotent. | `void` |
281+
| `upsertOverrides(input)` | Set (full-replace) overrides for a date range. | `{ activityContexts }` |
282+
| `clearOverrides({ engineId, dateRange, activityIds })` | Clear overrides for the named activities. | `{ activityContexts }` |
283+
284+
Short-forms exist on `PeekAccessService` too: `peek.createPricingEngine(...)`,
285+
`updatePricingEngine`, `deletePricingEngine`, `upsertPricingOverrides`,
286+
`clearPricingOverrides` — identical behavior, one less accessor call.
287+
288+
---
289+
290+
## 10. Errors
291+
292+
- **Validation errors** (thrown *before* any network call, as plain `Error`):
293+
missing `engineId` / `dateRange` / `name` / `activityId` / ticket id, a
294+
`currency` that isn't 3 uppercase letters, a non-numeric price amount, a
295+
non-numeric `percentageAdjustment`, or `percentageAdjustment <= -100`.
296+
- **Peek rejections**: an `InvalidDataError` from Peek (e.g. overlapping tiers)
297+
surfaces as a thrown `Error` carrying Peek's message. A `NotFoundError` on
298+
`updateEngine` throws; on `deleteEngine` it's swallowed (idempotent).
299+
- **Transport errors**: HTTP 418 → `AdminAccountRequiredError`, 429 (after
300+
retries) → `RateLimitError`, GraphQL errors → `PeekGraphQLError`. All
301+
importable — branch with `instanceof`.
302+
303+
---
304+
305+
## 11. Common pitfalls (the greatest hits)
306+
307+
- **Forgetting to create the engine first.** The `engineId` must exist before any
308+
upsert. Create it lazily and persist the id.
309+
- **Clearing with `activities: []`.** That clears nothing. Use `clearOverrides`
310+
with the actual `activityIds`.
311+
- **Numbers instead of strings.** `price.amount` and `percentageAdjustment` are
312+
strings (`"80.00"`, `"-25"`).
313+
- **`spotsTaken` off-by-one.** `[5,10)` tickets → `{ minSpots: 4, maxSpots: 9 }`.
314+
`minSpots` counts spots already taken, not ticket lower bound.
315+
- **Expecting a partial update.** Upsert is a full replace for that
316+
`(dateRange, activity)`. Send the complete desired set every time.
317+
- **Mixing fixed and percentage in one template.** The API may accept it, but
318+
keeping one mode per schedule is far easier to reason about — pick fixed *or*
319+
percentage per set of overrides.

docs/internal/ARCHITECTURE.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,38 @@ Each resource follows the same **three-file triad**:
127127
| `*-service.ts` | Public class holding the business logic; calls the shared client, then runs the converter. |
128128

129129
Resources: `products`, `account-users`, `resource-pools`, `timeslots`,
130-
`resellers`, `promo-codes`, `daily-notes`, `availability`, `memberships`,
131-
`bookings`, `reviews`. Clean data shapes are split by brand: Peek models in
132-
`src/models/peek/`, CNG models in `src/models/cng/`.
130+
`resellers`, `promo-codes`, `pricing`, `daily-notes`, `availability`,
131+
`memberships`, `bookings`, `reviews`. Clean data shapes are split by brand: Peek
132+
models in `src/models/peek/`, CNG models in `src/models/cng/`.
133133

134134
`ProductService` exposes three top-level product filters in addition to the combined `getAllProducts()`:
135135
- `getAllActivities()` — fetches only the `activities` connection (one request, no add-on pagination).
136136
- `getAllAddons()` — fetches only the `itemOptions` connection, paginated.
137137

138+
`ProductService` also surfaces each activity's `currency` on the clean
139+
`Product` (empty string for add-ons, which have none) — the field pricing
140+
consumers need to set the currency on fixed-price overrides.
141+
142+
`pricing` is a **write-only, primitives-only** triad
143+
(`src/internal/peek/pricing/`, model `src/models/peek/pricing.ts`). It wraps the
144+
four Peek pricing mutations — `createPricingEngine`, `updatePricingEngine`,
145+
`deletePricingEngine`, and `upsertPricingOverridesActivityContexts` (used for
146+
both upsert and clear) — behind `PricingService`
147+
(`createEngine`/`updateEngine`/`deleteEngine`/`upsertOverrides`/`clearOverrides`).
148+
It deliberately does **not** own the domain logic that decides *what* the
149+
overrides are (segmenting an activity across time windows, ordering tiers,
150+
computing `spotsTaken` bounds, sunrise/sunset resolution): callers build a clean
151+
`UpsertOverridesInput` and the service sends it faithfully. The only transforms
152+
it applies are strip-the-`mode`-tag on each resource-option override (clean
153+
`{mode:"fixed",price}` / `{mode:"percentage",percentageAdjustment}` → the bare
154+
`price` / `percentageAdjustment` wire key, in `pricing-queries.ts`) and, on the
155+
response, the inverse plus filter-`__typename` normalization (in
156+
`pricing-converter.ts`). `deleteEngine` is idempotent — a `NotFoundError`
157+
resolves. Validation (engine/date-range presence, currency format, numeric
158+
amounts, `percentageAdjustment > -100`) lives in the service. The consumer-facing
159+
guide is `docs/external/pricing-api.md`**keep it in sync** whenever this
160+
surface changes.
161+
138162
`waivers` is a **webhook-only resource**: it has no GraphQL reads (so no
139163
queries/service/converter triad), just `src/internal/peek/waivers/waiver-webhook.ts`
140164
and the `src/models/peek/waiver.ts` model. See the webhook notes below.

0 commit comments

Comments
 (0)