|
| 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. |
0 commit comments