diff --git a/CHANGELOG.md b/CHANGELOG.md index 075c537..52f5dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,119 @@ # Changelog +## Next: 0.9.0, season seats (joins the season 2a section under ## 0.9.0 in the version PR; publish only once the phase 4 API is live on production) + +- Season seats: `setAsideSeasonBlock` (`POST /v1/seasons/:key/block`), `retrieveSeasonBlock` and + `releaseSeasonBlock` (`POST /v1/seasons/:key/block/release`). Each write answers `{ job, block }`. + Follow the job with `retrieveSeasonBulkJob`; `SeasonBulkJobKind` adds `season_block_set_aside` + and `season_block_release` (read by id, not listed). Types: `SeasonBlock`, `SeasonBlockCounts`, + `SeasonBlockJobAnswer`, `SetAsideSeasonBlockParams`, `ReleaseSeasonBlockParams`, + `SeasonBlockReleaseScope`, `SeasonSeatSelection`. +- `inventoryMode` on season create and update (opt a season into season seats), read back on every + season. Type: `SeasonInventoryMode`. +- Webhook names `season.block.ready` and `season.block.released`. +- A season-seats season's date cancel (`createSeasonAmendment`) or past date + (`markSeasonPerformancePast`) can answer `202` while the date leaves every season seat: + `SeasonDateLeavePending`, recognised by `isSeasonDateLeavePending`. The result types are unchanged. +- New refusals come through `SeatLayerError` with `code`: `season_block_not_enabled`, `block_exists`, + `block_not_ready`, `season_not_draft_or_active`, `block_selection_empty`, `seat_not_in_block`, + `season_block_set_aside`, `season_inventory_locked`, `date_leave_in_progress`. + `inventoryMode` can also answer `season_block_packages` (more than one package), + `season_block_on_sale`, `season_block_exists` and `invalid_inventory_mode`. The set-aside can + also answer `season_block_packages`, `season_block_one_package`, `plan_not_published`, + `season_block_undoing`, `chart_unavailable` and `dates_missing`, and the release + `release_mismatch`. On `503 season_busy` or `season_not_configured`, send the same request again. + +## Unreleased + +- A date its season cancelled never opens again: `transitionEventSales` to `testable` or `live` + and `reopen` answer `409 season_date_cancelled`, and `updateEventSeries` with `sales: 'on'` + leaves it closed as a failed item of the job (`season_date_cancelled`; not in `failedDates`). +- `createSeasonAmendment` refuses to move (`reschedule`), postpone or replace a cancelled date, + and `markSeasonPerformancePast` refuses to mark one as taken place: `409 performance_cancelled`. +- `updateEventSeries` sends only the prices that change: each date keeps its own price for every + category (or tier) left out, so weekend and weekday prices keep their difference. +- A season's `transferCutoffHours` / `returnCutoffHours` of `null` means the club default + (Settings › Ticket site), or until the date starts when the club has none. +- `createHolderTransfer` and `createHolderReturn` are not stopped by the club's customer switches + in Settings › Ticket site; cut-offs still apply. +- Cancelling a date voids its tickets as a `date_cancel_void` job (`SeasonBulkJobKind`), named + by `seasonDateCancelVoidJobId(amendmentId)` (`sbj_void_…`); `season.ticket.voided` says + `date_cancelled` for each season ticket it voids. +- A Season package holds 2 to 50 dates. `seasonHolderImportRowsPerCall(dates)` gives the most + rows one `createSeasonHolderImport` call takes (100 up to 20 dates, 43 at 50); a call with + more answers `422 invalid_rows` with `maxRows`. +- `SeasonSingleTicketDates.jobRefused` says why a `season_singles` job could not start; the + change stands, and sending `singleTickets: true` again opens any date still not open. An + `updateSeason` retried with its first key and `expectedRevision` is not applied twice. +- A series create given up for its key answers `409 create_abandoned` with a message to send + it again with a new idempotency key. + +- `createSeason` takes `mode` (`live` or `test`), as `POST /v1/seasons` already allows. +- Adds 39 Season operations (87 in all): performances and package prices, + `publishSeason`, checkout tests, import from a previous season, occurrence + inventory returns and reclaims, the holder list, season order refunds and + cancellations, hosted renewal links, contacts and notices, renewal quotes, + intents and checkouts, sales and seat reports, holder and order CSV exports, + bulk refunds with their jobs, and the release of lapsed renewal offers. +- Adds `holders` (customers, holders, holder tickets, ticket transfers), + `orders` (orders across events and seasons, and one event's orders, refunds, + cancellations and resends), and `eventSeries` resources. +- Adds `holders.createHolderReturn`: give back a holder's seat on one date, one + per ticket (`HolderReturn`, `HolderReturnTicket`, `CreateHolderReturnParams`). +- Adds 14 event sales operations to `events`: buyer checkout choice, payment + gateway, sales readiness, preflight and transitions, booking fee, and + discount codes. +- 27 of the new mutations use exact header replay (41 in all) and reuse one + `Idempotency-Key` across retries; the others are sent once. CSV exports + resolve with the CSV text; `deleteSeasonPlan` and `deleteEventDiscountCode` + resolve with nothing. +- Adds 10 webhook event names to `WebhookEventName`: `series.created`, + `series.dates_added`, `order.paid`, `order.refunded`, `order.cancelled`, + `customer.created`, `customer.email_changed`, `season.ticket.transfer_sent`, + `season.ticket.transferred`, and `season.ticket.transfer_cancelled`. +- Adds `seasons.refundSeasonPerformance` (refund every order of a cancelled date as a job, with + header replay), `seasons.listSeasonBulkJobs`, and `seasons.retrieveSeasonBulkJob` + (`SeasonBulkJob`, `SeasonBulkJobDetail`, `RefundSeasonPerformanceParams`). +- Adds price types: `priceTypeKey` on `replaceSeasonPackagePrices` rows (`SeasonPackagePriceInput`, + `SeasonPriceTypeKey`), `priceTypeKey` and `priceTypeLabel` on every `SeasonPackagePrice`, + `priceTypeKey` on each `SeasonHoldItem`, and `seats` with `priceTypeKey` on a retrieved or + inspected `SeasonRenewalOffer`. +- `createSeasonAmendment` takes `kind: 'postpone' | 'cancel'` with `refund` and `reason` + (`CreateSeasonAmendmentParams`) and resolves with the `refundJob` a refunding cancel starts. +- `SeasonAmendment.kind` adds `postpone`, `cancel`, `performance_added`, `performance_removed`, and + `performance_past` (`SeasonAmendmentKind`). +- `updateSeason` takes `checkoutMode`, `transferCutoffHours`, and `returnCutoffHours` + (`UpdateSeasonParams`). +- `SeasonHolderImportRow` takes `holderEmail` and `holderName`. +- `WebhookEventName` lists all 56 public event names, adding `object.resale.*`, + `season.occurrence.*`, `season.performance.*` (including `postponed`, `cancelled`, and + `rescheduled`), `season.bulk_action.completed`, `season.order.*`, `season.ticket.issued`, and + `season.ticket.voided`. +- `events.list` and `events.listAll` take `seriesKey` to list one event series' dates. +- Adds `seasons.retrieveSeasonRenewalQuote`: what renewing an offer costs, and whether SeatLayer checkout can take it now (`SeasonRenewalQuote`, `SeasonRenewalQuoteProblem`). +- Adds `seasons.createSeasonRenewalIntent`: record a holder's intent for a renewal taken by phone or at the box office, so your own checkout can commit. +- Adds `seasons.createSeasonRenewalCheckout`: a SeatLayer checkout that renews an offer with no hosted page, with header replay (`SeasonRenewalCheckoutParams`, `SeasonRenewalCheckoutStarted`). +- Adds `holders.createHolderRenewalCheckout`: the same checkout for one of a holder's offers, with header replay. +- Adds `holders.declineHolderRenewalOffer`: decline one of a holder's offers, with an optional `reason`. +- `declineSeasonRenewalOffer` takes an optional `{ reason }` (`DeclineSeasonRenewalOfferParams`), and `SeasonRenewalOffer` has `declineReason`. +- `Season` carries `checkoutMode`, `transferCutoffHours`, and `returnCutoffHours`, and `audience` is `'embed_only' | 'public'`; `updateSeason` takes `audience`. +- `openSeasonSales` resolves with `warnings` (`SeasonSalesOpenResult`): `checkout_test_missing` until a checkout test or rehearsal has passed. It can also refuse with `409 plan_not_published` or `409 checkout_mode_not_chosen`. +- A cancelled date refunds per date only: `createSeasonAmendment`'s `refund` is `'none' | 'per_date'`, and `refundSeasonPerformance` takes `amount: 'per_date'` (`full` answers `422 date_refund_per_date_only`; refund a whole order with the order refund). +- `SeasonHolder.status` adds `reserved` (an imported holder with seats and no offer yet). +- `retrieveSeasonHostedRenewals` readiness carries `missingPrices` with `prices_missing`. +- `addEventSeriesDates` takes `{ retryFailed: true }` to make the failed dates again (`409 series_busy`, `422 nothing_to_retry`). +- `Order` has `holderRef` and `customerId`, and `buyer.email` is nullable (a box office sale with no email); `listOrders` takes `holderRef`. +- Season holds, give-backs, and date cancels can refuse with `performance_cancelled`, `refund_not_started` (503), `missing_manage_capability` (403), or `invalid_refund`. +- `createEventSeries` takes `{ fromEventKey, schedule }` (`CreateEventSeriesFromEventParams`): the event becomes the first date under its own key, and every other date is a copy of it. It refuses with `404 event_not_found`, or `422 event_has_no_date`, `event_in_season`, `event_in_performance_group`, `event_in_series` or `invalid_request`. +- Every date of an event series carries the series' plain name (`Hamlet`, not `Hamlet · 3 Nov`); tell dates apart by `startsAt`. `updateEventSeries` renames each date that carries the series name, and a series name cannot hold `{n}`, `{date}` or `{time}`. +- Adds `seasons.releaseSeasonLapsedOffers`: release the seats of every lapsed renewal offer as a job (202 `{ job }`), with header replay. An offer extended before its turn is skipped and keeps its seat. It can refuse with `503 job_busy` or `season_not_configured`; send it again. +- `retrieveSeasonHostedRenewals` and `listSeasonRenewalOffers` page, newest first: pass `{ cursor, limit }` (`SeasonRenewalOfferPageOptions`; `limit` 1-500 and 1-100), and each page carries `nextCursor`, null on the last. A bad cursor or limit answers `400 invalid_cursor` or `invalid_limit`. +- `createSeasonRenewalOffers` resolves with `{ offers }` for 100 contracts or fewer, and with `{ job, batchId }` (`SeasonRenewalOfferJob`, a 202) above that: a `renewal_run` job makes the offers. Follow it with `retrieveSeasonBulkJob`, then page the offers with `listSeasonRenewalOffers`. +- `createSeasonHolderImportFromSeason` resolves with `{ job }` (a 202) when a commit brings across more than 100 holders: a `holder_import` job, whose `summary` has the same counts when it completes. `SeasonBulkJobKind` adds `holder_import`, and `SeasonBulkJobDetail` has `summary`. Both creates can refuse with `409 job_mismatch` or `503 season_not_configured`, and the bring-across with `503 job_busy`. +- `createSeasonRenewalNotices` takes up to 20,000 offer ids; more answers `422 too_many_offer_ids`. +- `createSeasonRenewalNotices` takes `batchId` instead of `offerIds`: email every open offer one `createSeasonRenewalOffers` batch made, such as the batch a `renewal_run` job made. +- Single tickets on a season: `createSeason` and `updateSeason` take `singleTickets` (default `false`, Season only; anything else answers `422 invalid_single_tickets`), and every `Season` carries it, with `singleTicketsNotOpen` on a season on sale. `updateSeason`, `openSeasonSales` and `resumeSeasonSales` resolve with `singleTicketDates` (`SeasonSingleTicketDates`, on `SeasonResult` and `SeasonSalesOpenResult`) when they open or stop the dates' own sales. `SeasonBulkJobKind` adds `season_singles`. + ## 0.8.0 (2026-09-10) - Added Event hosting regions: `region` on `events.create`, the `EventHostingRegion` type, `defaultRegion` on workspace create and update, and `region` on every Event read back (null on older Events). Events created without a region run in Western Europe. diff --git a/README.md b/README.md index a13d0ce..4e99146 100644 --- a/README.md +++ b/README.md @@ -285,11 +285,16 @@ await seatlayer.seasons.createSeasonHolderImport( { idempotencyKey: 'incumbents-2027-commit' }, ); const deadlineAt = Date.parse('2027-06-30T23:59:59Z'); -const { offers } = await seatlayer.seasons.createSeasonRenewalOffers( +const created = await seatlayer.seasons.createSeasonRenewalOffers( seasonKey, { successorPlanActivationId: importInput.successorPlanActivationId, deadlineAt }, { idempotencyKey: 'renewal-offers-2027' }, ); +// Above 100 contracts the answer is { job, batchId }: a renewal_run job makes the +// offers. Follow it with retrieveSeasonBulkJob, then page them with +// listSeasonRenewalOffers ({ cursor: page.nextCursor }). +if ('job' in created) return followRenewalRun(created.job, created.batchId); +const { offers } = created; // Browser intent is not payment. Inspect and commit only after your trusted // host has made its commercial decision with the exact immutable Plan. @@ -304,10 +309,78 @@ const terminal = await seatlayer.seasons.waitForSeasonRenewal(seasonKey, accepte if (terminal.offer.state === 'partial_terminal') reconcile(terminal.offer.commitOutcomes); ``` -Renewal intent requires a browser session bound to the exact `buyerRef`/holder. +Renewal intent normally comes from a browser session bound to the exact `buyerRef`/holder. For +a renewal taken by phone or at the box office, record it with your secret key first: +`createSeasonRenewalIntent(seasonKey, offerId)`, then take the payment and commit. A decline +can carry the holder's reason: `declineSeasonRenewalOffer(seasonKey, offerId, { reason })`. Extension is explicit and pre-lapse; no default extension is invented while organizer bounds and permission policy remain an owner-open rollout decision. +### Renewal with SeatLayer checkout + +When SeatLayer takes the season's payments, renew a holder with no hosted page: read the price, +start a checkout, and send the holder to pay. Paying renews the offer on every date. + +```ts +const quote = await seatlayer.seasons.retrieveSeasonRenewalQuote(seasonKey, offerId); +if (quote.problem === null) { + const started = await seatlayer.seasons.createSeasonRenewalCheckout( + seasonKey, + offerId, + { returnUrl: 'https://tickets.yourclub.example/renewed' }, + { idempotencyKey: `renew-${offerId}` }, + ); + redirect(started.payment.url!); +} +``` + +For one holder, `holders.createHolderRenewalCheckout(holderRef, offerId, params)` and +`holders.declineHolderRenewalOffer(holderRef, offerId, { reason })` do the same for an offer of +theirs. + +### Season seats + +Season seats are the seats a season sets aside on every date before its sales open. A season +buyer's hold then doesn't have to ask each date, and single-ticket buyers see those seats as +unavailable. They are off for every season until you send `inventoryMode: 'block'` on +`createSeason` or `updateSeason`. The season needs one package, and its sales must not have opened. + +```ts +await seatlayer.seasons.updateSeason(seasonKey, { expectedRevision: revision, inventoryMode: 'block' }); + +let { job } = await seatlayer.seasons.setAsideSeasonBlock(seasonKey, { + expectedRevision: revision, + keepForSingleTickets: { sectionIds: ['sec_family'] }, +}); +while (job.state === 'planning' || job.state === 'running') { + await new Promise((resolve) => setTimeout(resolve, 2000)); + ({ job } = await seatlayer.seasons.retrieveSeasonBulkJob(seasonKey, job.jobId)); +} +const { block } = await seatlayer.seasons.retrieveSeasonBlock(seasonKey); + +// Release unsold season seats to single sale. Leave out `at` to release now. +await seatlayer.seasons.releaseSeasonBlock(seasonKey, { + expectedRevision: block.revision, + scope: { all: true }, + at: '2026-12-01T09:00:00Z', +}); +``` + +- On an active season, block mode turns on by itself once every date has the seats, and + `season.block.ready` is sent once. On a draft, `publishSeason` turns it on. + `season.block.released` is sent once a release has run. +- In block mode, a date cancel (`createSeasonAmendment`, kind `cancel`) or + `markSeasonPerformancePast` first takes the date off every season seat. Until that finishes the + answer is a `202`: check it with `isSeasonDateLeavePending(result)`, then send the same request + again after `Retry-After`. +- Refusals come through `SeatLayerError` with `code`, and nothing changes. For example + `409 season_block_packages` (more than one package), `409 season_block_on_sale` (sales have + opened), or `409 block_not_ready` from `openSeasonSales` before block mode is on. +- A partner's seat on a date (one a sales channel sells there) stays out of the block. It is + listed in `block.notFreeOnEveryDate` with reason `channel`. + +The [Season seats docs](https://docs.seatlayer.io/server-api/seasons/#season-seats) list every refusal. + ## Private allocations `seatlayer.channels` manages event allocations and mints short-lived buyer access for a browser. @@ -356,6 +429,12 @@ exactly when you don't want that, and you can control it explicitly: await seatlayer.events.list({ limit: 50, counts: false }); ``` +To list only the dates of one event series, pass its key: + +```ts +await seatlayer.events.list({ seriesKey: 'ser_1' }); +``` + ## Keeping a hold alive When an order takes longer than the checkout window (an invoice, a phone sale), extend rather than @@ -463,12 +542,21 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Fourteen +**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Forty-one mutations use exact header replay: `charts.create`, `charts.copy`, `templates.instantiateTemplate`, -`events.create`, `workspaces.create`, `performanceGroups.createPerformanceGroup`, -`seasons.createSeason`, `seasons.updateSeason`, `seasons.deleteSeason`, `seasons.createSeasonPlan`, -`seasons.duplicateSeasonToLive`, `seasons.createSeasonHolderImport`, -`seasons.createSeasonRenewalOffers`, and `seasons.createSeasonAmendment`. The SDK generates one +`events.create`, `workspaces.create`, `performanceGroups.createPerformanceGroup`; 26 Season +operations (`createSeason`, `updateSeason`, `deleteSeason`, `publishSeason`, `createSeasonPlan`, +`updateSeasonPlan`, `deleteSeasonPlan`, `duplicateSeasonToLive`, `createSeasonHolderImport`, +`createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, +`createSeasonPerformances`, `attachSeasonPerformances`, `deleteSeasonPerformance`, +`markSeasonPerformancePast`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, +`replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `refundSeasonOrder`, `cancelSeasonOrder`, +`cancelSeasonOrderDate`, `refundSeasonPerformance`, `releaseSeasonLapsedOffers`, and +`createSeasonRenewalCheckout`); +`holders.updateHolder`, `holders.createHolderTransfer`, `holders.createHolderReturn`, and +`holders.createHolderRenewalCheckout`; `orders.refundEventOrder` and `orders.cancelEventOrder`; and +`eventSeries.createEventSeries`, `eventSeries.addEventSeriesDates`, and +`eventSeries.updateEventSeries`. The SDK generates one `Idempotency-Key` and reuses it for every attempt. All remaining SDK mutations are sent once; some have a server-side domain idempotency contract, but the SDK does not retry them automatically. @@ -480,7 +568,7 @@ before trying again. ```ts new SeatLayer({ secretKey: process.env.SEATLAYER_SECRET_KEY!, - maxRetries: 3, // retries for reads and the 14 exact-header-replay mutations + maxRetries: 3, // retries for reads and the 41 exact-header-replay mutations timeoutMs: 30_000, // per attempt }); ``` @@ -498,20 +586,26 @@ await seatlayer.request('POST', '/v1/events/ev_1/some-new-route', { body: { … The client exposes these resources. Performance Groups cover runs, sessions, holds, and bookings; Seasons cover catalogue, plan, sales, buyer-session, booking, renewal, occurrence, reporting, -outbox, and support operations. +outbox, and support operations, plus performances, package prices, holders, season orders, hosted +renewals, and seat reports. The CSV exports (`seasons.exportSeasonHoldersCsv`, +`seasons.exportSeasonOrdersCsv`, `orders.exportEventOrdersCsv`) resolve with the CSV text as a +string. | Resource | Methods | | --- | --- | | `charts` | `list` `listAll` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | | `templates` | `instantiateTemplate` | -| `events` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updatePoster` `deletePoster` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` | +| `events` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updatePoster` `deletePoster` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` `retrieveEventBuyerCheckout` `replaceEventBuyerCheckout` `retrieveEventPaymentGateway` `replaceEventPaymentGateway` `retrieveEventSalesReadiness` `preflightEventSales` `transitionEventSales` `retrieveEventBookingFee` `replaceEventBookingFee` `listEventDiscountCodes` `createEventDiscountCode` `updateEventDiscountCode` `endEventDiscountCode` `deleteEventDiscountCode` | +| `eventSeries` | `createEventSeries` `listEventSeries` `retrieveEventSeries` `addEventSeriesDates` `updateEventSeries` | +| `orders` | `listOrders` `retrieveOrder` `listOrderTickets` `listEventOrders` `retrieveEventOrder` `exportEventOrdersCsv` `refundEventOrder` `cancelEventOrder` `resendEventOrderTickets` | +| `holders` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` `createHolderRenewalCheckout` `declineHolderRenewalOffer` | | `inventory` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` `listBookings` `retrieveBooking` `listInventoryBookings` `retrieveInventoryBooking` | | `channels` | `listChannels` `createChannel` `updateChannel` `updateChannelAssignments` `listChannelAllocation` `retrieveChannelAccessPreview` `pauseChannel` `unpauseChannel` `archiveChannel` `retrieveChannelReport` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` `createAccessLink` `listAccessLinks` `rotateAccessLink` `revokeAccessLink` | | `sessions` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` | | `webhooks` | `list` `create` `update` `delete` `listDeliveries` | | `workspaces` | `list` `create` `retrieve` `update` | | `performanceGroups` | `listPerformanceGroups` `createPerformanceGroup` `retrievePerformanceGroup` `deletePerformanceGroup` `activatePerformanceGroup` `closePerformanceGroup` `retrievePerformanceGroupLifecycle` `createPerformanceGroupBuyerAccessSession` `listPerformanceGroupBuyerAccessSessions` `revokePerformanceGroupBuyerAccessSession` `retrievePerformanceGroupHold` `bookPerformanceGroupHold` `retrievePerformanceGroupBooking` | -| `seasons` | 48 organizer operations for catalogue and Plan lifecycle, sales windows, buyer access and booking, holder imports, renewals, occurrence amendments, reports, audit, outbox, and support export, plus polling helpers | +| `seasons` | 87 organizer operations for catalogue and Plan lifecycle, performances and package prices (with price types), sales windows, buyer access and booking, checkout tests, holder imports and the holder list, season orders, renewals and hosted renewal links, renewal quotes and checkouts, occurrence amendments (postpone, cancel) and inventory returns, bulk refunds of a cancelled date and the release of lapsed renewal offers with the jobs that run them, sales and seat reports, CSV exports, audit, outbox, and support export, plus polling helpers | Full reference: [SeatLayer Server API](https://docs.seatlayer.io/server-api/events/) diff --git a/src/http.ts b/src/http.ts index 64638be..4a38daf 100644 --- a/src/http.ts +++ b/src/http.ts @@ -142,18 +142,27 @@ export class HttpClient { /** Internal typed-operation path for any mutation backed by exact header replay. */ mutationWithHeaderReplay( - method: 'POST' | 'PATCH' | 'DELETE', + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, options: RequestOptions = {}, ): Promise { return this.#request(method, path, options, true); } + /** + * Internal read for text downloads such as CSV exports. The success body is + * returned as-is instead of being parsed as JSON; errors still map as usual. + */ + getText(path: string, options: RequestOptions = {}): Promise { + return this.#request('GET', path, options, false, 'text'); + } + async #request( method: string, path: string, options: RequestOptions, headerReplay: boolean, + responseType: 'json' | 'text' = 'json', ): Promise { method = method.toUpperCase(); if (options.body !== undefined && options.rawBody !== undefined) { @@ -166,7 +175,7 @@ export class HttpClient { const headers: Record = { Authorization: `Bearer ${this.#secretKey}`, - Accept: 'application/json', + Accept: responseType === 'text' ? 'text/csv, application/json' : 'application/json', 'User-Agent': '@seatlayer/server', }; if (options.body !== undefined) headers['Content-Type'] = 'application/json'; @@ -224,6 +233,7 @@ export class HttpClient { options.onResponse?.(response); if (response.status === 204) return undefined as T; const text = await response.text(); + if (responseType === 'text') return text as T; return (text ? JSON.parse(text) : undefined) as T; } diff --git a/src/index.ts b/src/index.ts index 4efc401..115f954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,11 @@ import { HttpClient, type ClientOptions } from './http.js'; import { Charts } from './resources/charts.js'; import { Channels } from './resources/channels.js'; +import { EventSeriesResource } from './resources/event-series.js'; import { Events } from './resources/events.js'; +import { Holders } from './resources/holders.js'; import { Inventory } from './resources/inventory.js'; +import { Orders } from './resources/orders.js'; import { PerformanceGroups } from './resources/performance-groups.js'; import { Sessions } from './resources/sessions.js'; import { Seasons } from './resources/seasons.js'; @@ -21,7 +24,10 @@ export class SeatLayer { readonly charts: Charts; readonly channels: Channels; readonly events: Events; + readonly eventSeries: EventSeriesResource; + readonly holders: Holders; readonly inventory: Inventory; + readonly orders: Orders; readonly performanceGroups: PerformanceGroups; readonly sessions: Sessions; readonly seasons: Seasons; @@ -42,7 +48,10 @@ export class SeatLayer { this.charts = new Charts(this.#http); this.channels = new Channels(this.#http); this.events = new Events(this.#http); + this.eventSeries = new EventSeriesResource(this.#http); + this.holders = new Holders(this.#http); this.inventory = new Inventory(this.#http); + this.orders = new Orders(this.#http); this.performanceGroups = new PerformanceGroups(this.#http); this.sessions = new Sessions(this.#http); this.seasons = new Seasons(this.#http); @@ -96,7 +105,11 @@ export { SeasonLifecycleTimeoutError, SeasonBookingTimeoutError, type CreateSeasonParams, + type CreateSeasonPerformancesParams, type CreateSeasonPlanParams, + type CreateSeasonAmendmentParams, + type RefundSeasonPerformanceParams, + type UpdateSeasonParams, type Season, type SeasonCompatibilityIssue, type SeasonBuyerAccessReveal, @@ -114,25 +127,84 @@ export { type SeasonHoldItem, type SeasonHolderImport, type SeasonHolderImportRow, + type SeasonCheckoutTest, + type SeasonDateJob, + type SeasonBulkJob, + type SeasonBulkJobCounts, + type SeasonBulkJobDetail, + type SeasonBulkJobKind, + type SeasonBlock, + type SeasonBlockCounts, + type SeasonBlockJobAnswer, + type SeasonBlockReleaseScope, + type SeasonSeatSelection, + type SeasonDateLeavePending, + type SetAsideSeasonBlockParams, + type ReleaseSeasonBlockParams, + type SeasonBulkJobState, + type SeasonRefundJobSummary, + type SeasonHolder, + type SeasonHolderFilter, + type SeasonHolderImportFromSeason, + type SeasonHolderListOptions, + type SeasonHolderPage, + type SeasonHostedRenewals, + type SeasonOccurrenceInventoryAction, + type SeasonOccurrenceInventorySource, + type SeasonOrderCancelReceipt, + type SeasonOrderDateCancel, + type SeasonOrderRefund, + type SeasonPackagePrice, + type SeasonPackagePriceInput, + type SeasonPriceTypeKey, + type SeasonPackagePrices, + type SeasonPackageResult, + type SeasonPackageSummary, + type SeasonPerformance, + type SeasonPerformanceCreateResult, + type SeasonPerformanceList, + type SeasonPlanPerformanceAttachResult, + type SeasonPlanPerformanceRemoveResult, + type SeasonPriceList, + type SeasonPublishResult, + type SeasonSalesOpenResult, + type SeasonResult, + type SeasonSingleTicketDates, + type SeasonSalesReport, + type SeasonSeatCounts, + type SeasonSeatsReport, + type SeasonStoppedOrder, type SeasonPlanPublicationResult, type SeasonPublicationState, + type SeasonInventoryMode, type SeasonSalesState, type SeasonSelectionParams, type SeasonSourcePerformanceGroup, type SeasonStructureState, type SeasonRenewalOffer, + type SeasonRenewalOfferJob, + type SeasonRenewalOfferPageOptions, type SeasonOccurrence, type SeasonAmendment, + type SeasonAmendmentKind, + type SeasonAmendmentRequestKind, type SeasonReport, type SeasonOperationSummary, type SeasonSupportLookup, type SeasonOutboxOccurrence, type SeasonAuditEntry, type SeasonRenewalResult, + type SeasonRenewalQuote, + type SeasonRenewalQuoteProblem, + type SeasonRenewalCheckoutParams, + type SeasonRenewalCheckoutStarted, + type DeclineSeasonRenewalOfferParams, type SeasonValidation, type WaitForSeasonLifecycleOptions, } from './resources/seasons.js'; -export { SeasonRenewalTimeoutError } from './resources/seasons.js'; +export { + SeasonRenewalTimeoutError, isSeasonDateLeavePending, seasonDateCancelVoidJobId, seasonHolderImportRowsPerCall, +} from './resources/seasons.js'; export type { AccessLink, AccessLinkReveal, @@ -161,12 +233,80 @@ export type { } from './resources/channels.js'; export { PUBLIC_CHANNEL_ID } from './resources/channels.js'; export type { + CreateEventDiscountCodeParams, + EventBookingFee, + EventBuyerCheckout, + EventBuyerCheckoutOption, + EventBuyerCheckoutReplaceResult, + EventBuyerCheckoutState, EventChartUpdateResult, + EventDiscountCode, EventListOptions, EventPage, + EventPaymentGatewayAssignment, + EventPaymentGatewayOptions, + EventSalesPreflightResult, + EventSalesReadiness, + EventSalesState, + EventSalesTransitionResult, EventUpdateParams, PosterImage, + UpdateEventDiscountCodeParams, } from './resources/events.js'; +export type { + AddEventSeriesDatesParams, + CreateEventSeriesParams, + CreateEventSeriesFromPlanParams, + CreateEventSeriesFromEventParams, + EventSeries, + EventSeriesCreated, + EventSeriesDatesAdded, + EventSeriesDetail, + EventSeriesJobSummary, + EventSeriesListOptions, + EventSeriesPage, + EventSeriesSchedule, + EventSeriesUpdated, + UpdateEventSeriesParams, +} from './resources/event-series.js'; +export type { + CreateHolderReturnParams, + CreateHolderTransferParams, + Customer, + CustomerListOptions, + CustomerPage, + Holder, + HolderReturn, + HolderReturnTicket, + HolderSeason, + HolderTicket, + HolderTicketDetail, + HolderTicketListOptions, + HolderTicketPage, + TicketTransfer, + UpdateHolderParams, +} from './resources/holders.js'; +export type { + CancelOrderParams, + EventOrderCancelResult, + EventOrderDetail, + EventOrderExportOptions, + EventOrderListOptions, + EventOrderPage, + EventOrderRefundResult, + EventOrderResendResult, + EventOrderStatus, + EventOrderSummary, + Order, + OrderDetail, + OrderLine, + OrderListOptions, + OrderPage, + OrderStatus, + OrderTicket, + RefundOrderParams, + ResendOrderTicketsParams, +} from './resources/orders.js'; export type { InstantiateTemplateOptions, InstantiateTemplateParams } from './resources/templates.js'; export type * from './types.js'; diff --git a/src/resources/event-series.ts b/src/resources/event-series.ts new file mode 100644 index 0000000..453729e --- /dev/null +++ b/src/resources/event-series.ts @@ -0,0 +1,210 @@ +import type { HttpClient } from '../http.js'; +import type { KeyMode } from '../types.js'; + +/** A repeat rule. Dates and times are local to the series time zone. */ +export interface EventSeriesSchedule { + rule: 'none' | 'daily' | 'weekly' | 'biweekly' | 'dates'; + from?: string; + until?: string; + count?: number; + times: string[]; + skip?: string[]; + dates?: string[]; +} + +export interface EventSeriesJobSummary { + jobId: string; + state: string; + total: number; + done: number; + failed: number; + [key: string]: unknown; +} + +export interface EventSeries { + seriesKey: string; + name: string; + chartKey: string; + timezone: string; + state: 'creating' | 'ready' | 'partial' | 'failed'; + dateCount: number; + firstEventKey: string | null; + job: EventSeriesJobSummary | null; + mode: KeyMode; + createdAt: number; + updatedAt: number; + [key: string]: unknown; +} + +/** A series planned on a published floor plan. Every date carries `name` as its name. */ +export interface CreateEventSeriesFromPlanParams { + name: string; + chartKey: string; + schedule: EventSeriesSchedule; + timezone?: string; + venue?: string; + durationMinutes?: number; + /** Plan the dates without creating anything. */ + dryRun?: boolean; +} + +/** + * A series made from an event you already set up. The event becomes the first + * date under its own key, and every other date is a copy of it: floor plan, + * prices, checkout, payment account, poster, booking form and its own seat + * blocks, but none of its orders, holds, sales or reservations. The series + * takes the event's name, zone and floor plan. + */ +export interface CreateEventSeriesFromEventParams { + fromEventKey: string; + schedule: EventSeriesSchedule; + /** Optional; when sent it must be the event's own name. */ + name?: string; + /** Optional; when sent it must be the event's own zone. */ + timezone?: string; + /** List every date the series would hold, the event included, without creating anything. */ + dryRun?: boolean; +} + +export type CreateEventSeriesParams = CreateEventSeriesFromPlanParams | CreateEventSeriesFromEventParams; + +export interface EventSeriesCreated { + series: EventSeries; + jobId: string; + [key: string]: unknown; +} + +export interface EventSeriesListOptions { + /** Page size. Defaults to 50 and is capped at 100. */ + limit?: number; + cursor?: string; +} + +export interface EventSeriesPage { + series: EventSeries[]; + /** Absent once the list is exhausted. */ + nextCursor?: string; + [key: string]: unknown; +} + +export interface EventSeriesDetail { + series: EventSeries; + events: Array<{ + key: string; + name: string; + startsAt: number | null; + seriesKey: string | null; + [key: string]: unknown; + }>; + job: (EventSeriesJobSummary & { + failedDates: Array<{ moment: string; error: string | null; [key: string]: unknown }>; + }) | null; + [key: string]: unknown; +} + +/** Add dates from another repeat rule, or from an explicit list. */ +/** + * New dates by schedule, by exact moments, or `{ retryFailed: true }` to make + * again every date that failed to be made (up to 100, as in `job.failedDates`). + */ +export type AddEventSeriesDatesParams = { schedule: EventSeriesSchedule } | { dates: string[] } | { retryFailed: true }; + +export interface EventSeriesDatesAdded { + jobId: string; + added: number; + [key: string]: unknown; +} + +export interface UpdateEventSeriesParams { + name?: string; + /** + * Only the categories whose price changes (for a tiered category, only the tiers sent), each as + * `PUT /v1/events/:key/pricing` takes it. Every price left out keeps each date's own value. + */ + prices?: { + categories: Array<{ + key: string; + price?: number | null; + tiers?: Array<{ id: string; price: number }>; + }>; + }; + /** + * `on` never opens a date its season cancelled: that date is a failed item of the job + * (`season_date_cancelled`), counted in `job.failed` but not in `failedDates`, which are the + * dates that could not be made. The run's other dates open. + */ + sales?: 'on' | 'off'; +} + +export interface EventSeriesUpdated { + series: EventSeries; + jobId: string | null; + job: (EventSeriesJobSummary & { skipped: number }) | null; + [key: string]: unknown; +} + +/** + * Event series: many dated Events made from one chart and a repeat rule. + * `seriesKey` is the key the API returns as `series.seriesKey`. + */ +export class EventSeriesResource { + #http: HttpClient; + + constructor(http: HttpClient) { + this.#http = http; + } + + /** + * Plan an event series from a schedule (`dryRun`), or create its dates as a + * background job. Retried safely with one Idempotency-Key. A series from an + * event that never started and was given up answers `409 create_abandoned` + * for that key every time: send the request again with a new key. + */ + createEventSeries( + params: CreateEventSeriesParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay('/v1/event-series', { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** One page of event series. Pass `cursor` from the previous page's `nextCursor`. */ + listEventSeries(options: EventSeriesListOptions = {}): Promise { + return this.#http.get('/v1/event-series', { query: { + limit: options.limit, + cursor: options.cursor, + } }); + } + + /** Retrieve an event series, its dates and its current job. */ + retrieveEventSeries(seriesKey: string): Promise { + return this.#http.get(`/v1/event-series/${encodeURIComponent(seriesKey)}`); + } + + /** Add dates to a series, copied from its first date. Retried safely with one Idempotency-Key. */ + addEventSeriesDates( + seriesKey: string, + params: AddEventSeriesDatesParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/event-series/${encodeURIComponent(seriesKey)}/dates`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** + * Change the name, prices or sales of every upcoming date in a series. + * Send only the prices that change: each date keeps its own price for the rest. + * Retried safely with one Idempotency-Key. + */ + updateEventSeries( + seriesKey: string, + params: UpdateEventSeriesParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay('PATCH', `/v1/event-series/${encodeURIComponent(seriesKey)}`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } +} diff --git a/src/resources/events.ts b/src/resources/events.ts index eb38768..b7736f9 100644 --- a/src/resources/events.ts +++ b/src/resources/events.ts @@ -25,6 +25,8 @@ export interface EventListOptions { cursor?: string; /** Include live availability counts. One server round-trip per event. */ counts?: boolean; + /** Only the dates of this event series. */ + seriesKey?: string; } export interface EventPage { @@ -60,6 +62,136 @@ export type EventUpdateParams = { /** Raw image input accepted by fetch; Node Buffers satisfy ArrayBufferView. */ export type PosterImage = Blob | ArrayBuffer | ArrayBufferView; +/** Where an event is in its sales lifecycle. */ +export type EventSalesState = 'setup' | 'testable' | 'live' | 'paused' | 'ended'; + +/** Who takes payment: SeatLayer's hosted checkout, or your own (`integrator`). */ +export type EventBuyerCheckout = 'seatlayer' | 'integrator'; + +export interface EventBuyerCheckoutOption { + value: EventBuyerCheckout; + selected: boolean; + eligible: boolean; + blockers: Array<{ reason: string; count?: number; [key: string]: unknown }>; + [key: string]: unknown; +} + +export interface EventBuyerCheckoutState { + buyerCheckout: EventBuyerCheckout | null; + revision: number; + salesState: EventSalesState; + paymentGateway: unknown; + options: EventBuyerCheckoutOption[]; + [key: string]: unknown; +} + +export interface EventBuyerCheckoutReplaceResult extends EventBuyerCheckoutState { + ok: boolean; + changed: boolean; + from?: string | null; + paymentGatewayCleared?: boolean; + meta: Record; +} + +export interface EventPaymentGatewayOptions { + assigned: string | null; + assignedConnectionId: string | null; + buyerCheckout: string | null; + eventMode: KeyMode; + eventCurrency: string | null; + options: Array<{ + connectionId: string; + provider: string; + mode: string; + currency: string | null; + eligible: boolean; + problem?: string; + [key: string]: unknown; + }>; + problem: string | null; + [key: string]: unknown; +} + +export interface EventPaymentGatewayAssignment { + assigned: string | null; + assignedConnectionId: string | null; + [key: string]: unknown; +} + +export interface EventSalesReadiness { + state: EventSalesState; + revision: number; + path: EventBuyerCheckout | null; + readiness: Record; + required: string[]; + gatewayRefusal: unknown; + [key: string]: unknown; +} + +export interface EventSalesPreflightResult { + ok: true; + preflight: { id: string; order_id: string; [key: string]: unknown }; + reused: boolean; + [key: string]: unknown; +} + +export interface EventSalesTransitionResult { + ok: boolean; + changed: boolean; + state: EventSalesState; + revision: number; + from?: EventSalesState; + meta: Record; + [key: string]: unknown; +} + +export interface EventBookingFee { + bookingFeeMinor: number | null; + currency: string | null; + confirmedOrders: number; + editable: boolean; + available: boolean; + reason: string | null; + [key: string]: unknown; +} + +export interface EventDiscountCode { + id: string; + code: string; + kind: 'percent' | 'amount'; + percent: number | null; + amountMinor: number | null; + currency: string | null; + startsAt: number | null; + endsAt: number | null; + maxUses: number | null; + state: string; + status: string; + uses: number; + discountGivenMinor: number; + createdAt: number; + updatedAt: number; + [key: string]: unknown; +} + +export interface CreateEventDiscountCodeParams { + code: string; + kind: 'percent' | 'amount'; + /** Required when `kind` is `percent`. */ + percent?: number; + /** Required when `kind` is `amount`. */ + amountMinor?: number; + startsAt?: number | null; + endsAt?: number | null; + maxUses?: number | null; + active?: boolean; +} + +export interface UpdateEventDiscountCodeParams extends Partial { + /** Pause a code, or make it active again. */ + state?: 'active' | 'paused'; +} + export class Events { #http: HttpClient; @@ -83,6 +215,7 @@ export class Events { limit: options.limit, cursor: options.cursor, ...(options.counts === false ? { counts: '0' } : {}), + seriesKey: options.seriesKey, }, }); } @@ -189,6 +322,10 @@ export class Events { return this.#http.post(`/v1/events/${encodeURIComponent(eventKey)}/close`); } + /** + * Open a paused event's sales again. A date its season cancelled never opens again: + * `409 season_date_cancelled`. + */ reopen(eventKey: string): Promise { return this.#http.post(`/v1/events/${encodeURIComponent(eventKey)}/reopen`); } @@ -244,4 +381,110 @@ export class Events { retrieveLog(eventKey: string, options: { limit?: number; before?: number } = {}): Promise { return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/log`, { query: options }); } + + /** Retrieve who takes payment for an event, and what blocks changing it. */ + retrieveEventBuyerCheckout(eventKey: string): Promise { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/buyer-checkout`); + } + + /** + * Choose SeatLayer checkout or your own for an event. Compare-and-set on + * `expectedRevision`; sent once, so read the state back after an unknown outcome. + */ + replaceEventBuyerCheckout(eventKey: string, params: { + buyerCheckout: EventBuyerCheckout; + expectedRevision: number; + }): Promise { + return this.#http.put(`/v1/events/${encodeURIComponent(eventKey)}/buyer-checkout`, { body: params }); + } + + /** Retrieve an event's payment gateway and the connected gateways it could use. */ + retrieveEventPaymentGateway(eventKey: string, options: { + /** Check the options against this currency instead of the event's. */ + currency?: string; + } = {}): Promise { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/payment-gateway`, { + query: { currency: options.currency }, + }); + } + + /** Assign one of the organisation's connected gateways to an event. Sent once. */ + replaceEventPaymentGateway(eventKey: string, params?: { + connectionId?: string | null; + provider?: string | null; + }): Promise { + return this.#http.put(`/v1/events/${encodeURIComponent(eventKey)}/payment-gateway`, { body: params }); + } + + /** Retrieve what an event still needs before its next sales step. */ + retrieveEventSalesReadiness(eventKey: string): Promise { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/sales/readiness`); + } + + /** Record a test order as the event's sales preflight. Sent once. */ + preflightEventSales(eventKey: string, params: { orderId: string }): Promise { + return this.#http.post(`/v1/events/${encodeURIComponent(eventKey)}/sales/preflight`, { body: params }); + } + + /** + * Move an event's sales to testable, live, paused or ended. Compare-and-set + * on `expectedRevision`; sent once. A date its season cancelled never opens again: + * moving it to `testable` or `live` answers `409 season_date_cancelled`. + */ + transitionEventSales(eventKey: string, params: { + to: EventSalesState; + expectedRevision: number; + reason?: string; + }): Promise { + return this.#http.post(`/v1/events/${encodeURIComponent(eventKey)}/sales/transition`, { body: params }); + } + + /** Retrieve an event's booking fee. */ + retrieveEventBookingFee(eventKey: string): Promise { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/booking-fee`); + } + + /** Set an event's booking fee in minor units, or clear it with `null`. Sent once. */ + replaceEventBookingFee(eventKey: string, params: { bookingFeeMinor: number | null }): Promise { + return this.#http.put(`/v1/events/${encodeURIComponent(eventKey)}/booking-fee`, { body: params }); + } + + /** List an event's discount codes. */ + listEventDiscountCodes(eventKey: string): Promise<{ codes: EventDiscountCode[] }> { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/discount-codes`); + } + + /** Create a discount code for an event. Sent once. */ + createEventDiscountCode( + eventKey: string, + params: CreateEventDiscountCodeParams, + ): Promise<{ code: EventDiscountCode }> { + return this.#http.post(`/v1/events/${encodeURIComponent(eventKey)}/discount-codes`, { body: params }); + } + + /** Change or pause a discount code. Sent once. */ + updateEventDiscountCode( + eventKey: string, + codeId: string, + params: UpdateEventDiscountCodeParams, + ): Promise<{ code: EventDiscountCode }> { + return this.#http.patch( + `/v1/events/${encodeURIComponent(eventKey)}/discount-codes/${encodeURIComponent(codeId)}`, + { body: params }, + ); + } + + /** End a discount code for good. */ + endEventDiscountCode(eventKey: string, codeId: string): Promise<{ code: EventDiscountCode }> { + return this.#http.post( + `/v1/events/${encodeURIComponent(eventKey)}/discount-codes/${encodeURIComponent(codeId)}/end`, + ); + } + + /** Delete a discount code nobody has used. Resolves with nothing (204). Sent once. */ + deleteEventDiscountCode(eventKey: string, codeId: string): Promise { + return this.#http.delete( + `/v1/events/${encodeURIComponent(eventKey)}/discount-codes/${encodeURIComponent(codeId)}`, + ); + } } diff --git a/src/resources/holders.ts b/src/resources/holders.ts new file mode 100644 index 0000000..121dc33 --- /dev/null +++ b/src/resources/holders.ts @@ -0,0 +1,300 @@ +import type { HttpClient } from '../http.js'; +import type { KeyMode } from '../types.js'; +import type { + DeclineSeasonRenewalOfferParams, + SeasonRenewalCheckoutParams, + SeasonRenewalCheckoutStarted, + SeasonRenewalOffer, +} from './seasons.js'; + +export interface Customer { + id: string; + email: string; + name: string | null; + phone: string | null; + status: 'active' | 'disabled'; + mode: KeyMode; + createdAt: number; + holderRefs: string[]; + [key: string]: unknown; +} + +export interface CustomerPage { + customers: Customer[]; + nextCursor: string | null; + [key: string]: unknown; +} + +export interface CustomerListOptions { + /** Only the customer with this email. */ + email?: string; + /** Only the customer linked to this holder reference. */ + holderRef?: string; + cursor?: string; + /** Page size. Defaults to 50 and is capped at 100. */ + limit?: number; +} + +export interface Holder { + holderRef: string; + customerId: string | null; + email: string | null; + name: string | null; + phone: string | null; + status: 'active' | 'disabled' | 'pending'; + [key: string]: unknown; +} + +export interface UpdateHolderParams { + email?: string; + name?: string | null; + phone?: string | null; +} + +export interface HolderSeason { + seasonKey: string; + name: string; + package: { planActivationId: string; name: string | null; [key: string]: unknown } | null; + seats: string[]; + status: 'active' | 'reserved' | 'offered' | 'renewed' | 'released' | 'cancelled'; + dates: Array<{ + eventKey: string; + name: string; + startsAt: number | null; + state: 'booked' | 'cancelled' | 'renewed' | 'failed' | 'partial_terminal'; + seats: string[]; + [key: string]: unknown; + }>; + renewalOffer: { offerId: string; state: string; deadlineAt: number | null; [key: string]: unknown } | null; + [key: string]: unknown; +} + +export interface HolderTicket { + ticketId: string; + orderId: string; + eventKey: string; + eventName: string; + startsAt: number | null; + label: string; + status: 'issued' | 'checked_in' | 'void'; + seasonKey: string | null; + [key: string]: unknown; +} + +export interface HolderTicketDetail extends HolderTicket { + qrUrl: string; + scanUrl: string; +} + +export interface HolderTicketPage { + tickets: HolderTicket[]; + nextCursor: string | null; + [key: string]: unknown; +} + +export interface HolderTicketListOptions { + /** Only tickets of this season. */ + seasonKey?: string; + /** Only dates starting at or after this time, in epoch milliseconds. */ + from?: number; + /** Only dates starting before this time, in epoch milliseconds. */ + to?: number; + cursor?: string; + /** Page size. Defaults to 50 and is capped at 100. */ + limit?: number; +} + +export interface TicketTransfer { + transferId: string; + state: 'sent' | 'accepted' | 'cancelled' | 'expired'; + seasonKey: string | null; + tickets: string[]; + fromHolderRef: string | null; + toEmail: string; + toHolderRef: string | null; + expiresAt: number; + createdAt: number; + acceptedAt: number | null; + cancelledAt: number | null; + [key: string]: unknown; +} + +export interface CreateHolderTransferParams { + ticketIds: string[]; + toEmail: string; +} + +export interface HolderReturnTicket { + ticketId: string; + eventKey: string; + label: string; + state: 'pending' | 'returned' | 'failed'; + /** Why this seat could not be given back, when `state` is `failed`. */ + error: string | null; + [key: string]: unknown; +} + +export interface HolderReturn { + returnId: string; + /** `pending` while a seat is still being released; it finishes on its own. */ + state: 'pending' | 'returned' | 'failed'; + seasonKey: string; + tickets: HolderReturnTicket[]; + createdAt: number; + [key: string]: unknown; +} + +export interface CreateHolderReturnParams { + /** 1 to 20 ticket ids, all of one season. Each ticket is one seat on one date. */ + ticketIds: string[]; +} + +/** + * Season ticket holders, their customers, tickets, ticket transfers, returns, and renewals. + * `holderRef` is your own holder reference, as your imports and bookings carry + * it; a hosted season purchase uses its order id. + */ +export class Holders { + #http: HttpClient; + + constructor(http: HttpClient) { + this.#http = http; + } + + /** List customers, or find one by email or holder reference. */ + listCustomers(options: CustomerListOptions = {}): Promise { + return this.#http.get('/v1/customers', { query: { + email: options.email, + holderRef: options.holderRef, + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** Retrieve the customer behind a holder reference. */ + retrieveHolder(holderRef: string): Promise<{ holder: Holder }> { + return this.#http.get(`/v1/holders/${encodeURIComponent(holderRef)}`); + } + + /** Set a holder's email, name or phone. Retried safely with one Idempotency-Key. */ + updateHolder( + holderRef: string, + params: UpdateHolderParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ holder: Holder }> { + return this.#http.mutationWithHeaderReplay('PATCH', `/v1/holders/${encodeURIComponent(holderRef)}`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** Sign a holder's customer out of the ticket site: every session, or the one named. */ + revokeHolderSessions(holderRef: string, params?: { sessionId?: string }): Promise<{ + revoked: number; [key: string]: unknown; + }> { + return this.#http.post(`/v1/holders/${encodeURIComponent(holderRef)}/sessions/revoke`, { + body: params, + }); + } + + /** List the seasons a holder holds seats in. */ + listHolderSeasons(holderRef: string): Promise<{ seasons: HolderSeason[] }> { + return this.#http.get(`/v1/holders/${encodeURIComponent(holderRef)}/seasons`); + } + + /** One page of a holder's tickets, date by date. */ + listHolderTickets(holderRef: string, options: HolderTicketListOptions = {}): Promise { + return this.#http.get(`/v1/holders/${encodeURIComponent(holderRef)}/tickets`, { query: { + seasonKey: options.seasonKey, + from: options.from, + to: options.to, + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** Retrieve one of a holder's tickets with its QR and scan links. */ + retrieveHolderTicket(holderRef: string, ticketId: string): Promise<{ ticket: HolderTicketDetail }> { + return this.#http.get( + `/v1/holders/${encodeURIComponent(holderRef)}/tickets/${encodeURIComponent(ticketId)}`, + ); + } + + /** + * Send some of a holder's season tickets to an email. Retried safely with one Idempotency-Key. + * Turning customer sends off in Settings › Ticket site doesn't stop your server; cut-offs still apply. + */ + createHolderTransfer( + holderRef: string, + params: CreateHolderTransferParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ transfer: TicketTransfer }> { + return this.#http.postWithHeaderReplay(`/v1/holders/${encodeURIComponent(holderRef)}/transfers`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** List transfers a holder sent or was sent. */ + listHolderTransfers(holderRef: string): Promise<{ transfers: TicketTransfer[] }> { + return this.#http.get(`/v1/holders/${encodeURIComponent(holderRef)}/transfers`); + } + + /** Cancel a transfer that has not been accepted yet. */ + cancelHolderTransfer(holderRef: string, transferId: string): Promise<{ transfer: TicketTransfer }> { + return this.#http.post( + `/v1/holders/${encodeURIComponent(holderRef)}/transfers/${encodeURIComponent(transferId)}/cancel`, + ); + } + + /** Accept a transfer on behalf of the recipient holder. */ + acceptHolderTransfer(holderRef: string, transferId: string): Promise<{ transfer: TicketTransfer }> { + return this.#http.post( + `/v1/holders/${encodeURIComponent(holderRef)}/transfers/${encodeURIComponent(transferId)}/accept`, + ); + } + + /** + * Give back a holder's seat on one date, one per ticket. Retried safely with one Idempotency-Key. + * Turning give-backs off in Settings › Ticket site doesn't stop your server; cut-offs still apply. + */ + createHolderReturn( + holderRef: string, + params: CreateHolderReturnParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ return: HolderReturn }> { + return this.#http.postWithHeaderReplay(`/v1/holders/${encodeURIComponent(holderRef)}/returns`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** + * Start a SeatLayer checkout that renews one of a holder's offers (`renewalOffer` on + * `listHolderSeasons`). Send the buyer to `payment.url`. Retried safely with one + * Idempotency-Key, which answers the same order. + */ + createHolderRenewalCheckout( + holderRef: string, + offerId: string, + params: SeasonRenewalCheckoutParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/holders/${encodeURIComponent(holderRef)}/renewal-offers/${encodeURIComponent(offerId)}/checkout`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Decline one of a holder's renewal offers and release its seats, optionally with the + * holder's reason. Declining again answers the same offer, with the first reason. + */ + declineHolderRenewalOffer( + holderRef: string, + offerId: string, + params?: DeclineSeasonRenewalOfferParams, + ): Promise<{ offer: SeasonRenewalOffer }> { + return this.#http.post( + `/v1/holders/${encodeURIComponent(holderRef)}/renewal-offers/${encodeURIComponent(offerId)}/decline`, + { body: params }, + ); + } +} diff --git a/src/resources/orders.ts b/src/resources/orders.ts new file mode 100644 index 0000000..7d2552a --- /dev/null +++ b/src/resources/orders.ts @@ -0,0 +1,282 @@ +import type { HttpClient } from '../http.js'; +import type { KeyMode } from '../types.js'; + +export type OrderStatus = + | 'pending' + | 'authorized' + | 'inventory_committed' + | 'confirmed' + | 'compensating' + | 'failed' + | 'expired' + | 'refunded' + | 'partially_refunded'; + +/** Event order filters also accept orders where only some seats were cancelled. */ +export type EventOrderStatus = OrderStatus | 'partially_cancelled'; + +export interface Order { + orderId: string; + displayRef: string; + status: OrderStatus; + kind: 'event' | 'season' | 'renewal' | 'box_office'; + eventKey: string | null; + seasonKey: string | null; + /** A season order's own id, or the reference a renewal was offered to; null for event and box office orders. */ + holderRef: string | null; + /** The customer the order belongs to once its buyer has signed in; otherwise null. */ + customerId: string | null; + /** `email` is null for a box office sale taken without one. */ + buyer: { name: string | null; email: string | null; [key: string]: unknown }; + amounts: { + currency: string; + subtotalMinor: number; + bookingFeeMinor: number; + totalMinor: number; + refundedMinor: number; + [key: string]: unknown; + }; + mode: KeyMode; + createdAt: number; + confirmedAt: number | null; + [key: string]: unknown; +} + +export interface OrderLine { + label: string; + category: string | null; + quantity: number; + unitPriceMinor: number; + amountMinor: number; + [key: string]: unknown; +} + +export interface OrderDetail extends Order { + lines: OrderLine[]; +} + +export interface OrderPage { + orders: Order[]; + nextCursor: string | null; + [key: string]: unknown; +} + +export interface OrderListOptions { + /** Only this event's orders (not season orders). */ + eventKey?: string; + /** Only this season's orders, renewals included. */ + seasonKey?: string; + /** Only this holder's orders: the season order with this id and renewals offered to this reference. */ + holderRef?: string; + status?: OrderStatus; + /** Created at or after, in epoch milliseconds. */ + from?: number; + /** Created before, in epoch milliseconds. */ + to?: number; + cursor?: string; + /** Page size. Defaults to 50 and is capped at 100. */ + limit?: number; +} + +export interface OrderTicket { + ticketId: string; + eventKey: string; + eventName: string; + startsAt: number | null; + label: string; + status: 'issued' | 'checked_in' | 'void'; + scanUrl: string; + [key: string]: unknown; +} + +export interface EventOrderListOptions { + /** Search by buyer name, email, order number or seat. */ + q?: string; + status?: EventOrderStatus; + /** Only orders paid this way. */ + paidBy?: string; + /** Paid at or after, in epoch milliseconds. */ + from?: number; + /** Paid before, in epoch milliseconds. */ + to?: number; + cursor?: string; + /** Page size. Defaults to 25 and is capped at 100. */ + limit?: number; +} + +/** The CSV export takes the list filters, without paging. */ +export type EventOrderExportOptions = Omit; + +export interface EventOrderSummary { + orderId: string; + displayRef: string; + status: OrderStatus; + [key: string]: unknown; +} + +export interface EventOrderPage { + orders: EventOrderSummary[]; + matchedCount: number; + nextCursor: string | null; + pageSize: number; + totals: Record; + [key: string]: unknown; +} + +export interface EventOrderDetail { + orderId: string; + displayRef: string; + tickets: Array>; + lines: Array<{ label: string; [key: string]: unknown }>; + [key: string]: unknown; +} + +export interface EventOrderRefundResult { + orderId: string; + operationId: string; + refundId?: string | null; + amountMinor?: number | null; + refundedMinor?: number; + status?: OrderStatus; + refundStatus?: string; + [key: string]: unknown; +} + +export interface EventOrderCancelResult { + receipt?: Record; + [key: string]: unknown; +} + +export interface EventOrderResendResult { + orderId: string; + displayRef: string; + sentTo: string; + addressCorrected: boolean; + resentAt: number; + ticketCount: number; + [key: string]: unknown; +} + +export interface RefundOrderParams { + /** Partial refund in minor units. Leave out for a full refund. */ + amountMinor?: number; +} + +/** Cancelling an order moves no money: record why, and confirm any refund was settled. */ +export interface CancelOrderParams { + reason: 'lost_dispute' | 'refunded_elsewhere' | 'other'; + note?: string; + refundConfirmed?: boolean; + usedTicketsAcknowledged?: boolean; + notifyBuyer?: boolean; +} + +export interface ResendOrderTicketsParams { + /** Send to this address instead of the buyer's. */ + email?: string; +} + +/** Orders across events and seasons, plus one event's orders, refunds and resends. */ +export class Orders { + #http: HttpClient; + + constructor(http: HttpClient) { + this.#http = http; + } + + /** One page of orders across events and seasons. Pass `cursor` from the previous page. */ + listOrders(options: OrderListOptions = {}): Promise { + return this.#http.get('/v1/orders', { query: { + eventKey: options.eventKey, + seasonKey: options.seasonKey, + holderRef: options.holderRef, + status: options.status, + from: options.from, + to: options.to, + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** Retrieve an order and its lines. */ + retrieveOrder(orderId: string): Promise<{ order: OrderDetail }> { + return this.#http.get(`/v1/orders/${encodeURIComponent(orderId)}`); + } + + /** List an order's tickets with their scan links. */ + listOrderTickets(orderId: string): Promise<{ tickets: OrderTicket[] }> { + return this.#http.get(`/v1/orders/${encodeURIComponent(orderId)}/tickets`); + } + + /** One page of a single event's orders, with totals for the filter. */ + listEventOrders(eventKey: string, options: EventOrderListOptions = {}): Promise { + return this.#http.get(`/v1/events/${encodeURIComponent(eventKey)}/orders`, { query: { + q: options.q, + status: options.status, + paidBy: options.paidBy, + from: options.from, + to: options.to, + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** Retrieve one event order with its guests and tickets. */ + retrieveEventOrder(eventKey: string, orderId: string): Promise { + return this.#http.get( + `/v1/events/${encodeURIComponent(eventKey)}/orders/${encodeURIComponent(orderId)}`, + ); + } + + /** Download one event's orders as CSV. Returns the CSV text unparsed. */ + exportEventOrdersCsv(eventKey: string, options: EventOrderExportOptions = {}): Promise { + return this.#http.getText(`/v1/events/${encodeURIComponent(eventKey)}/orders.csv`, { query: { + q: options.q, + status: options.status, + paidBy: options.paidBy, + from: options.from, + to: options.to, + } }); + } + + /** + * Refund an event order through its payment provider, in full or in part. + * Retried safely with one Idempotency-Key; a 202 means the refund is still pending. + */ + refundEventOrder( + eventKey: string, + orderId: string, + params?: RefundOrderParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/events/${encodeURIComponent(eventKey)}/orders/${encodeURIComponent(orderId)}/refund`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** Cancel an event order without moving money. Retried safely with one Idempotency-Key. */ + cancelEventOrder( + eventKey: string, + orderId: string, + params: CancelOrderParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/events/${encodeURIComponent(eventKey)}/orders/${encodeURIComponent(orderId)}/cancel`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** Email an event order's tickets again. Sent once: an unknown outcome is not retried. */ + resendEventOrderTickets( + eventKey: string, + orderId: string, + params?: ResendOrderTicketsParams, + ): Promise { + return this.#http.post( + `/v1/events/${encodeURIComponent(eventKey)}/orders/${encodeURIComponent(orderId)}/resend`, + { body: params }, + ); + } +} diff --git a/src/resources/seasons.ts b/src/resources/seasons.ts index 7733aec..a08db38 100644 --- a/src/resources/seasons.ts +++ b/src/resources/seasons.ts @@ -1,8 +1,12 @@ import type { HttpClient, RequestOptions } from '../http.js'; +import type { EventHostingRegion } from '../types.js'; +import type { CancelOrderParams, RefundOrderParams, ResendOrderTicketsParams } from './orders.js'; export type SeasonStructureState = 'draft' | 'active' | 'closing' | 'closed' | 'archived'; export type SeasonSalesState = 'closed' | 'open' | 'paused' | 'ended'; export type SeasonPublicationState = 'draft' | 'published' | 'superseded'; +/** `block`: season seats are on for the season. `per_hold`, the default: they are off. */ +export type SeasonInventoryMode = 'block' | 'per_hold'; export interface SeasonSourcePerformanceGroup { key: string; @@ -39,7 +43,17 @@ export interface Season { currency: string; venue: string | null; timezone: string | null; - audience: 'embed_only'; + /** `public` lists the season on your SeatLayer website. */ + audience: 'embed_only' | 'public'; + /** Who takes the money: `seatlayer` (SeatLayer checkout) or `server` (your own). Null until you choose. */ + checkoutMode: 'seatlayer' | 'server' | null; + /** + * Hours before a date starts that its tickets stop being sent to others. Null: the season has no + * cut-off of its own, so the club default (Settings › Ticket site) applies, or until the date starts. + */ + transferCutoffHours: number | null; + /** Hours before a date starts that a holder can no longer give a seat back on it. Null: the club default, as above. */ + returnCutoffHours: number | null; revision: number; structureState: SeasonStructureState; salesState: SeasonSalesState; @@ -49,12 +63,57 @@ export interface Season { activatedAt: number | null; closedAt: number | null; buyerRehearsalReady: boolean; + /** Each date also sells its own single tickets. False, the default, is Season only. */ + singleTickets: boolean; + /** With `singleTickets` on and the season on sale: the dates whose own sales are not open. */ + singleTicketsNotOpen?: Array<{ eventKey: string; name: string; salesState: string; [key: string]: unknown }>; + /** + * How the season's seats are decided: `block` once season seats are turned on for it + * (`inventoryMode: "block"` on create or update) or it sells from its block, else `per_hold`. + */ + inventoryMode: SeasonInventoryMode; } export interface SeasonDetail extends Season { plans: SeasonPlan[]; } +/** What opening or stopping the dates' single ticket sales did. */ +export interface SeasonSingleTicketDates { + direction: 'open' | 'close'; + /** Dates whose single sales this opened. */ + opened: string[]; + /** Dates whose single sales this stopped; orders already made stay. */ + closed: string[]; + /** Dates that could not open, with the sales readiness checks each lacks. */ + notOpened: Array<{ + eventKey: string; name: string; salesState: 'setup' | 'testable' | 'live' | 'paused' | 'ended'; + unmet: string[]; [key: string]: unknown; + }>; + /** A `season_singles` job doing them when the season has more than 12 upcoming dates. */ + jobId: string | null; + /** + * Why that job could not start, in the rare case it cannot. The change still stands, and the + * dates wait in `singleTicketsNotOpen` until you send `singleTickets: true` again. + */ + jobRefused?: string; + [key: string]: unknown; +} + +/** A season answer. A change that opened or stopped the dates' single sales says which in `singleTicketDates`. */ +export interface SeasonResult { + season: SeasonDetail; + singleTicketDates?: SeasonSingleTicketDates; +} + +/** What opening sales answers: the season and anything advised but not required. */ +export interface SeasonSalesOpenResult { + season: SeasonDetail; + warnings: Array<{ code: 'checkout_test_missing' | (string & {}); message: string; [key: string]: unknown }>; + /** With `singleTickets` on: which dates opened their own sales. */ + singleTicketDates?: SeasonSingleTicketDates; +} + export interface SeasonCompatibilityIssue { code: string; message: string; @@ -110,6 +169,15 @@ export interface SeasonSelectionParams { export interface CreateSeasonParams extends SeasonSelectionParams { name: string; edition?: string | null; + /** The season's mode, `live` or `test`; every date must be in the same mode. */ + mode?: 'live' | 'test'; + /** Each date also sells its own single tickets once the season is on sale. Default false: Season only. */ + singleTickets?: boolean; + /** + * `block` turns season seats on for this season. Default `per_hold`. Anything else answers + * `422 invalid_inventory_mode`. + */ + inventoryMode?: SeasonInventoryMode; } export interface CreateSeasonPlanParams extends SeasonSelectionParams { @@ -134,6 +202,8 @@ export interface SeasonHoldItem { categoryKey: string; tierId: string | null; quantity: number; + /** The price type the seat is held as: `adult` unless the hold named another. */ + priceTypeKey: string; } export interface SeasonHoldAllocation { @@ -175,6 +245,9 @@ export interface SeasonHolderImportRow { priorContractRef: string; labels: string[]; existingBookingRef?: string | null; + /** Where the holder's renewal emails go. */ + holderEmail?: string | null; + holderName?: string | null; } export interface SeasonHolderImport { @@ -211,11 +284,18 @@ export interface SeasonRenewalOffer { orderRef: string | null; bookingRef: string | null; successorContractId: string | null; + /** Why the holder declined, when a decline gave a reason. The first decline's reason stays. */ + declineReason: string | null; commitOutcomes: Array<{ eventKey: string; state: 'pending' | 'committed' | 'failed'; code: string | null; }>; + /** + * Each seat with the price type its renewal keeps (a child seat renews at the + * child price). Sent by `retrieveSeasonRenewalOffer` and `inspectSeasonRenewalOffer`. + */ + seats?: Array<{ label: string; priceTypeKey: string; [key: string]: unknown }>; createdAt: number; updatedAt: number; } @@ -226,6 +306,59 @@ export interface SeasonRenewalResult { retryAfterSeconds?: number; } +/** Why SeatLayer checkout cannot take a renewal now. */ +export type SeasonRenewalQuoteProblem = + | 'offer_not_open' | 'checkout_mode_not_seatlayer' | 'sales_ended' | 'plan_not_published' + | 'seats_changed' | 'prices_missing' | 'unsupported_currency' | 'gateway_not_connected' + | 'gateway_currency_mismatch'; + +/** What renewing an offer costs, priced as the SeatLayer checkout will charge it. */ +export interface SeasonRenewalQuote { + offerId: string; + state: string; + /** Null when the seats cannot be priced (`prices_missing`, `seats_changed`). */ + totalMinor: number | null; + currency: string; + /** One line per seat, for the whole package. */ + lines: Array<{ + label: string; category: string | null; priceTypeKey: string; amountMinor: number; + [key: string]: unknown; + }>; + performances: Array<{ eventKey: string; name: string; startsAt: number | null; [key: string]: unknown }>; + /** Null when SeatLayer checkout can take the renewal now; otherwise why not. */ + problem: SeasonRenewalQuoteProblem | null; + [key: string]: unknown; +} + +export interface SeasonRenewalCheckoutParams { + /** + * Where the buyer comes back to, on one of the organization's allowed domains. + * `seatlayer_checkout=success` or `cancelled` is added. + */ + returnUrl: string; + /** Where the tickets go when no email is on file for the holder; the one on file wins. */ + buyerEmail?: string; + buyerName?: string; + /** Which connected gateway to pay with, when the organization has more than one. */ + provider?: string; +} + +/** An order waiting for payment: send the buyer to `payment.url`; paying renews the offer. */ +export interface SeasonRenewalCheckoutStarted { + orderId: string; + totalMinor: number; + currency: string; + /** When the payment window closes; the offer is held for this order until then. */ + expiresAt: number; + payment: { url: string | null; [key: string]: unknown }; + [key: string]: unknown; +} + +export interface DeclineSeasonRenewalOfferParams { + /** Why the holder declined, 1 to 200 characters, kept on the offer as `declineReason`. */ + reason?: string; +} + export interface SeasonBuyerRehearsalResult { season: SeasonDetail; rehearsal: { @@ -240,9 +373,31 @@ export interface SeasonOccurrence { latestAmendment: { amendmentId: string; kind: string; classification: string } | null; } +/** Amendment kinds you can ask for with `createSeasonAmendment`. */ +export type SeasonAmendmentRequestKind = 'reschedule' | 'replace' | 'cancel_exception' | 'postpone' | 'cancel'; + +/** + * Every amendment kind a read can return: the requestable kinds plus the + * membership records a package's date changes write. + */ +export type SeasonAmendmentKind = SeasonAmendmentRequestKind + | 'performance_added' | 'performance_removed' | 'performance_past'; + +export interface CreateSeasonAmendmentParams { + eventKey: string; + kind: SeasonAmendmentRequestKind; + startsAt?: number; + name?: string; + /** With `cancel` only: refund the date's SeatLayer orders as a job. `none` (the default) refunds nothing. */ + /** Refund each holder for the cancelled date (`per_date`). A whole-order refund is the order refund. */ + refund?: 'none' | 'per_date'; + /** With a refund: kept on the job. */ + reason?: string; +} + export interface SeasonAmendment { amendmentId: string; revision: number; eventKey: string; - kind: 'reschedule' | 'replace' | 'cancel_exception'; + kind: SeasonAmendmentKind; classification: 'identity_preserving' | 'exception'; planActivationId: string | null; occurrenceSetSha256: string; before: Record; after: Record; @@ -291,6 +446,575 @@ export interface SeasonAuditEntry { id: number; action: string; actorKind: string; details: unknown; at: number; } +export interface SeasonHolderImportFromSeason { + sourceSeasonId: string; + successorPlanActivationId: string; + dryRun: boolean; + holders: number; + acceptedCount: number; + alreadyImported: number; + rejectedCount: number; + withEmail: number; + hosted: number; + integrator: number; + excluded: { + refunded: number; rehearsal: number; tooManySeats: number; unusableRef: number; + [key: string]: unknown; + }; + importIds: string[]; + rejected: Array<{ holderRef: string; labels: string[]; errorCode: string | null; [key: string]: unknown }>; + preview: Array<{ + holderRef: string; holderName: string | null; hasEmail: boolean; labels: string[]; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +/** A package (Plan) as the performance views show it. */ +export interface SeasonPackageSummary { + planKey: string; + name: string | null; + published: boolean; + performanceCount: number; + [key: string]: unknown; +} + +export interface SeasonPerformance { + eventKey: string; + name: string; + startsAt: number | null; + endsAt: number | null; + timezone: string | null; + venue: string | null; + status: string; + salesState: string; + mode: 'live' | 'test'; + past: boolean; + packages: Array<{ planKey: string; name: string | null; published: boolean; [key: string]: unknown }>; + [key: string]: unknown; +} + +export interface SeasonPerformanceList { + seasonKey: string; + performances: SeasonPerformance[]; + packages: SeasonPackageSummary[]; + [key: string]: unknown; +} + +export interface SeasonPerformanceCreateResult extends SeasonPerformanceList { + created: Array<{ eventKey: string; name: string; startsAt: number; [key: string]: unknown }>; +} + +export interface CreateSeasonPerformancesParams { + /** Title for each performance; the API fills in the date. */ + titlePattern: string; + firstStartsAt?: number; + timezone: string; + repeat: 'none' | 'daily' | 'weekly' | 'biweekly' | 'dates'; + count?: number; + /** Start times in epoch milliseconds, for `repeat: 'dates'`. */ + dates?: number[]; + venue?: string | null; + durationMinutes?: number | null; + region?: EventHostingRegion; + attachToPlanKeys?: string[]; +} + +export interface SeasonPackageResult extends SeasonPerformanceList { + package: SeasonPackageSummary; +} + +export interface SeasonPublishResult { + season: { + key: string; name: string; structureState: SeasonStructureState; salesState: SeasonSalesState; + revision: number; [key: string]: unknown; + }; + packages: Array<{ + planKey: string; name: string; published: true; activationId: string | null; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +export interface SeasonCheckoutTest { + id: string; + status: 'passed' | 'failed'; + startedAt: number; + finishedAt: number; + planKey: string; + seats: string[]; + steps: Array<{ + step: 'session' | 'availability' | 'hold' | 'release'; + ok: boolean; durationMs: number; message?: string; detail?: string; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +/** The kinds of seat a package can price a category for. A price without one is `adult`. */ +export type SeasonPriceTypeKey = 'adult' | 'child' | 'concession' | 'senior' | 'student'; + +export interface SeasonPackagePrice { + categoryKey: string; + priceTypeKey: SeasonPriceTypeKey; + /** The type's display name, such as `Child`. */ + priceTypeLabel: string; + /** Minor units. */ + unitPrice: number; + [key: string]: unknown; +} + +/** One row of a `replaceSeasonPackagePrices` table. */ +export interface SeasonPackagePriceInput { + categoryKey: string; + /** Leave out for `adult`. Each category takes each type once. */ + priceTypeKey?: SeasonPriceTypeKey; + /** Minor units. */ + unitPrice: number; +} + +export interface SeasonPackagePrices { + planKey: string; + currency: string; + prices: SeasonPackagePrice[]; + [key: string]: unknown; +} + +export interface SeasonPriceList { + seasonKey: string; + currency: string; + packages: Array<{ planKey: string; name: string | null; prices: SeasonPackagePrice[]; [key: string]: unknown }>; + [key: string]: unknown; +} + +/** Background work started when a date is added to or removed from a published package. */ +export interface SeasonDateJob { + jobId: string; + kind: 'date_added' | 'date_removed'; + state: 'planning' | 'running' | 'completed' | 'failed' | 'cancelled'; + total: number; + done: number; + skipped: number; + failed: number; + pending: number; + lastError: string | null; + createdAt: number; + updatedAt: number; + completedAt: number | null; + [key: string]: unknown; +} + +export type SeasonBulkJobKind = + | 'date_added' | 'date_removed' | 'renewal_run' | 'lapsed_release' | 'bulk_refund' | 'holder_import' + | 'series_dates' | 'series_update' | 'season_singles' | 'date_cancel_void' + // Season seats: read by id with `retrieveSeasonBulkJob`, not listed by `listSeasonBulkJobs`. + | 'season_block_set_aside' | 'season_block_release'; + +/** Seats kept for single tickets, or released: sections, categories or seats. */ +export interface SeasonSeatSelection { + sectionIds?: string[]; + categoryKeys?: string[]; + labels?: string[]; +} + +export interface SetAsideSeasonBlockParams { + /** The season's `revision` you read; another answers `409 revision_mismatch`. */ + expectedRevision: number; + /** Left out of the block: kept for single tickets. The block is every other seat the packages price. */ + keepForSingleTickets?: SeasonSeatSelection; +} + +/** What a release covers: every unsold seat, or the sections, categories or seats named. */ +export type SeasonBlockReleaseScope = + | { all: true } + | { sectionIds: string[] } + | { categoryKeys: string[] } + | { labels: string[] }; + +export interface ReleaseSeasonBlockParams { + expectedRevision: number; + scope: SeasonBlockReleaseScope; + /** Release on these dates only. Left out: every date still to come. */ + eventKeys?: string[]; + /** An ISO time to release at. Left out: now. */ + at?: string; + reason?: string; +} + +export interface SeasonBlockCounts { + setAside: number; + free: number; + /** In a cart, plus held for a renewal. */ + held: number; + booked: number; + released: number; + [key: string]: unknown; +} + +/** A season's seats set aside on every date (`retrieveSeasonBlock`). */ +export interface SeasonBlock { + /** How the season sells now: `block` once its seats are set aside and on. */ + mode: 'block' | 'per_hold' | (string & {}); + /** The set-aside. A draft's block is `ready` before Publish turns it on. */ + state: 'none' | 'setting_aside' | 'ready' | 'failed' | (string & {}); + revision: number; + counts: SeasonBlockCounts; + /** Seats a package prices that weren't free on every date, so stay single tickets. */ + notFreeOnEveryDate: { + count: number; + sample: Array<{ eventKey: string; label: string; reason: string }>; + }; + /** Every unsold seat was released: a seat that comes back goes to single sale, not the block. */ + windowEnded: boolean; + scheduledRelease: { at: string; jobId: string } | null; + dates: Array<{ + eventKey: string; + state: 'up_to_date' | 'catching_up' | 'failed' | (string & {}); + behind: number; + lastAppliedAt: string | null; + }>; + [key: string]: unknown; +} + +/** + * A season-seats season's date still leaving a shard before a cancel (`createSeasonAmendment`) + * or a past date (`markSeasonPerformancePast`) runs: `202`. It finishes by itself; send the same + * request again after `Retry-After` to read how it ended. The methods keep their result types, so + * check with `isSeasonDateLeavePending` first. + */ +export interface SeasonDateLeavePending { + state: 'finishing'; + code: 'block_date_leave_pending'; + retryable: true; + eventKey: string; + shardsLeft: number[]; + shardCount: number; + message: string; + [key: string]: unknown; +} + +/** True for the `202` a season-seats season's date cancel or past date answers while it finishes. */ +export function isSeasonDateLeavePending(value: unknown): value is SeasonDateLeavePending { + return !!value && typeof value === 'object' + && (value as { state?: unknown }).state === 'finishing' + && (value as { code?: unknown }).code === 'block_date_leave_pending'; +} + +/** The 202 of `setAsideSeasonBlock` and `releaseSeasonBlock`: the job to follow, and the block now. */ +export interface SeasonBlockJobAnswer { + job: SeasonBulkJob; + block: SeasonBlock; +} + +/** + * The job voiding a cancelled date's tickets: `sbj_void_` and the cancel's `amendmentId`. + * Cancelling a date starts it (a repeated cancel joins it); follow it with + * `retrieveSeasonBulkJob`. Each season ticket it voids is told by `season.ticket.voided` with + * reason `date_cancelled`. + */ +export function seasonDateCancelVoidJobId(amendmentId: string): string { + return `sbj_void_${amendmentId}`; +} + +/** + * The most rows one `createSeasonHolderImport` call takes for a package of `dates` dates. Each + * row is fenced on every date: 100 up to 20 dates, then `floor(2300 / (dates + 3))`, 43 at 50. + * A call with more answers `422 invalid_rows` with `maxRows`. + */ +export function seasonHolderImportRowsPerCall(dates: number): number { + return Math.max(1, Math.min(100, Math.floor(2300 / (Math.max(1, dates) + 3)))); +} + +export type SeasonBulkJobState = 'planning' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface SeasonBulkJobCounts { + total: number; + done: number; + skipped: number; + failed: number; + [key: string]: unknown; +} + +/** Background work on a Season, such as the refund of a cancelled date. */ +export interface SeasonBulkJob { + jobId: string; + kind: SeasonBulkJobKind; + state: SeasonBulkJobState; + counts: SeasonBulkJobCounts; + createdAt: number; + updatedAt: number; + completedAt: number | null; + [key: string]: unknown; +} + +export interface SeasonBulkJobDetail extends SeasonBulkJob { + /** + * What the job ended with, empty until it completes. `holder_import`: the same counts as a + * bring-across of 100 holders or fewer. `renewal_run`: `batchId`, `offered`, `skipped`, `failed`. + */ + summary: Record; + /** At most 500. For a refund, `key` is the order id and `error` the provider's reason. */ + failedItems: Array<{ key: string; error: string | null; attempts: number; [key: string]: unknown }>; +} + +/** The 202 answer of `createSeasonRenewalOffers` above 100 contracts: a `renewal_run` job makes them. */ +export interface SeasonRenewalOfferJob { + job: SeasonBulkJob; + /** The batch the job is making offers for. Read them with `listSeasonRenewalOffers` once it completes. */ + batchId: string; +} + +/** One page of a renewal offer list. Pass `cursor` from the previous page's `nextCursor`. */ +export interface SeasonRenewalOfferPageOptions { + cursor?: string; + /** + * Offers a page: 1-100 for `listSeasonRenewalOffers` (default 100), 1-500 for + * `retrieveSeasonHostedRenewals` (default 500). + */ + limit?: number; +} + +/** The refund job a `cancel` amendment started when it asked for a refund. */ +export interface SeasonRefundJobSummary { + jobId: string; + kind: 'bulk_refund'; + state: string; + counts: SeasonBulkJobCounts; + [key: string]: unknown; +} + +export interface RefundSeasonPerformanceParams { + /** `per_date`: the date's share of each order (rounded down). `full`: what is left of each order. */ + /** What is refunded: each holder's share for this date. `full` is refused (`422 date_refund_per_date_only`); refund a whole order with the order refund. */ + amount: 'per_date'; + /** Kept on the job. */ + reason?: string; +} + +export interface UpdateSeasonParams { + expectedRevision: number; + name?: string; + edition?: string | null; + /** Who takes the money. It cannot go back to undecided, and it is fixed once a live booking exists. */ + checkoutMode?: 'seatlayer' | 'server'; + /** Hours before a date starts that its tickets stop being sent to others (0 to 720); `null` uses the club default. */ + transferCutoffHours?: number | null; + /** Hours before a date starts that a holder can no longer give a seat back (0 to 720); `null` uses the club default. */ + returnCutoffHours?: number | null; + /** `public` lists the season on your SeatLayer website; an ended season cannot become public (`409 sales_ended`). */ + audience?: 'embed_only' | 'public'; + /** Turned on while on sale, each upcoming date's own sales open through its readiness checks; off, they stop. */ + singleTickets?: boolean; + /** + * `block` turns season seats on for this season; `per_hold` turns them off again, refused with + * `409 season_block_exists` once seats are set aside. It does not change `revision`. Refused with + * `409 season_block_packages` (more than one package), `409 season_block_on_sale` (sales already + * opened without season seats), `409 season_not_draft_or_active`, or `422 invalid_inventory_mode`. + */ + inventoryMode?: SeasonInventoryMode; +} + +export interface SeasonStoppedOrder { + orderId: string | null; + bookingRef: string; + holderRef: string | null; + reason: string; + [key: string]: unknown; +} + +export interface SeasonPlanPerformanceAttachResult extends SeasonPerformanceList { + extended: number; + conflicts: Array<{ + label: string; bookingRef: string; holderRef?: string; + reason: 'seat_taken' | 'seat_blocked' | 'seat_missing'; + [key: string]: unknown; + }>; + ticketsIssued: number; + stopped: SeasonStoppedOrder[]; + stoppedTruncated?: boolean; + job: SeasonDateJob | null; +} + +export interface SeasonPlanPerformanceRemoveResult extends SeasonPerformanceList { + released: number; + stopped: SeasonStoppedOrder[]; + stoppedTruncated?: boolean; + job: SeasonDateJob | null; +} + +/** The booking or renewal offer the returned seats came from. */ +export type SeasonOccurrenceInventorySource = { bookingActionId: string } | { renewalOfferId: string }; + +export interface SeasonOccurrenceInventoryAction { + actionId: string; + returnActionId: string; + eventKey: string; + planActivationId: string; + state: 'returned' | 'reclaimed'; + labels: string[]; + [key: string]: unknown; +} + +export interface SeasonHolder { + id: string; + kind: 'hosted' | 'integrator' | 'imported'; + bookingRef: string | null; + orderId: string | null; + orderNumber: string | null; + holderName: string | null; + holderEmail: string | null; + holderRef: string | null; + labels: string[]; + packageName: string | null; + packageId: string | null; + dates: Array<{ eventKey: string; state: 'active' | 'cancelled' | 'problem'; [key: string]: unknown }>; + datesTotal: number; + datesActive: number; + datesCancelled: number; + paid: { amountMinor: number; currency: string; refundedMinor: number; [key: string]: unknown } | null; + soldAtCounter: boolean; + orderStatus: string | null; + status: 'active' | 'partly_cancelled' | 'cancelled' | 'needs_review' + | 'reserved' | 'renewal_open' | 'renewed' | 'declined' | 'lapsed'; + renewal: { offerId: string; state: string; deadlineAt: number | null; [key: string]: unknown } | null; + boughtAt: number | null; + [key: string]: unknown; +} + +export interface SeasonHolderPage { + holders: SeasonHolder[]; + nextCursor: string | null; + totals?: { holders: number; active: number; cancelled: number; imported: number; [key: string]: unknown }; + [key: string]: unknown; +} + +export interface SeasonHolderFilter { + /** Search by holder name, email, reference, order number or seat. */ + q?: string; + status?: 'active' | 'cancelled' | 'renewal'; +} + +export interface SeasonHolderListOptions extends SeasonHolderFilter { + cursor?: string; + /** Page size. Defaults to 50 and is capped at 100. */ + limit?: number; +} + +export interface SeasonOrderRefund { + orderId: string; + refundId: string | null; + amountMinor: number | null; + currency: string; + state: 'succeeded' | 'pending' | 'unknown' | 'cancelled'; + [key: string]: unknown; +} + +export interface SeasonOrderCancelReceipt { + orderId: string; + displayRef: string; + status: 'refunded'; + currency: string; + settledOutsideMinor: number; + released: string[]; + seatsReleased: boolean; + emailQueued: boolean; + cancelledAt: number; + [key: string]: unknown; +} + +export interface SeasonOrderDateCancel { + eventKey: string; + ticketsVoided: number; + labels: string[]; + state: 'returned' | 'pending'; + [key: string]: unknown; +} + +export interface SeasonHostedRenewals { + checkoutMode: 'seatlayer' | 'server' | null; + transferCutoffHours?: number | null; + readiness: { + ready: boolean; + problem: string | null; + /** With `prices_missing`: the seat types holders renew as that their next package does not price. */ + missingPrices?: Array<{ packageId: string; categoryKey: string; priceTypeKey: string; [key: string]: unknown }>; + [key: string]: unknown; + }; + holdersWithoutEmail: number; + /** Pass back as `cursor` for the next page of offers; null on the last. */ + nextCursor: string | null; + offers: Array<{ + offerId: string; + holderName: string | null; + holderEmail: string | null; + linkActiveSince: number | null; + paymentActive: boolean; + notices: { + invitedAt: number | null; lastReminderAt: number | null; nextNoticeAt: number | null; + nextNoticeKind: string | null; problem: string | null; [key: string]: unknown; + }; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +export interface SeasonSeatCounts { + sold: number; + byPackage: Array<{ + packageId: string | null; packageName: string | null; seats: number; holders: number; + [key: string]: unknown; + }>; + byDate: Array<{ + eventKey: string; name: string | null; startsAt: number | null; timezone: string | null; seats: number; + byPackage: Array<{ packageId: string | null; seats: number; [key: string]: unknown }>; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +export interface SeasonSalesReport { + season: { + key: string; name: string; currency: string; mode: 'live' | 'test'; + checkoutMode: string | null; timezone: string | null; [key: string]: unknown; + }; + generatedAt: number; + ownCheckout: boolean; + money: { + rows: Array<{ + packageId: string | null; packageName: string | null; channel: 'online' | 'box_office'; + currency: string; orders: number; refundedOrders: number; seats: number; grossMinor: number; + discountMinor: number; refundedMinor: number; netMinor: number; [key: string]: unknown; + }>; + [key: string]: unknown; + } | null; + seats: SeasonSeatCounts | null; + holders: { + total: number; active: number; cancelled: number; imported: number; importedRenewed: number; + soldThroughSeatLayer: number; soldThroughYourCheckout: number; + byStatus: { all: number; active: number; cancelled: number; renewal: number; [key: string]: unknown }; + [key: string]: unknown; + } | null; + renewals: { + offered: number; renewed: number; declined: number; lapsed: number; open: number; + needsReview: number; rate: number | null; [key: string]: unknown; + } | null; + downloadLimit: number; + /** Sections the report could not compute this time; each is null above. */ + unavailable: Array<'seats' | 'holders' | 'renewals'>; + [key: string]: unknown; +} + +export interface SeasonSeatsReport { + packages: Array<{ + packageId: string | null; packageName: string; datesLeft: number; + state: 'ok' | 'no_dates_left' | 'chart_unavailable' | 'dates_differ'; + freeEveryDate: number | null; notEveryDate: number | null; renewalHeld: number | null; + [key: string]: unknown; + }>; + dates: Array<{ eventKey: string; free: number; [key: string]: unknown }>; + [key: string]: unknown; +} + export interface WaitForSeasonLifecycleOptions { /** Maximum elapsed wait. Default 30 seconds. */ timeoutMs?: number; @@ -379,9 +1103,9 @@ export class Seasons { updateSeason( seasonKey: string, - params: { expectedRevision: number; name?: string; edition?: string | null }, + params: UpdateSeasonParams, options: { idempotencyKey?: string; signal?: AbortSignal } = {}, - ): Promise<{ season: SeasonDetail }> { + ): Promise { return this.#http.mutationWithHeaderReplay('PATCH', `/v1/seasons/${pathPart(seasonKey)}`, { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, }); @@ -452,19 +1176,27 @@ export class Seasons { ); } - openSeasonSales(seasonKey: string, expectedRevision: number): Promise<{ season: SeasonDetail }> { - return this.#sales(seasonKey, 'open', expectedRevision); + /** + * Open sales. `warnings` says what is advised but not required, such as + * `checkout_test_missing` until a checkout test or rehearsal has passed. With + * `singleTickets` on, `singleTicketDates` says which dates opened their own sales. + * With season seats turned on (`inventoryMode: 'block'`) but the seats not yet set aside: + * `409 block_not_ready`. + */ + openSeasonSales(seasonKey: string, expectedRevision: number): Promise { + return this.#sales(seasonKey, 'open', expectedRevision) as Promise; } - pauseSeasonSales(seasonKey: string, expectedRevision: number): Promise<{ season: SeasonDetail }> { + pauseSeasonSales(seasonKey: string, expectedRevision: number): Promise { return this.#sales(seasonKey, 'pause', expectedRevision); } - resumeSeasonSales(seasonKey: string, expectedRevision: number): Promise<{ season: SeasonDetail }> { + /** Resume sales. With `singleTickets` on, `singleTicketDates` says which dates opened their own sales. */ + resumeSeasonSales(seasonKey: string, expectedRevision: number): Promise { return this.#sales(seasonKey, 'resume', expectedRevision); } - endSeasonSales(seasonKey: string, expectedRevision: number): Promise<{ season: SeasonDetail }> { + endSeasonSales(seasonKey: string, expectedRevision: number): Promise { return this.#sales(seasonKey, 'end', expectedRevision); } @@ -528,6 +1260,11 @@ export class Seasons { return this.#http.post(`/v1/seasons/${pathPart(seasonKey)}/buyer-rehearsals/validate`); } + /** + * Import incumbent holders, as a dry run or for real. A call takes at most + * `seasonHolderImportRowsPerCall(dates)` rows (100 up to 20 dates); more answers + * `422 invalid_rows` with `maxRows` on the error's `body`. + */ createSeasonHolderImport( seasonKey: string, params: { successorPlanActivationId: string; dryRun?: boolean; rows: SeasonHolderImportRow[] }, @@ -551,18 +1288,30 @@ export class Seasons { return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/imports/${pathPart(importId)}`); } + /** + * Make one renewal offer per eligible prior Contract. 100 contracts or fewer answer + * `{ offers }`; more answer 202 `{ job, batchId }`: a `renewal_run` job makes the offers. + * Follow it with `retrieveSeasonBulkJob`, then page them with `listSeasonRenewalOffers`. + * Retried safely with one Idempotency-Key, which answers the same offers or job. + */ createSeasonRenewalOffers( seasonKey: string, params: { successorPlanActivationId?: string; deadlineAt: number; contractIds?: string[] }, options: { idempotencyKey?: string; signal?: AbortSignal } = {}, - ): Promise<{ offers: SeasonRenewalOffer[] }> { + ): Promise<{ offers: SeasonRenewalOffer[] } | SeasonRenewalOfferJob> { return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers`, { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, }); } - listSeasonRenewalOffers(seasonKey: string): Promise<{ offers: SeasonRenewalOffer[]; truncated: boolean }> { - return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers`); + /** One page of renewal offers, newest first. Pass `cursor` from the previous page's `nextCursor`. */ + listSeasonRenewalOffers(seasonKey: string, options: SeasonRenewalOfferPageOptions = {}): Promise<{ + offers: SeasonRenewalOffer[]; truncated: boolean; nextCursor: string | null; + }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers`, { query: { + cursor: options.cursor, + limit: options.limit, + } }); } retrieveSeasonRenewalOffer(seasonKey: string, offerId: string): Promise { @@ -589,9 +1338,14 @@ export class Seasons { ); } - declineSeasonRenewalOffer(seasonKey: string, offerId: string): Promise { + /** Decline an offer and release its seats at once, optionally with the holder's reason. */ + declineSeasonRenewalOffer( + seasonKey: string, + offerId: string, + params: DeclineSeasonRenewalOfferParams = {}, + ): Promise { return this.#renewal( - `/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/decline`, {}, + `/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/decline`, params, ); } @@ -605,14 +1359,21 @@ export class Seasons { return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/occurrences`); } + /** + * Postpone, reschedule, replace or cancel one date. A `cancel` also voids every live ticket for + * the date as a `date_cancel_void` job, `seasonDateCancelVoidJobId(amendment.amendmentId)`; + * with a `refund`, `refundJob` names the refund job. A cancelled date can't be moved + * (`reschedule`), postponed or replaced: `409 performance_cancelled`. + * + * On a season-seats season, a `cancel` first takes the date off every season seat: while that + * finishes the answer is a `202` `SeasonDateLeavePending` (check with `isSeasonDateLeavePending`); + * send the same request again to read the amendment. + */ createSeasonAmendment( seasonKey: string, - params: { - eventKey: string; kind: 'reschedule' | 'replace' | 'cancel_exception'; - startsAt?: number; name?: string; - }, + params: CreateSeasonAmendmentParams, options: { idempotencyKey?: string; signal?: AbortSignal } = {}, - ): Promise<{ amendment: SeasonAmendment }> { + ): Promise<{ amendment: SeasonAmendment; refundJob?: SeasonRefundJobSummary }> { return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/amendments`, { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, }); @@ -665,6 +1426,482 @@ export class Seasons { return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/export`); } + /** + * Dry-run or commit renewal rights derived from a previous season's holders. + * A commit of more than 100 holders answers 202 `{ job }`: a `holder_import` job brings them + * across, and its `summary` has the same counts when it completes. + * Retried safely with one Idempotency-Key, which answers the same import or job. + */ + createSeasonHolderImportFromSeason( + seasonKey: string, + params: { sourceSeasonId: string; successorPlanActivationId: string; dryRun?: boolean }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ import: SeasonHolderImportFromSeason } | { job: SeasonBulkJob }> { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/imports/from-season`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** List a Season's performances and package membership, in every lifecycle state. */ + listSeasonPerformances(seasonKey: string): Promise { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/performances`); + } + + /** Create performances inside a draft Season from a repeat rule. Retried safely with one Idempotency-Key. */ + createSeasonPerformances( + seasonKey: string, + params: CreateSeasonPerformancesParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/performances`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** Attach existing Events to a draft Season and its packages. Retried safely with one Idempotency-Key. */ + attachSeasonPerformances( + seasonKey: string, + params: { eventKeys: string[]; attachToPlanKeys?: string[] }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/performances/attach`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** Remove a performance from a draft Season and every package. Retried safely with one Idempotency-Key. */ + deleteSeasonPerformance( + seasonKey: string, + eventKey: string, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay( + 'DELETE', + `/v1/seasons/${pathPart(seasonKey)}/performances/${pathPart(eventKey)}`, + { idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** Rename a draft package or change the performances it covers. Retried safely with one Idempotency-Key. */ + updateSeasonPlan( + seasonKey: string, + planKey: string, + params: { name?: string; eventKeys?: string[] }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay( + 'PATCH', + `/v1/seasons/${pathPart(seasonKey)}/plans/${pathPart(planKey)}`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Delete a draft package that is not the Season's last one. Resolves with + * nothing (204). Retried safely with one Idempotency-Key. + */ + deleteSeasonPlan( + seasonKey: string, + planKey: string, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay( + 'DELETE', + `/v1/seasons/${pathPart(seasonKey)}/plans/${pathPart(planKey)}`, + { idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Activate the Season structure and publish every draft package in one call. + * Retried safely with one Idempotency-Key. + */ + publishSeason( + seasonKey: string, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/publish`, { + idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** + * Run a server-side hold and release to prove the Season buyer path. + * Retried safely with one Idempotency-Key. + */ + createSeasonCheckoutTest( + seasonKey: string, + params?: { planKey?: string; quantity?: number }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ test: SeasonCheckoutTest }> { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/checkout-tests`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** List recent Season checkout tests, newest first. */ + listSeasonCheckoutTests(seasonKey: string): Promise<{ tests: SeasonCheckoutTest[] }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/checkout-tests`); + } + + /** + * Replace a package's price table, per chart category, in minor units. + * Retried safely with one Idempotency-Key. + */ + replaceSeasonPackagePrices( + seasonKey: string, + planKey: string, + params: { prices: SeasonPackagePriceInput[]; currency?: string }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay( + 'PUT', + `/v1/seasons/${pathPart(seasonKey)}/plans/${pathPart(planKey)}/prices`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** List every package price table for a Season. */ + listSeasonPrices(seasonKey: string): Promise { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/prices`); + } + + /** + * Add a performance to a published package and extend its holders' allocations. + * Retried safely with one Idempotency-Key; a 202 carries the background `job`. + */ + attachSeasonPlanPerformance( + seasonKey: string, + planKey: string, + params: { eventKey: string }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/plans/${pathPart(planKey)}/performances`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Remove a performance from a published package and release its allocations. + * Retried safely with one Idempotency-Key; a 202 carries the background `job`. + */ + deleteSeasonPlanPerformance( + seasonKey: string, + planKey: string, + eventKey: string, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.mutationWithHeaderReplay( + 'DELETE', + `/v1/seasons/${pathPart(seasonKey)}/plans/${pathPart(planKey)}/performances/${pathPart(eventKey)}`, + { idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Mark a performance past so it stops blocking new Season sales. + * Retried safely with one Idempotency-Key. A cancelled date answers `409 performance_cancelled`. + * + * On a season-seats season the date first leaves every season seat: while that finishes the + * answer is a `202` `SeasonDateLeavePending` (check with `isSeasonDateLeavePending`). + */ + markSeasonPerformancePast( + seasonKey: string, + eventKey: string, + params: { force?: boolean }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/performances/${pathPart(eventKey)}/past`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Return one occurrence's Season allocation to ordinary Event inventory. + * Single-attempt: `actionId` is the domain identity to reuse after an unknown outcome. + */ + returnSeasonOccurrenceInventory(seasonKey: string, eventKey: string, params: { + actionId: string; bookingRef: string; planActivationId: string; labels: string[]; + source: SeasonOccurrenceInventorySource; + }): Promise<{ occurrenceInventoryAction: SeasonOccurrenceInventoryAction }> { + return this.#http.post( + `/v1/seasons/${pathPart(seasonKey)}/occurrences/${pathPart(eventKey)}/returns`, { body: params }, + ); + } + + /** + * Reclaim returned occurrence inventory while it is still free. + * Single-attempt: `actionId` is the domain identity to reuse after an unknown outcome. + */ + reclaimSeasonOccurrenceInventory(seasonKey: string, eventKey: string, params: { + actionId: string; returnActionId: string; bookingRef: string; planActivationId: string; + labels: string[]; source: SeasonOccurrenceInventorySource; + }): Promise<{ occurrenceInventoryAction: SeasonOccurrenceInventoryAction }> { + return this.#http.post( + `/v1/seasons/${pathPart(seasonKey)}/occurrences/${pathPart(eventKey)}/reclaims`, { body: params }, + ); + } + + /** One page of season ticket holders. Pass `cursor` from the previous page's `nextCursor`. */ + listSeasonHolders(seasonKey: string, options: SeasonHolderListOptions = {}): Promise { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/holders`, { query: { + q: options.q, + status: options.status, + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** + * Refund a season order in full or in part through its payment provider. + * Retried safely with one Idempotency-Key; a 202 means the refund is still pending. + */ + refundSeasonOrder( + seasonKey: string, + orderId: string, + params?: RefundOrderParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/orders/${pathPart(orderId)}/refund`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Cancel a season order without moving money, and release every date. + * Retried safely with one Idempotency-Key. + */ + cancelSeasonOrder( + seasonKey: string, + orderId: string, + params: CancelOrderParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ receipt: SeasonOrderCancelReceipt }> { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/orders/${pathPart(orderId)}/cancel`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Take back one date of a season order and put its seats back on sale. + * Retried safely with one Idempotency-Key; a 202 means the return is still pending. + */ + cancelSeasonOrderDate( + seasonKey: string, + orderId: string, + eventKey: string, + params?: { actionId?: string }, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ dateCancel: SeasonOrderDateCancel }> { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/orders/${pathPart(orderId)}/dates/${pathPart(eventKey)}/cancel`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** Email a season order's tickets again. Sent once: an unknown outcome is not retried. */ + resendSeasonOrderTickets( + seasonKey: string, + orderId: string, + params?: ResendOrderTicketsParams, + ): Promise<{ sentTo: string; [key: string]: unknown }> { + return this.#http.post( + `/v1/seasons/${pathPart(seasonKey)}/orders/${pathPart(orderId)}/resend`, { body: params }, + ); + } + + /** + * Retrieve hosted renewal links, payments and emails for one page of offers, newest first. + * Pass `cursor` from the previous page's `nextCursor`. + */ + retrieveSeasonHostedRenewals( + seasonKey: string, + options: SeasonRenewalOfferPageOptions = {}, + ): Promise { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/renewal-hosted`, { query: { + cursor: options.cursor, + limit: options.limit, + } }); + } + + /** + * Make a new renewal link for one holder; the old link stops working. The + * URL is shown once and the call is never retried, so a lost response means + * rotating again. + */ + rotateSeasonRenewalOfferLink(seasonKey: string, offerId: string): Promise<{ url: string; [key: string]: unknown }> { + return this.#http.post(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/link`); + } + + /** Set the name and email a renewal offer is sent to. Pass `null` to clear one. */ + updateSeasonRenewalOfferContact(seasonKey: string, offerId: string, params: { + email?: string | null; name?: string | null; + }): Promise<{ contact: { name: string | null; email: string | null; [key: string]: unknown } }> { + return this.#http.post( + `/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/contact`, { body: params }, + ); + } + + /** + * Email renewal invitations now and schedule reminders before the deadline. Leave `offerIds` + * out to email every open offer; more than 20,000 ids answers `422 too_many_offer_ids`. + */ + createSeasonRenewalNotices(seasonKey: string, params?: { + offerIds?: string[]; + /** Instead of `offerIds`: every open offer one `createSeasonRenewalOffers` batch made (its `batchId`). */ + batchId?: string; + }): Promise<{ + scheduled: number; withoutEmail: number; [key: string]: unknown; + }> { + return this.#http.post(`/v1/seasons/${pathPart(seasonKey)}/renewal-notices`, { body: params }); + } + + /** Send one holder a new renewal invitation. Sent once: an unknown outcome is not retried. */ + resendSeasonRenewalOffer(seasonKey: string, offerId: string): Promise<{ ok: true; [key: string]: unknown }> { + return this.#http.post(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/resend`); + } + + /** What renewing an offer costs, and whether SeatLayer checkout can take it now (`problem` is null). */ + retrieveSeasonRenewalQuote(seasonKey: string, offerId: string): Promise { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/quote`); + } + + /** + * Record that the holder means to renew, for a renewal taken by phone or at the + * box office, so your own checkout can commit the offer. A repeat answers the same offer. + */ + createSeasonRenewalIntent(seasonKey: string, offerId: string): Promise { + return this.#renewal(`/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/intent`); + } + + /** + * Start a SeatLayer checkout that renews an offer, with no hosted page. Send the + * buyer to `payment.url`. Retried safely with one Idempotency-Key, which answers the same order. + */ + createSeasonRenewalCheckout( + seasonKey: string, + offerId: string, + params: SeasonRenewalCheckoutParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/renewal-offers/${pathPart(offerId)}/checkout`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** Retrieve Season money, seats, holders and renewals as totals. */ + retrieveSeasonSalesReport(seasonKey: string): Promise<{ report: SeasonSalesReport }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/reports/sales`); + } + + /** Retrieve seats still free per package and per date. */ + retrieveSeasonSeatsReport(seasonKey: string): Promise<{ seats: SeasonSeatsReport }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/reports/seats`); + } + + /** Download every season ticket holder as CSV. Returns the CSV text unparsed. */ + exportSeasonHoldersCsv(seasonKey: string, options: SeasonHolderFilter = {}): Promise { + return this.#http.getText(`/v1/seasons/${pathPart(seasonKey)}/reports/holders.csv`, { query: { + q: options.q, + status: options.status, + } }); + } + + /** Download every season order SeatLayer took as CSV. Returns the CSV text unparsed. */ + exportSeasonOrdersCsv(seasonKey: string): Promise { + return this.#http.getText(`/v1/seasons/${pathPart(seasonKey)}/reports/orders.csv`); + } + + /** Retrieve Season seats sold per package and per date. */ + retrieveSeasonSeatCounts(seasonKey: string): Promise<{ seats: SeasonSeatCounts | null }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/seat-counts`); + } + + /** + * Refund every SeatLayer order holding a cancelled date, as a job. Follow it with + * `retrieveSeasonBulkJob`. Retried safely with one Idempotency-Key, which replays the same job. + */ + refundSeasonPerformance( + seasonKey: string, + eventKey: string, + params: RefundSeasonPerformanceParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ job: SeasonBulkJob }> { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/performances/${pathPart(eventKey)}/refunds`, + { body: params, idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** + * Release the seats of every lapsed renewal offer, as a job. An offer extended before its + * turn is skipped and keeps its seat. Follow it with `retrieveSeasonBulkJob`. Retried safely + * with one Idempotency-Key, which replays the same job. + */ + releaseSeasonLapsedOffers( + seasonKey: string, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise<{ job: SeasonBulkJob }> { + return this.#http.postWithHeaderReplay( + `/v1/seasons/${pathPart(seasonKey)}/renewal-offers/release-lapsed`, + { idempotencyKey: options.idempotencyKey, signal: options.signal }, + ); + } + + /** List a Season's bulk jobs, newest first (100 at most). */ + listSeasonBulkJobs(seasonKey: string): Promise<{ jobs: SeasonBulkJob[] }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/jobs`); + } + + /** Retrieve one bulk job with its counts and up to 500 failed items. */ + retrieveSeasonBulkJob(seasonKey: string, jobId: string): Promise<{ job: SeasonBulkJobDetail }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/jobs/${pathPart(jobId)}`); + } + + /** + * Set the season's seats aside on every date: every seat its packages price, minus + * `keepForSingleTickets`. Answers a `season_block_set_aside` job to follow with + * `retrieveSeasonBulkJob`. On an active season block mode turns on by itself once every + * date has the seats (`season.block.ready`); on a draft, Publish turns it on. Season seats + * must be on for the season (`409 season_block_not_enabled` otherwise). Retried safely + * with one Idempotency-Key, which replays the same answer. + */ + setAsideSeasonBlock( + seasonKey: string, + params: SetAsideSeasonBlockParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/block`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + + /** Retrieve the season's seats: counts, seats not free on every date, each date's catch-up. */ + retrieveSeasonBlock(seasonKey: string): Promise<{ block: SeasonBlock }> { + return this.#http.get(`/v1/seasons/${pathPart(seasonKey)}/block`); + } + + /** + * Release unsold season seats to single sale, now or `at` a set time, on every date or on + * `eventKeys`. Held seats go when their hold ends; booked seats are never released. Answers a + * `season_block_release` job; `season.block.released` follows once it has run. Retried safely + * with one Idempotency-Key: the release runs once. + */ + releaseSeasonBlock( + seasonKey: string, + params: ReleaseSeasonBlockParams, + options: { idempotencyKey?: string; signal?: AbortSignal } = {}, + ): Promise { + return this.#http.postWithHeaderReplay(`/v1/seasons/${pathPart(seasonKey)}/block/release`, { + body: params, idempotencyKey: options.idempotencyKey, signal: options.signal, + }); + } + async waitForSeasonRenewal( seasonKey: string, initial: SeasonRenewalResult, @@ -725,7 +1962,7 @@ export class Seasons { } #sales(seasonKey: string, action: 'open' | 'pause' | 'resume' | 'end', expectedRevision: number) { - return this.#http.post<{ season: SeasonDetail }>( + return this.#http.post( `/v1/seasons/${pathPart(seasonKey)}/sales/${action}`, { body: { expectedRevision } }, ); diff --git a/src/types.ts b/src/types.ts index 11da5c6..89ee8a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -519,6 +519,7 @@ export type EventHostingRegion = | 'south-america' | 'asia-pacific' | 'northeast-asia' | 'southeast-asia' | 'oceania' | 'africa' | 'middle-east'; +/** Every public webhook event name, in the order the API lists them. */ export type WebhookEventName = | 'seat.booked' | 'seat.released' @@ -528,6 +529,19 @@ export type WebhookEventName = | 'hold.extended' | 'event.created' | 'event.soldout' + | 'object.resale.listed' + | 'object.resale.unlisted' + | 'object.resale.sold' + | 'customer.created' + | 'customer.email_changed' + | 'order.paid' + | 'order.refunded' + | 'order.cancelled' + | 'season.ticket.transfer_sent' + | 'season.ticket.transferred' + | 'season.ticket.transfer_cancelled' + | 'season.ticket.issued' + | 'season.ticket.voided' | 'season.structure.activated' | 'season.structure.closed' | 'season.structure.archived' @@ -542,6 +556,8 @@ export type WebhookEventName = | 'season.booking.confirmed' | 'season.booking.cancelled' | 'season.booking.emergency_requested' + | 'season.occurrence.returned' + | 'season.occurrence.reclaimed' | 'season.renewal.offered' | 'season.renewal.intent_received' | 'season.renewal.renewed' @@ -549,7 +565,20 @@ export type WebhookEventName = | 'season.renewal.lapsed' | 'season.renewal.released' | 'season.operation.partial_terminal' - | 'season.amendment.applied'; + | 'season.amendment.applied' + | 'season.performance.added' + | 'season.performance.removed' + | 'season.performance.past' + | 'season.performance.postponed' + | 'season.performance.cancelled' + | 'season.performance.rescheduled' + | 'season.bulk_action.completed' + | 'season.block.ready' + | 'season.block.released' + | 'season.order.paid' + | 'season.order.refunded' + | 'series.created' + | 'series.dates_added'; export interface Webhook { id: string; diff --git a/test/client.test.ts b/test/client.test.ts index 45e2f07..63c505a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1263,6 +1263,12 @@ describe('pagination', () => { expect(call(0).url).toContain('counts=0'); }); + it('filters events by series', async () => { + const { sdk, call } = client([{ status: 200, body: { events: [] } }]); + await sdk.events.list({ seriesKey: 'ser a/b', limit: 10 }); + expect(new URL(call(0).url).searchParams.get('seriesKey')).toBe('ser a/b'); + }); + it('keeps counts on a single explicit page', async () => { const { sdk, call } = client([{ status: 200, body: { events: [] } }]); await sdk.events.list({ limit: 10 }); diff --git a/test/sales-operations.test.ts b/test/sales-operations.test.ts new file mode 100644 index 0000000..c8d0672 --- /dev/null +++ b/test/sales-operations.test.ts @@ -0,0 +1,571 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { + SeatLayer, + SeatLayerConflictError, + SeatLayerError, + SeatLayerRateLimitError, + type WebhookEventName, +} from '../src/index.js'; + +type StubResponse = { + status: number; + body?: unknown; + /** Sent as-is instead of JSON, for CSV downloads. */ + rawBody?: string; + headers?: Record; +}; + +function stubFetch(responses: StubResponse[]) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetch = vi.fn(async (url: Parameters[0], init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + const next = responses.shift(); + if (!next) throw new Error('stubFetch: more requests than queued responses'); + return new Response(next.rawBody ?? (next.body === undefined ? null : JSON.stringify(next.body)), { + status: next.status, + headers: { 'content-type': 'application/json', ...(next.headers ?? {}) }, + }); + }); + return { fetch: fetch as unknown as typeof globalThis.fetch, calls, responses }; +} + +const GENERATED_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** One operation in a table: the call, the request it must send, and what it returns. */ +interface OperationCase { + name: string; + run: (sdk: SeatLayer) => Promise; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** Path plus query string, exactly as sent. */ + path: string; + /** Parsed JSON body; leave out when no body may be sent. */ + body?: unknown; + /** A caller key, 'generated' for a header-replay call without one, or absent for no header. */ + idempotencyKey?: string; + response?: StubResponse; + /** When present, the resolved value must equal this. */ + returns?: unknown; +} + +/** Runs every case against one queued stub, then checks each request and that the queue drained. */ +async function expectOperations(cases: OperationCase[]) { + const stub = stubFetch(cases.map((entry) => entry.response ?? { status: 200, body: { ok: true } })); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + const results: unknown[] = []; + for (const entry of cases) results.push(await entry.run(sdk)); + + expect(stub.calls).toHaveLength(cases.length); + cases.forEach((entry, index) => { + const call = stub.calls[index]!; + const url = new URL(call.url); + const headers = call.init.headers as Record; + expect(`${entry.name}: ${call.init.method} ${url.pathname}${url.search}`) + .toBe(`${entry.name}: ${entry.method} ${entry.path}`); + const sent = call.init.body === undefined ? undefined : JSON.parse(call.init.body as string); + expect(sent, `${entry.name} body`).toEqual(entry.body); + if (entry.idempotencyKey === undefined) { + expect(headers['Idempotency-Key'], `${entry.name} sends no Idempotency-Key`).toBeUndefined(); + } else if (entry.idempotencyKey === 'generated') { + expect(headers['Idempotency-Key'], `${entry.name} generates a key`).toMatch(GENERATED_KEY); + } else { + expect(headers['Idempotency-Key'], `${entry.name} sends the caller key`).toBe(entry.idempotencyKey); + } + if ('returns' in entry) expect(results[index], `${entry.name} result`).toEqual(entry.returns); + }); + expect(stub.responses).toHaveLength(0); +} + +describe('Holders resource', () => { + it('maps all 14 holder, customer, ticket, transfer, return, and renewal operations', async () => { + await expectOperations([ + { + name: 'listCustomers', + run: (sdk) => sdk.holders.listCustomers({ + email: 'fan+1@example.com', holderRef: 'holder a/b', cursor: 'c/1', limit: 10, + }), + method: 'GET', path: '/v1/customers?email=fan%2B1%40example.com&holderRef=holder+a%2Fb&cursor=c%2F1&limit=10', + }, + { + name: 'retrieveHolder', + run: (sdk) => sdk.holders.retrieveHolder('holder a/b'), + method: 'GET', path: '/v1/holders/holder%20a%2Fb', + }, + { + name: 'updateHolder', + run: (sdk) => sdk.holders.updateHolder('holder a/b', { email: 'new@example.com', name: null }, { + idempotencyKey: 'holder-1', + }), + method: 'PATCH', path: '/v1/holders/holder%20a%2Fb', + body: { email: 'new@example.com', name: null }, idempotencyKey: 'holder-1', + }, + { + name: 'revokeHolderSessions', + run: (sdk) => sdk.holders.revokeHolderSessions('holder a/b', { sessionId: 'hs_1' }), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/sessions/revoke', body: { sessionId: 'hs_1' }, + }, + { + name: 'listHolderSeasons', + run: (sdk) => sdk.holders.listHolderSeasons('holder a/b'), + method: 'GET', path: '/v1/holders/holder%20a%2Fb/seasons', + }, + { + name: 'listHolderTickets', + run: (sdk) => sdk.holders.listHolderTickets('holder a/b', { + seasonKey: 'sea_a/b', from: 1, to: 2, cursor: 'c/1', limit: 5, + }), + method: 'GET', path: '/v1/holders/holder%20a%2Fb/tickets?seasonKey=sea_a%2Fb&from=1&to=2&cursor=c%2F1&limit=5', + }, + { + name: 'retrieveHolderTicket', + run: (sdk) => sdk.holders.retrieveHolderTicket('holder a/b', 'tkt/1'), + method: 'GET', path: '/v1/holders/holder%20a%2Fb/tickets/tkt%2F1', + }, + { + name: 'createHolderTransfer', + run: (sdk) => sdk.holders.createHolderTransfer('holder a/b', { + ticketIds: ['tkt/1'], toEmail: 'friend@example.com', + }), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/transfers', + body: { ticketIds: ['tkt/1'], toEmail: 'friend@example.com' }, idempotencyKey: 'generated', + response: { status: 201, body: { transfer: { transferId: 'trf_1', state: 'sent' } } }, + }, + { + name: 'listHolderTransfers', + run: (sdk) => sdk.holders.listHolderTransfers('holder a/b'), + method: 'GET', path: '/v1/holders/holder%20a%2Fb/transfers', + }, + { + name: 'cancelHolderTransfer', + run: (sdk) => sdk.holders.cancelHolderTransfer('holder a/b', 'trf/1'), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/transfers/trf%2F1/cancel', + }, + { + name: 'acceptHolderTransfer', + run: (sdk) => sdk.holders.acceptHolderTransfer('holder a/b', 'trf/1'), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/transfers/trf%2F1/accept', + }, + { + name: 'createHolderReturn', + run: (sdk) => sdk.holders.createHolderReturn('holder a/b', { + ticketIds: ['tkt_0123456789abcdef01234567'], + }), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/returns', + body: { ticketIds: ['tkt_0123456789abcdef01234567'] }, idempotencyKey: 'generated', + response: { status: 201, body: { return: { returnId: 'ret_1', state: 'pending' } } }, + returns: { return: { returnId: 'ret_1', state: 'pending' } }, + }, + { + name: 'createHolderRenewalCheckout', + run: (sdk) => sdk.holders.createHolderRenewalCheckout('holder a/b', 'sro/1', { + returnUrl: 'https://tickets.example/renewed', buyerEmail: 'cara@example.com', + buyerName: 'Cara', provider: 'stripe', + }, { idempotencyKey: 'crm-renew-cara-2027' }), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/renewal-offers/sro%2F1/checkout', + body: { + returnUrl: 'https://tickets.example/renewed', buyerEmail: 'cara@example.com', + buyerName: 'Cara', provider: 'stripe', + }, + idempotencyKey: 'crm-renew-cara-2027', + response: { status: 201, body: { orderId: 'ord_1', payment: { url: 'https://pay.example/1' } } }, + returns: { orderId: 'ord_1', payment: { url: 'https://pay.example/1' } }, + }, + { + name: 'declineHolderRenewalOffer with a reason', + run: (sdk) => sdk.holders.declineHolderRenewalOffer('holder a/b', 'sro/1', { reason: 'Moving away' }), + method: 'POST', path: '/v1/holders/holder%20a%2Fb/renewal-offers/sro%2F1/decline', + body: { reason: 'Moving away' }, + response: { status: 200, body: { offer: { offerId: 'sro/1', state: 'declined', declineReason: 'Moving away' } } }, + returns: { offer: { offerId: 'sro/1', state: 'declined', declineReason: 'Moving away' } }, + }, + ]); + }); + + it('declines a holder offer with no body when no reason is given', async () => { + await expectOperations([ + { + name: 'declineHolderRenewalOffer', + run: (sdk) => sdk.holders.declineHolderRenewalOffer('holder_1', 'sro_1'), + method: 'POST', path: '/v1/holders/holder_1/renewal-offers/sro_1/decline', + }, + ]); + }); + + it('reuses one Idempotency-Key when a holder renewal checkout retries, but sends a decline once', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 201, body: { orderId: 'ord_1' } }, + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 201, body: { orderId: 'ord_1' } }, + { status: 503, body: { error: 'unavailable' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + const params = { returnUrl: 'https://tickets.example/renewed' }; + + await sdk.holders.createHolderRenewalCheckout('a/b', 'sro_1', params); + await sdk.holders.createHolderRenewalCheckout('a/b', 'sro_1', params, { idempotencyKey: 'renew-1' }); + await expect(sdk.holders.declineHolderRenewalOffer('a/b', 'sro_1', { reason: 'Moving away' })) + .rejects.toBeInstanceOf(SeatLayerError); + + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(stub.calls.map((call) => `${call.init.method} ${new URL(call.url).pathname}`)).toEqual([ + ...Array(4).fill('POST /v1/holders/a%2Fb/renewal-offers/sro_1/checkout'), + 'POST /v1/holders/a%2Fb/renewal-offers/sro_1/decline', + ]); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(headers[2]!['Idempotency-Key']).toBe('renew-1'); + expect(headers[3]!['Idempotency-Key']).toBe('renew-1'); + expect(headers[4]!['Idempotency-Key']).toBeUndefined(); + expect(JSON.parse(stub.calls[4]!.init.body as string)).toEqual({ reason: 'Moving away' }); + expect(stub.responses).toHaveLength(0); + }); + + it('reuses one generated key when a transfer retries, and sends session revoke once', async () => { + const stub = stubFetch([ + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 201, body: { transfer: { transferId: 'trf_1' } } }, + { status: 503, body: { error: 'unavailable' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + + await sdk.holders.createHolderTransfer('holder_1', { ticketIds: ['tkt_1'], toEmail: 'a@example.com' }); + await expect(sdk.holders.revokeHolderSessions('holder_1')).rejects.toBeInstanceOf(SeatLayerError); + + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(headers[2]!['Idempotency-Key']).toBeUndefined(); + expect(stub.calls[2]!.init.body).toBeUndefined(); + expect(stub.responses).toHaveLength(0); + }); + + it('reuses one Idempotency-Key when a return retries, generated or the caller\'s', async () => { + const created = { + return: { + returnId: 'ret_1', + state: 'returned', + seasonKey: 'sea_1', + tickets: [{ ticketId: 'tkt_0123456789abcdef01234567', eventKey: 'ev_1', label: 'A-1', state: 'returned', error: null }], + createdAt: 1, + }, + }; + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 201, body: created }, + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 200, body: created }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + const params = { ticketIds: ['tkt_0123456789abcdef01234567'] }; + + const first = await sdk.holders.createHolderReturn('a/b', params); + const again = await sdk.holders.createHolderReturn('a/b', params, { idempotencyKey: 'return-1' }); + + expect(first).toEqual(created); + expect(again).toEqual(created); + expectTypeOf(first.return.tickets[0]!.state).toEqualTypeOf<'pending' | 'returned' | 'failed'>(); + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(stub.calls.map((call) => `${call.init.method} ${new URL(call.url).pathname}`)) + .toEqual(Array(4).fill('POST /v1/holders/a%2Fb/returns')); + expect(stub.calls.map((call) => JSON.parse(call.init.body as string))).toEqual(Array(4).fill(params)); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(headers[2]!['Idempotency-Key']).toBe('return-1'); + expect(headers[3]!['Idempotency-Key']).toBe('return-1'); + expect(stub.responses).toHaveLength(0); + }); +}); + +describe('Orders resource', () => { + it('maps all 9 order operations, including the event order routes', async () => { + const csv = 'orderId,displayRef,total\nord_1,SL-1,"1,000"\n'; + await expectOperations([ + { + name: 'listOrders', + run: (sdk) => sdk.orders.listOrders({ + eventKey: 'ev a/b', seasonKey: 'sea_a/b', holderRef: 'ord_1/2', status: 'confirmed', from: 1, to: 2, cursor: 'c/1', limit: 50, + }), + method: 'GET', + path: '/v1/orders?eventKey=ev+a%2Fb&seasonKey=sea_a%2Fb&holderRef=ord_1%2F2&status=confirmed&from=1&to=2&cursor=c%2F1&limit=50', + }, + { + name: 'retrieveOrder', + run: (sdk) => sdk.orders.retrieveOrder('ord_1/2'), + method: 'GET', path: '/v1/orders/ord_1%2F2', + }, + { + name: 'listOrderTickets', + run: (sdk) => sdk.orders.listOrderTickets('ord_1/2'), + method: 'GET', path: '/v1/orders/ord_1%2F2/tickets', + }, + { + name: 'listEventOrders', + run: (sdk) => sdk.orders.listEventOrders('ev a/b', { + q: 'smith', status: 'partially_cancelled', paidBy: 'card', from: 1, to: 2, cursor: 'c/1', limit: 25, + }), + method: 'GET', + path: '/v1/events/ev%20a%2Fb/orders?q=smith&status=partially_cancelled&paidBy=card&from=1&to=2&cursor=c%2F1&limit=25', + }, + { + name: 'retrieveEventOrder', + run: (sdk) => sdk.orders.retrieveEventOrder('ev a/b', 'ord_1/2'), + method: 'GET', path: '/v1/events/ev%20a%2Fb/orders/ord_1%2F2', + }, + { + name: 'exportEventOrdersCsv', + run: (sdk) => sdk.orders.exportEventOrdersCsv('ev a/b', { status: 'confirmed', paidBy: 'card' }), + method: 'GET', path: '/v1/events/ev%20a%2Fb/orders.csv?status=confirmed&paidBy=card', + response: { status: 200, rawBody: csv, headers: { 'content-type': 'text/csv; charset=utf-8' } }, + returns: csv, + }, + { + name: 'refundEventOrder', + run: (sdk) => sdk.orders.refundEventOrder('ev a/b', 'ord_1/2', { amountMinor: 1_000 }, { + idempotencyKey: 'event-refund-1', + }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/orders/ord_1%2F2/refund', + body: { amountMinor: 1_000 }, idempotencyKey: 'event-refund-1', + response: { status: 202, body: { orderId: 'ord_1/2', operationId: 'op_1' } }, + }, + { + name: 'cancelEventOrder', + run: (sdk) => sdk.orders.cancelEventOrder('ev a/b', 'ord_1/2', { reason: 'other', notifyBuyer: false }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/orders/ord_1%2F2/cancel', + body: { reason: 'other', notifyBuyer: false }, idempotencyKey: 'generated', + }, + { + name: 'resendEventOrderTickets', + run: (sdk) => sdk.orders.resendEventOrderTickets('ev a/b', 'ord_1/2', { email: 'fan@example.com' }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/orders/ord_1%2F2/resend', body: { email: 'fan@example.com' }, + }, + ]); + }); + + it('types the CSV export as text and never retries a ticket resend', async () => { + const stub = stubFetch([{ status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + expectTypeOf(sdk.orders.exportEventOrdersCsv).returns.resolves.toEqualTypeOf(); + + await expect(sdk.orders.resendEventOrderTickets('ev_1', 'ord_1')).rejects.toBeInstanceOf(SeatLayerRateLimitError); + expect(stub.calls).toHaveLength(1); + expect(stub.calls[0]!.init.body).toBeUndefined(); + }); +}); + +describe('Event sales operations', () => { + it('maps all 14 checkout, gateway, sales, booking-fee, and discount-code operations', async () => { + await expectOperations([ + { + name: 'retrieveEventBuyerCheckout', + run: (sdk) => sdk.events.retrieveEventBuyerCheckout('ev a/b'), + method: 'GET', path: '/v1/events/ev%20a%2Fb/buyer-checkout', + }, + { + name: 'replaceEventBuyerCheckout', + run: (sdk) => sdk.events.replaceEventBuyerCheckout('ev a/b', { buyerCheckout: 'seatlayer', expectedRevision: 3 }), + method: 'PUT', path: '/v1/events/ev%20a%2Fb/buyer-checkout', + body: { buyerCheckout: 'seatlayer', expectedRevision: 3 }, + }, + { + name: 'retrieveEventPaymentGateway', + run: (sdk) => sdk.events.retrieveEventPaymentGateway('ev a/b', { currency: 'EUR' }), + method: 'GET', path: '/v1/events/ev%20a%2Fb/payment-gateway?currency=EUR', + }, + { + name: 'replaceEventPaymentGateway', + run: (sdk) => sdk.events.replaceEventPaymentGateway('ev a/b', { connectionId: 'pc_1', provider: null }), + method: 'PUT', path: '/v1/events/ev%20a%2Fb/payment-gateway', body: { connectionId: 'pc_1', provider: null }, + }, + { + name: 'retrieveEventSalesReadiness', + run: (sdk) => sdk.events.retrieveEventSalesReadiness('ev a/b'), + method: 'GET', path: '/v1/events/ev%20a%2Fb/sales/readiness', + }, + { + name: 'preflightEventSales', + run: (sdk) => sdk.events.preflightEventSales('ev a/b', { orderId: 'ord_1/2' }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/sales/preflight', body: { orderId: 'ord_1/2' }, + }, + { + name: 'transitionEventSales', + run: (sdk) => sdk.events.transitionEventSales('ev a/b', { to: 'live', expectedRevision: 4, reason: 'launch' }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/sales/transition', + body: { to: 'live', expectedRevision: 4, reason: 'launch' }, + }, + { + name: 'retrieveEventBookingFee', + run: (sdk) => sdk.events.retrieveEventBookingFee('ev a/b'), + method: 'GET', path: '/v1/events/ev%20a%2Fb/booking-fee', + }, + { + name: 'replaceEventBookingFee', + run: (sdk) => sdk.events.replaceEventBookingFee('ev a/b', { bookingFeeMinor: null }), + method: 'PUT', path: '/v1/events/ev%20a%2Fb/booking-fee', body: { bookingFeeMinor: null }, + }, + { + name: 'listEventDiscountCodes', + run: (sdk) => sdk.events.listEventDiscountCodes('ev a/b'), + method: 'GET', path: '/v1/events/ev%20a%2Fb/discount-codes', + }, + { + name: 'createEventDiscountCode', + run: (sdk) => sdk.events.createEventDiscountCode('ev a/b', { + code: 'EARLY', kind: 'percent', percent: 10, maxUses: null, + }), + method: 'POST', path: '/v1/events/ev%20a%2Fb/discount-codes', + body: { code: 'EARLY', kind: 'percent', percent: 10, maxUses: null }, + response: { status: 201, body: { code: { id: 'dc_1', code: 'EARLY' } } }, + }, + { + name: 'updateEventDiscountCode', + run: (sdk) => sdk.events.updateEventDiscountCode('ev a/b', 'dc/1', { state: 'paused' }), + method: 'PATCH', path: '/v1/events/ev%20a%2Fb/discount-codes/dc%2F1', body: { state: 'paused' }, + }, + { + name: 'endEventDiscountCode', + run: (sdk) => sdk.events.endEventDiscountCode('ev a/b', 'dc/1'), + method: 'POST', path: '/v1/events/ev%20a%2Fb/discount-codes/dc%2F1/end', + }, + { + name: 'deleteEventDiscountCode', + run: (sdk) => sdk.events.deleteEventDiscountCode('ev a/b', 'dc/1'), + method: 'DELETE', path: '/v1/events/ev%20a%2Fb/discount-codes/dc%2F1', + response: { status: 204 }, returns: undefined, + }, + ]); + }); + + it('keeps every event sales mutation single-attempt', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 503, body: { error: 'unavailable' } }, + { status: 503, body: { error: 'unavailable' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + expectTypeOf(sdk.events.deleteEventDiscountCode).returns.resolves.toEqualTypeOf(); + + await expect(sdk.events.transitionEventSales('ev_1', { to: 'paused', expectedRevision: 1 })) + .rejects.toBeInstanceOf(SeatLayerError); + await expect(sdk.events.createEventDiscountCode('ev_1', { code: 'X', kind: 'amount', amountMinor: 100 })) + .rejects.toBeInstanceOf(SeatLayerError); + await expect(sdk.events.replaceEventBookingFee('ev_1', { bookingFeeMinor: 50 })) + .rejects.toBeInstanceOf(SeatLayerError); + + expect(stub.calls).toHaveLength(3); + expect(stub.calls.every((call) => !(call.init.headers as Record)['Idempotency-Key'])).toBe(true); + }); +}); + +describe('Event series resource', () => { + it('makes failed dates again with { retryFailed: true }', async () => { + await expectOperations([{ + name: 'addEventSeriesDates', + run: (sdk) => sdk.eventSeries.addEventSeriesDates('ser/1', { retryFailed: true }), + method: 'POST', path: '/v1/event-series/ser%2F1/dates', + body: { retryFailed: true }, idempotencyKey: 'generated', + response: { status: 202, body: { jobId: 'job_3', added: 2 } }, + }]); + }); + + it('makes a series from an event already set up (fromEventKey)', async () => { + const schedule = { rule: 'daily' as const, from: '2026-11-06', until: '2026-11-19', times: ['14:00', '17:30', '20:15'] }; + await expectOperations([{ + name: 'createEventSeries', + run: (sdk) => sdk.eventSeries.createEventSeries({ fromEventKey: 'lighthouse-keeper', schedule }, { idempotencyKey: 'series-2' }), + method: 'POST', path: '/v1/event-series', + body: { fromEventKey: 'lighthouse-keeper', schedule }, idempotencyKey: 'series-2', + response: { status: 202, body: { series: { seriesKey: 'ser_2', firstEventKey: 'lighthouse-keeper' }, jobId: 'job_4' } }, + }]); + }); + + it('answers a given-up series create as 409 create_abandoned, once, with the message to use a new key', async () => { + const stub = stubFetch([{ status: 409, body: { + error: 'create_abandoned', code: 'create_abandoned', + message: 'This series never started and was given up when another request came for the event. Send the request again with a new Idempotency-Key.', + } }]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 2 }); + const schedule = { rule: 'daily' as const, from: '2026-11-06', until: '2026-11-08', times: ['20:00'] }; + const error = await sdk.eventSeries.createEventSeries({ fromEventKey: 'ev_1', schedule }, { idempotencyKey: 'series-3' }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(SeatLayerConflictError); + expect((error as SeatLayerConflictError).code).toBe('create_abandoned'); + expect((error as SeatLayerConflictError).message).toMatch(/new Idempotency-Key/); + expect(stub.calls).toHaveLength(1); + }); + + it('maps all 5 event series operations', async () => { + const schedule = { rule: 'weekly' as const, from: '2027-01-01', count: 4, times: ['19:30'] }; + await expectOperations([ + { + name: 'createEventSeries', + run: (sdk) => sdk.eventSeries.createEventSeries({ + name: 'Tour', chartKey: 'ch_1', schedule, timezone: 'Europe/London', + }, { idempotencyKey: 'series-1' }), + method: 'POST', path: '/v1/event-series', + body: { name: 'Tour', chartKey: 'ch_1', schedule, timezone: 'Europe/London' }, idempotencyKey: 'series-1', + response: { status: 202, body: { series: { seriesKey: 'ser_1' }, jobId: 'job_1' } }, + }, + { + name: 'listEventSeries', + run: (sdk) => sdk.eventSeries.listEventSeries({ limit: 10, cursor: 'c/1' }), + method: 'GET', path: '/v1/event-series?limit=10&cursor=c%2F1', + }, + { + name: 'retrieveEventSeries', + run: (sdk) => sdk.eventSeries.retrieveEventSeries('ser/1'), + method: 'GET', path: '/v1/event-series/ser%2F1', + }, + { + name: 'addEventSeriesDates', + run: (sdk) => sdk.eventSeries.addEventSeriesDates('ser/1', { dates: ['2027-02-01T19:30'] }), + method: 'POST', path: '/v1/event-series/ser%2F1/dates', + body: { dates: ['2027-02-01T19:30'] }, idempotencyKey: 'generated', + response: { status: 202, body: { jobId: 'job_2', added: 1 } }, + }, + { + name: 'updateEventSeries', + run: (sdk) => sdk.eventSeries.updateEventSeries('ser/1', { + name: 'Tour 2', sales: 'off', prices: { categories: [{ key: 'A', price: 2_500 }] }, + }, { idempotencyKey: 'series-update-1' }), + method: 'PATCH', path: '/v1/event-series/ser%2F1', + body: { name: 'Tour 2', sales: 'off', prices: { categories: [{ key: 'A', price: 2_500 }] } }, + idempotencyKey: 'series-update-1', + }, + ]); + }); +}); + +describe('webhook event names', () => { + it('includes the series, order, customer, and season ticket transfer events', () => { + const names: WebhookEventName[] = [ + 'series.created', 'series.dates_added', 'order.paid', 'order.refunded', 'order.cancelled', + 'customer.created', 'customer.email_changed', 'season.ticket.transfer_sent', + 'season.ticket.transferred', 'season.ticket.transfer_cancelled', + ]; + expect(names).toHaveLength(10); + }); + + it('lists all 56 public names, including the 2.11 date and bulk job events', () => { + const names = [ + 'seat.booked', 'seat.released', 'seat.blocked', 'hold.expired', 'hold.created', 'hold.extended', + 'event.created', 'event.soldout', 'object.resale.listed', 'object.resale.unlisted', 'object.resale.sold', + 'customer.created', 'customer.email_changed', 'order.paid', 'order.refunded', 'order.cancelled', + 'season.ticket.transfer_sent', 'season.ticket.transferred', 'season.ticket.transfer_cancelled', + 'season.ticket.issued', 'season.ticket.voided', 'season.structure.activated', 'season.structure.closed', + 'season.structure.archived', 'season.plan.published', 'season.plan.superseded', 'season.sales.opened', + 'season.sales.paused', 'season.sales.ended', 'season.hold.committed', 'season.hold.released', + 'season.hold.expired', 'season.booking.confirmed', 'season.booking.cancelled', + 'season.booking.emergency_requested', 'season.occurrence.returned', 'season.occurrence.reclaimed', + 'season.renewal.offered', 'season.renewal.intent_received', 'season.renewal.renewed', + 'season.renewal.declined', 'season.renewal.lapsed', 'season.renewal.released', + 'season.operation.partial_terminal', 'season.amendment.applied', 'season.performance.added', + 'season.performance.removed', 'season.performance.past', 'season.performance.postponed', + 'season.performance.cancelled', 'season.performance.rescheduled', 'season.bulk_action.completed', + 'season.block.ready', 'season.block.released', + 'season.order.paid', 'season.order.refunded', 'series.created', 'series.dates_added', + ] as const satisfies readonly WebhookEventName[]; + expect(new Set(names).size).toBe(58); + // Every member of the union is listed above: nothing is left over. + expectTypeOf>().toEqualTypeOf(); + }); +}); diff --git a/test/seasons.test.ts b/test/seasons.test.ts index 197d8f9..81f459c 100644 --- a/test/seasons.test.ts +++ b/test/seasons.test.ts @@ -1,22 +1,48 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { SeatLayer, + SeatLayerError, SeatLayerRateLimitError, + SeatLayerValidationError, + seasonDateCancelVoidJobId, + isSeasonDateLeavePending, + seasonHolderImportRowsPerCall, + type SeasonAmendment, + type SeasonBulkJobDetail, type SeasonHold, + type SeasonPackagePrice, + type SeasonPriceList, + type SeasonPriceTypeKey, + type SeasonRenewalCheckoutStarted, + type SeasonBulkJob, + type SeasonBulkJobKind, + type SeasonRenewalOffer, + type SeasonRenewalOfferJob, + type SeasonRenewalQuote, + type SeasonResult, + type SeasonSingleTicketDates, type SeasonValidation, + type Season, + type SeasonInventoryMode, + type CreateSeasonParams, + type UpdateSeasonParams, } from '../src/index.js'; -function stubFetch(responses: Array<{ +type StubResponse = { status: number; body?: unknown; + /** Sent as-is instead of JSON, for CSV downloads. */ + rawBody?: string; headers?: Record; -}>) { +}; + +function stubFetch(responses: StubResponse[]) { const calls: Array<{ url: string; init: RequestInit }> = []; const fetch = vi.fn(async (url: Parameters[0], init?: RequestInit) => { calls.push({ url: String(url), init: init ?? {} }); const next = responses.shift(); if (!next) throw new Error('stubFetch: more requests than queued responses'); - return new Response(next.body === undefined ? null : JSON.stringify(next.body), { + return new Response(next.rawBody ?? (next.body === undefined ? null : JSON.stringify(next.body)), { status: next.status, headers: { 'content-type': 'application/json', ...(next.headers ?? {}) }, }); @@ -24,6 +50,52 @@ function stubFetch(responses: Array<{ return { fetch: fetch as unknown as typeof globalThis.fetch, calls, responses }; } +const GENERATED_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** One operation in a table: the call, the request it must send, and what it returns. */ +interface OperationCase { + name: string; + run: (sdk: SeatLayer) => Promise; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** Path plus query string, exactly as sent. */ + path: string; + /** Parsed JSON body; leave out when no body may be sent. */ + body?: unknown; + /** A caller key, 'generated' for a header-replay call without one, or absent for no header. */ + idempotencyKey?: string; + response?: StubResponse; + /** When present, the resolved value must equal this. */ + returns?: unknown; +} + +/** Runs every case against one queued stub, then checks each request and that the queue drained. */ +async function expectOperations(cases: OperationCase[]) { + const stub = stubFetch(cases.map((entry) => entry.response ?? { status: 200, body: { ok: true } })); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 3 }); + const results: unknown[] = []; + for (const entry of cases) results.push(await entry.run(sdk)); + + expect(stub.calls).toHaveLength(cases.length); + cases.forEach((entry, index) => { + const call = stub.calls[index]!; + const url = new URL(call.url); + const headers = call.init.headers as Record; + expect(`${entry.name}: ${call.init.method} ${url.pathname}${url.search}`) + .toBe(`${entry.name}: ${entry.method} ${entry.path}`); + const sent = call.init.body === undefined ? undefined : JSON.parse(call.init.body as string); + expect(sent, `${entry.name} body`).toEqual(entry.body); + if (entry.idempotencyKey === undefined) { + expect(headers['Idempotency-Key'], `${entry.name} sends no Idempotency-Key`).toBeUndefined(); + } else if (entry.idempotencyKey === 'generated') { + expect(headers['Idempotency-Key'], `${entry.name} generates a key`).toMatch(GENERATED_KEY); + } else { + expect(headers['Idempotency-Key'], `${entry.name} sends the caller key`).toBe(entry.idempotencyKey); + } + if ('returns' in entry) expect(results[index], `${entry.name} result`).toEqual(entry.returns); + }); + expect(stub.responses).toHaveLength(0); +} + function lifecycle(terminal: boolean, kind: 'ACTIVATION' | 'CLOSE' | 'ARCHIVE' | 'PLAN_PUBLICATION' = 'ACTIVATION') { return { season: { key: 'sea_a/b', plans: [] }, @@ -405,4 +477,873 @@ describe('Season organizer resource', () => { } expect(stub.responses).toHaveLength(0); }); + + it('maps the 32 season 2a operations: verb, escaped path, query, body, and replay class', async () => { + const csvHolders = 'holderRef,holderName\nholder_1,"Smith, A"\n'; + const csvOrders = 'orderId,displayRef\nord_1,SL-1\n'; + const source = { bookingActionId: 'sba_1' }; + await expectOperations([ + { + name: 'createSeasonHolderImportFromSeason', + run: (sdk) => sdk.seasons.createSeasonHolderImportFromSeason('sea_a/b', { + sourceSeasonId: 'sea_prev', successorPlanActivationId: 'spa_1', dryRun: true, + }, { idempotencyKey: 'from-season-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/imports/from-season', + body: { sourceSeasonId: 'sea_prev', successorPlanActivationId: 'spa_1', dryRun: true }, + idempotencyKey: 'from-season-1', response: { status: 201, body: { import: { holders: 2 } } }, + }, + { + name: 'listSeasonPerformances', + run: (sdk) => sdk.seasons.listSeasonPerformances('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/performances', + }, + { + name: 'createSeasonPerformances', + run: (sdk) => sdk.seasons.createSeasonPerformances('sea_a/b', { + titlePattern: 'Home game', timezone: 'Europe/London', repeat: 'weekly', count: 3, + firstStartsAt: 1_800_000_000_000, region: 'western-europe', attachToPlanKeys: ['spl/1'], + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/performances', + body: { + titlePattern: 'Home game', timezone: 'Europe/London', repeat: 'weekly', count: 3, + firstStartsAt: 1_800_000_000_000, region: 'western-europe', attachToPlanKeys: ['spl/1'], + }, + idempotencyKey: 'generated', response: { status: 201, body: { created: [] } }, + }, + { + name: 'attachSeasonPerformances', + run: (sdk) => sdk.seasons.attachSeasonPerformances('sea_a/b', { + eventKeys: ['ev a/b'], attachToPlanKeys: ['spl/1'], + }, { idempotencyKey: 'attach-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/performances/attach', + body: { eventKeys: ['ev a/b'], attachToPlanKeys: ['spl/1'] }, idempotencyKey: 'attach-1', + }, + { + name: 'deleteSeasonPerformance', + run: (sdk) => sdk.seasons.deleteSeasonPerformance('sea_a/b', 'ev a/b'), + method: 'DELETE', path: '/v1/seasons/sea_a%2Fb/performances/ev%20a%2Fb', idempotencyKey: 'generated', + }, + { + name: 'updateSeasonPlan', + run: (sdk) => sdk.seasons.updateSeasonPlan('sea_a/b', 'spl/1', { + name: 'Gold', eventKeys: ['ev_1'], + }, { idempotencyKey: 'plan-update-1' }), + method: 'PATCH', path: '/v1/seasons/sea_a%2Fb/plans/spl%2F1', + body: { name: 'Gold', eventKeys: ['ev_1'] }, idempotencyKey: 'plan-update-1', + }, + { + name: 'deleteSeasonPlan', + run: (sdk) => sdk.seasons.deleteSeasonPlan('sea_a/b', 'spl/1'), + method: 'DELETE', path: '/v1/seasons/sea_a%2Fb/plans/spl%2F1', idempotencyKey: 'generated', + response: { status: 204 }, returns: undefined, + }, + { + name: 'publishSeason', + run: (sdk) => sdk.seasons.publishSeason('sea_a/b', { idempotencyKey: 'publish-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/publish', idempotencyKey: 'publish-1', + }, + { + name: 'createSeasonCheckoutTest', + run: (sdk) => sdk.seasons.createSeasonCheckoutTest('sea_a/b', { planKey: 'spl/1', quantity: 2 }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/checkout-tests', + body: { planKey: 'spl/1', quantity: 2 }, idempotencyKey: 'generated', + }, + { + name: 'listSeasonCheckoutTests', + run: (sdk) => sdk.seasons.listSeasonCheckoutTests('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/checkout-tests', + }, + { + name: 'replaceSeasonPackagePrices', + run: (sdk) => sdk.seasons.replaceSeasonPackagePrices('sea_a/b', 'spl/1', { + prices: [{ categoryKey: 'A', unitPrice: 12_000 }], currency: 'GBP', + }, { idempotencyKey: 'prices-1' }), + method: 'PUT', path: '/v1/seasons/sea_a%2Fb/plans/spl%2F1/prices', + body: { prices: [{ categoryKey: 'A', unitPrice: 12_000 }], currency: 'GBP' }, idempotencyKey: 'prices-1', + }, + { + name: 'listSeasonPrices', + run: (sdk) => sdk.seasons.listSeasonPrices('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/prices', + }, + { + name: 'attachSeasonPlanPerformance', + run: (sdk) => sdk.seasons.attachSeasonPlanPerformance('sea_a/b', 'spl/1', { eventKey: 'ev a/b' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/plans/spl%2F1/performances', + body: { eventKey: 'ev a/b' }, idempotencyKey: 'generated', + response: { status: 202, body: { extended: 0, job: { jobId: 'sdj_1' } } }, + }, + { + name: 'deleteSeasonPlanPerformance', + run: (sdk) => sdk.seasons.deleteSeasonPlanPerformance('sea_a/b', 'spl/1', 'ev a/b'), + method: 'DELETE', path: '/v1/seasons/sea_a%2Fb/plans/spl%2F1/performances/ev%20a%2Fb', + idempotencyKey: 'generated', + }, + { + name: 'markSeasonPerformancePast', + run: (sdk) => sdk.seasons.markSeasonPerformancePast('sea_a/b', 'ev a/b', { force: true }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/performances/ev%20a%2Fb/past', + body: { force: true }, idempotencyKey: 'generated', + }, + { + name: 'returnSeasonOccurrenceInventory', + run: (sdk) => sdk.seasons.returnSeasonOccurrenceInventory('sea_a/b', 'ev a/b', { + actionId: 'sra_1', bookingRef: 'order_1', planActivationId: 'spa_1', labels: ['A-1'], source, + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/occurrences/ev%20a%2Fb/returns', + body: { actionId: 'sra_1', bookingRef: 'order_1', planActivationId: 'spa_1', labels: ['A-1'], source }, + }, + { + name: 'reclaimSeasonOccurrenceInventory', + run: (sdk) => sdk.seasons.reclaimSeasonOccurrenceInventory('sea_a/b', 'ev a/b', { + actionId: 'src_1', returnActionId: 'sra_1', bookingRef: 'order_1', planActivationId: 'spa_1', + labels: ['A-1'], source: { renewalOfferId: 'sro_1' }, + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/occurrences/ev%20a%2Fb/reclaims', + body: { + actionId: 'src_1', returnActionId: 'sra_1', bookingRef: 'order_1', planActivationId: 'spa_1', + labels: ['A-1'], source: { renewalOfferId: 'sro_1' }, + }, + }, + { + name: 'listSeasonHolders', + run: (sdk) => sdk.seasons.listSeasonHolders('sea_a/b', { + q: 'smith a/b', status: 'active', cursor: 'c/1', limit: 25, + }), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/holders?q=smith+a%2Fb&status=active&cursor=c%2F1&limit=25', + }, + { + name: 'refundSeasonOrder', + run: (sdk) => sdk.seasons.refundSeasonOrder('sea_a/b', 'ord_1/2', { amountMinor: 500 }, { + idempotencyKey: 'refund-1', + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/orders/ord_1%2F2/refund', + body: { amountMinor: 500 }, idempotencyKey: 'refund-1', + response: { status: 202, body: { orderId: 'ord_1/2', state: 'pending' } }, + }, + { + name: 'cancelSeasonOrder', + run: (sdk) => sdk.seasons.cancelSeasonOrder('sea_a/b', 'ord_1/2', { + reason: 'refunded_elsewhere', note: 'paid back', refundConfirmed: true, + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/orders/ord_1%2F2/cancel', + body: { reason: 'refunded_elsewhere', note: 'paid back', refundConfirmed: true }, + idempotencyKey: 'generated', + }, + { + name: 'cancelSeasonOrderDate', + run: (sdk) => sdk.seasons.cancelSeasonOrderDate('sea_a/b', 'ord_1/2', 'ev a/b', { actionId: 'sda_1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/orders/ord_1%2F2/dates/ev%20a%2Fb/cancel', + body: { actionId: 'sda_1' }, idempotencyKey: 'generated', + }, + { + name: 'resendSeasonOrderTickets', + run: (sdk) => sdk.seasons.resendSeasonOrderTickets('sea_a/b', 'ord_1/2', { email: 'fan@example.com' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/orders/ord_1%2F2/resend', body: { email: 'fan@example.com' }, + }, + { + name: 'retrieveSeasonHostedRenewals', + run: (sdk) => sdk.seasons.retrieveSeasonHostedRenewals('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/renewal-hosted', + }, + { + name: 'rotateSeasonRenewalOfferLink', + run: (sdk) => sdk.seasons.rotateSeasonRenewalOfferLink('sea_a/b', 'sro/1'), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro%2F1/link', + response: { status: 201, body: { url: 'https://renew.example/r/once' } }, + returns: { url: 'https://renew.example/r/once' }, + }, + { + name: 'updateSeasonRenewalOfferContact', + run: (sdk) => sdk.seasons.updateSeasonRenewalOfferContact('sea_a/b', 'sro/1', { + email: 'fan@example.com', name: null, + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro%2F1/contact', + body: { email: 'fan@example.com', name: null }, + }, + { + name: 'createSeasonRenewalNotices', + run: (sdk) => sdk.seasons.createSeasonRenewalNotices('sea_a/b', { offerIds: ['sro/1'] }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-notices', body: { offerIds: ['sro/1'] }, + }, + { + name: 'resendSeasonRenewalOffer', + run: (sdk) => sdk.seasons.resendSeasonRenewalOffer('sea_a/b', 'sro/1'), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro%2F1/resend', + response: { status: 202, body: { ok: true } }, + }, + { + name: 'retrieveSeasonSalesReport', + run: (sdk) => sdk.seasons.retrieveSeasonSalesReport('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/reports/sales', + }, + { + name: 'retrieveSeasonSeatsReport', + run: (sdk) => sdk.seasons.retrieveSeasonSeatsReport('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/reports/seats', + }, + { + name: 'exportSeasonHoldersCsv', + run: (sdk) => sdk.seasons.exportSeasonHoldersCsv('sea_a/b', { q: 'a/b', status: 'renewal' }), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/reports/holders.csv?q=a%2Fb&status=renewal', + response: { status: 200, rawBody: csvHolders, headers: { 'content-type': 'text/csv; charset=utf-8' } }, + returns: csvHolders, + }, + { + name: 'exportSeasonOrdersCsv', + run: (sdk) => sdk.seasons.exportSeasonOrdersCsv('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/reports/orders.csv', + response: { status: 200, rawBody: csvOrders, headers: { 'content-type': 'text/csv; charset=utf-8' } }, + returns: csvOrders, + }, + { + name: 'retrieveSeasonSeatCounts', + run: (sdk) => sdk.seasons.retrieveSeasonSeatCounts('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/seat-counts', + }, + ]); + }); + + it('maps the bulk refund of a cancelled date and the job reads', async () => { + const job = { + jobId: 'sbj_1', kind: 'bulk_refund', state: 'planning', + counts: { total: 0, done: 0, skipped: 0, failed: 0 }, createdAt: 1, updatedAt: 1, completedAt: null, + }; + await expectOperations([ + { + name: 'refundSeasonPerformance', + run: (sdk) => sdk.seasons.refundSeasonPerformance('sea_a/b', 'ev a/b', { + amount: 'per_date', reason: 'Storm', + }, { idempotencyKey: 'refund-date-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/performances/ev%20a%2Fb/refunds', + body: { amount: 'per_date', reason: 'Storm' }, idempotencyKey: 'refund-date-1', + response: { status: 202, body: { job } }, returns: { job }, + }, + { + name: 'refundSeasonPerformance without a key', + run: (sdk) => sdk.seasons.refundSeasonPerformance('sea_1', 'ev_1', { amount: 'per_date' }), + method: 'POST', path: '/v1/seasons/sea_1/performances/ev_1/refunds', + body: { amount: 'per_date' }, idempotencyKey: 'generated', response: { status: 202, body: { job } }, + }, + { + name: 'listSeasonBulkJobs', + run: (sdk) => sdk.seasons.listSeasonBulkJobs('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/jobs', + response: { status: 200, body: { jobs: [job] } }, returns: { jobs: [job] }, + }, + { + name: 'retrieveSeasonBulkJob', + run: (sdk) => sdk.seasons.retrieveSeasonBulkJob('sea_a/b', 'sbj/1'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/jobs/sbj%2F1', + response: { status: 200, body: { job: { ...job, failedItems: [] } } }, + }, + ]); + }); + + it('maps season seats: set aside, read and release, each a job read with retrieveSeasonBulkJob', async () => { + const block = { + mode: 'per_hold', state: 'setting_aside', revision: 7, + counts: { setAside: 1840, free: 1840, held: 0, booked: 0, released: 0 }, + notFreeOnEveryDate: { count: 1, sample: [{ eventKey: 'ev_3', label: 'C-14', reason: 'sold' }] }, + windowEnded: false, scheduledRelease: null, + dates: [{ eventKey: 'ev_1', state: 'up_to_date', behind: 0, lastAppliedAt: '2026-11-20T10:00:01Z' }], + }; + const setAsideJob = { + jobId: 'sbsa_sbp_spl_1', kind: 'season_block_set_aside', state: 'running', + counts: { total: 25, done: 4, skipped: 0, failed: 0 }, createdAt: 1, updatedAt: 2, completedAt: null, + }; + const releaseJob = { ...setAsideJob, jobId: 'sbr_1', kind: 'season_block_release', state: 'planning' }; + await expectOperations([ + { + name: 'setAsideSeasonBlock', + run: (sdk) => sdk.seasons.setAsideSeasonBlock('sea_a/b', { + expectedRevision: 7, keepForSingleTickets: { sectionIds: ['sec_family'] }, + }, { idempotencyKey: 'set-aside-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/block', + body: { expectedRevision: 7, keepForSingleTickets: { sectionIds: ['sec_family'] } }, idempotencyKey: 'set-aside-1', + response: { status: 202, body: { job: setAsideJob, block } }, returns: { job: setAsideJob, block }, + }, + { + name: 'setAsideSeasonBlock without a key', + run: (sdk) => sdk.seasons.setAsideSeasonBlock('sea_1', { expectedRevision: 7 }), + method: 'POST', path: '/v1/seasons/sea_1/block', + body: { expectedRevision: 7 }, idempotencyKey: 'generated', response: { status: 202, body: { job: setAsideJob, block } }, + }, + { + name: 'retrieveSeasonBlock', + run: (sdk) => sdk.seasons.retrieveSeasonBlock('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/block', + response: { status: 200, body: { block } }, returns: { block }, + }, + { + name: 'releaseSeasonBlock', + run: (sdk) => sdk.seasons.releaseSeasonBlock('sea_a/b', { + expectedRevision: 7, scope: { all: true }, eventKeys: ['ev_1'], at: '2026-12-01T09:00:00Z', reason: 'End of the season window', + }, { idempotencyKey: 'release-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/block/release', + body: { expectedRevision: 7, scope: { all: true }, eventKeys: ['ev_1'], at: '2026-12-01T09:00:00Z', reason: 'End of the season window' }, + idempotencyKey: 'release-1', + response: { status: 202, body: { job: releaseJob, block } }, returns: { job: releaseJob, block }, + }, + { + name: 'retrieveSeasonBulkJob reads a season seats job', + run: (sdk) => sdk.seasons.retrieveSeasonBulkJob('sea_a/b', 'sbr_1'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/jobs/sbr_1', + response: { status: 200, body: { job: { ...releaseJob, summary: { scope: { all: true } }, failedItems: [] } } }, + returns: { job: { ...releaseJob, summary: { scope: { all: true } }, failedItems: [] } }, + }, + ]); + expectTypeOf().toEqualTypeOf< + | 'date_added' | 'date_removed' | 'renewal_run' | 'lapsed_release' | 'bulk_refund' | 'holder_import' + | 'series_dates' | 'series_update' | 'season_singles' | 'date_cancel_void' + | 'season_block_set_aside' | 'season_block_release' + >(); + }); + + it('season seats refusals come through the typed error with their code; a job of a kind not known yet still parses', async () => { + const refusals = [ + { status: 409, body: { error: 'season_block_not_enabled', code: 'season_block_not_enabled', message: 'Season seats aren’t turned on for this season.' } }, + { status: 409, body: { error: 'block_exists', code: 'block_exists' } }, + { status: 422, body: { error: 'block_selection_empty', code: 'block_selection_empty' } }, + { status: 409, body: { error: 'season_block_set_aside', code: 'season_block_set_aside' } }, + { status: 409, body: { error: 'season_inventory_locked', code: 'season_inventory_locked', action: 'date_changes' } }, + ]; + const stub = stubFetch([...refusals, { status: 200, body: { job: { jobId: 'sbx_1', kind: 'some_future_kind', state: 'running', counts: { total: 1, done: 0, skipped: 0, failed: 0 }, createdAt: 1, updatedAt: 1, completedAt: null } } }]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch }); + for (const refusal of refusals) { + const error = await sdk.seasons.setAsideSeasonBlock('sea_1', { expectedRevision: 1 }).catch((thrown: unknown) => thrown); + expect(error).toBeInstanceOf(SeatLayerError); + expect((error as SeatLayerError).status).toBe(refusal.status); + expect((error as SeatLayerError).code).toBe(refusal.body.code); + } + await expect(sdk.seasons.retrieveSeasonBulkJob('sea_1', 'sbx_1')).resolves.toMatchObject({ job: { kind: 'some_future_kind' } }); + }); + + it('a season-seats date cancel or past date still finishing answers a 202 the guard recognises', async () => { + const pending = { + state: 'finishing', code: 'block_date_leave_pending', retryable: true, eventKey: 'ev_2', + shardsLeft: [0, 1], shardCount: 3, message: 'The date is being taken off sale for every season seat.', + }; + const stub = stubFetch([{ status: 202, body: pending, headers: { 'retry-after': '2' } }, { status: 202, body: pending }]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch }); + const cancel = await sdk.seasons.createSeasonAmendment('sea_1', { eventKey: 'ev_2', kind: 'cancel' } as never); + expect(isSeasonDateLeavePending(cancel)).toBe(true); + const past = await sdk.seasons.markSeasonPerformancePast('sea_1', 'ev_2', {}); + expect(isSeasonDateLeavePending(past)).toBe(true); + expect(isSeasonDateLeavePending({ amendment: { amendmentId: 'sam_1' } })).toBe(false); + expect(isSeasonDateLeavePending({ state: 'finishing', code: 'season_busy' })).toBe(false); + expect(isSeasonDateLeavePending({ state: 'done', code: 'block_date_leave_pending' })).toBe(false); + expect(isSeasonDateLeavePending(null)).toBe(false); + }); + + it('maps renewal at scale: the lapsed release job, the paged offer lists, and the 202 job answers', async () => { + const job = { + jobId: 'sbj_1', kind: 'lapsed_release', state: 'planning', + counts: { total: 0, done: 0, skipped: 0, failed: 0 }, createdAt: 1, updatedAt: 1, completedAt: null, + }; + const run = { job: { ...job, kind: 'renewal_run' }, batchId: 'srb_1' }; + const bringAcross = { job: { ...job, kind: 'holder_import' } }; + await expectOperations([ + { + name: 'releaseSeasonLapsedOffers', + run: (sdk) => sdk.seasons.releaseSeasonLapsedOffers('sea_a/b', { idempotencyKey: 'release-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/release-lapsed', idempotencyKey: 'release-1', + response: { status: 202, body: { job } }, returns: { job }, + }, + { + name: 'releaseSeasonLapsedOffers without a key', + run: (sdk) => sdk.seasons.releaseSeasonLapsedOffers('sea_1'), + method: 'POST', path: '/v1/seasons/sea_1/renewal-offers/release-lapsed', idempotencyKey: 'generated', + response: { status: 202, body: { job } }, + }, + { + name: 'listSeasonRenewalOffers paged', + run: (sdk) => sdk.seasons.listSeasonRenewalOffers('sea_a/b', { cursor: 'c/1', limit: 50 }), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/renewal-offers?cursor=c%2F1&limit=50', + response: { status: 200, body: { offers: [], truncated: false, nextCursor: null } }, + returns: { offers: [], truncated: false, nextCursor: null }, + }, + { + name: 'listSeasonRenewalOffers first page', + run: (sdk) => sdk.seasons.listSeasonRenewalOffers('sea_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/renewal-offers', + }, + { + name: 'retrieveSeasonHostedRenewals paged', + run: (sdk) => sdk.seasons.retrieveSeasonHostedRenewals('sea_a/b', { cursor: 'c/2', limit: 500 }), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/renewal-hosted?cursor=c%2F2&limit=500', + }, + { + name: 'createSeasonRenewalOffers above 100 contracts', + run: (sdk) => sdk.seasons.createSeasonRenewalOffers('sea_a/b', { deadlineAt: 1 }, { + idempotencyKey: 'offers-1', + }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers', body: { deadlineAt: 1 }, + idempotencyKey: 'offers-1', + response: { status: 202, body: run }, returns: run, + }, + { + name: 'createSeasonHolderImportFromSeason above 100 holders', + run: (sdk) => sdk.seasons.createSeasonHolderImportFromSeason('sea_a/b', { + sourceSeasonId: 'sea_0', successorPlanActivationId: 'spa_1', + }, { idempotencyKey: 'from-season-1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/imports/from-season', + body: { sourceSeasonId: 'sea_0', successorPlanActivationId: 'spa_1' }, idempotencyKey: 'from-season-1', + response: { status: 202, body: bringAcross }, returns: bringAcross, + }, + { + name: 'createSeasonRenewalNotices for one offer batch', + run: (sdk) => sdk.seasons.createSeasonRenewalNotices('sea_a/b', { batchId: 'srb_1' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-notices', body: { batchId: 'srb_1' }, + }, + ]); + }); + + it('types both answers of the renewal run and the bring-across, and the paged lists', () => { + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stubFetch([]).fetch }); + expectTypeOf(sdk.seasons.createSeasonRenewalOffers).returns.resolves + .toEqualTypeOf<{ offers: SeasonRenewalOffer[] } | SeasonRenewalOfferJob>(); + expectTypeOf().toEqualTypeOf<{ job: SeasonBulkJob; batchId: string }>(); + expectTypeOf(sdk.seasons.createSeasonHolderImportFromSeason).returns.resolves + .extract<{ job: SeasonBulkJob }>().toEqualTypeOf<{ job: SeasonBulkJob }>(); + expectTypeOf(sdk.seasons.releaseSeasonLapsedOffers).returns.resolves.toEqualTypeOf<{ job: SeasonBulkJob }>(); + expectTypeOf<'holder_import'>().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf>['nextCursor']>() + .toEqualTypeOf(); + expectTypeOf>['nextCursor']>() + .toEqualTypeOf(); + }); + + it('sends singleTickets on create and update, and returns the dates it opened', async () => { + const singleTicketDates = { + direction: 'open', opened: ['ev_1'], closed: [], + notOpened: [{ eventKey: 'ev_2', name: 'Night 2', salesState: 'setup', unmet: ['buyerCheckout'] }], + jobId: null, + }; + const updated = { season: { key: 'sea_1', singleTickets: true, plans: [] }, singleTicketDates }; + await expectOperations([ + { + name: 'createSeason with single tickets', + run: (sdk) => sdk.seasons.createSeason({ + name: 'Series', eventKeys: ['ev_1', 'ev_2'], singleTickets: true, + }, { idempotencyKey: 'create-singles' }), + method: 'POST', path: '/v1/seasons', + body: { name: 'Series', eventKeys: ['ev_1', 'ev_2'], singleTickets: true }, idempotencyKey: 'create-singles', + response: { status: 201, body: { season: { key: 'sea_1', singleTickets: true, plans: [] } } }, + }, + { + name: 'createSeason in test mode', + run: (sdk) => sdk.seasons.createSeason({ name: 'Rehearsal', eventKeys: ['ev_1', 'ev_2'], mode: 'test' }, { idempotencyKey: 'create-test' }), + method: 'POST', path: '/v1/seasons', + body: { name: 'Rehearsal', eventKeys: ['ev_1', 'ev_2'], mode: 'test' }, idempotencyKey: 'create-test', + response: { status: 201, body: { season: { key: 'sea_2', singleTickets: false, plans: [] } } }, + }, + { + name: 'updateSeason turning single tickets on', + run: (sdk) => sdk.seasons.updateSeason('sea_1', { expectedRevision: 5, singleTickets: true }, { + idempotencyKey: 'singles-on', + }), + method: 'PATCH', path: '/v1/seasons/sea_1', body: { expectedRevision: 5, singleTickets: true }, + idempotencyKey: 'singles-on', response: { status: 200, body: updated }, returns: updated, + }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stubFetch([]).fetch }); + expectTypeOf(sdk.seasons.updateSeason).returns.resolves.toEqualTypeOf(); + expectTypeOf(sdk.seasons.resumeSeasonSales).returns.resolves.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf<'season_singles'>().toMatchTypeOf(); + }); + + it('sends inventoryMode on create and update only when given, and reads it back on every season', async () => { + const blockSeason = { key: 'sea_1', inventoryMode: 'block', revision: 5, plans: [] }; + const updated = { season: { ...blockSeason, inventoryMode: 'per_hold' } }; + await expectOperations([ + { + name: 'createSeason with season seats on', + run: (sdk) => sdk.seasons.createSeason({ + name: 'Series', eventKeys: ['ev_1', 'ev_2'], inventoryMode: 'block', + }, { idempotencyKey: 'create-block' }), + method: 'POST', path: '/v1/seasons', + body: { name: 'Series', eventKeys: ['ev_1', 'ev_2'], inventoryMode: 'block' }, idempotencyKey: 'create-block', + response: { status: 201, body: { season: blockSeason } }, returns: { season: blockSeason }, + }, + { + name: 'createSeason leaving inventoryMode out sends none', + run: (sdk) => sdk.seasons.createSeason({ name: 'Series', eventKeys: ['ev_1', 'ev_2'] }, { idempotencyKey: 'create-default' }), + method: 'POST', path: '/v1/seasons', + body: { name: 'Series', eventKeys: ['ev_1', 'ev_2'] }, idempotencyKey: 'create-default', + response: { status: 201, body: { season: { key: 'sea_2', inventoryMode: 'per_hold', plans: [] } } }, + }, + { + name: 'updateSeason turning season seats on', + run: (sdk) => sdk.seasons.updateSeason('sea_1', { expectedRevision: 5, inventoryMode: 'block' }, { + idempotencyKey: 'block-on', + }), + method: 'PATCH', path: '/v1/seasons/sea_1', body: { expectedRevision: 5, inventoryMode: 'block' }, + idempotencyKey: 'block-on', response: { status: 200, body: { season: blockSeason } }, returns: { season: blockSeason }, + }, + { + name: 'updateSeason turning season seats off', + run: (sdk) => sdk.seasons.updateSeason('sea_1', { expectedRevision: 5, inventoryMode: 'per_hold' }, { + idempotencyKey: 'block-off', + }), + method: 'PATCH', path: '/v1/seasons/sea_1', body: { expectedRevision: 5, inventoryMode: 'per_hold' }, + idempotencyKey: 'block-off', response: { status: 200, body: updated }, returns: updated, + }, + { + name: 'updateSeason leaving inventoryMode out sends none', + run: (sdk) => sdk.seasons.updateSeason('sea_1', { expectedRevision: 5, name: 'Renamed' }, { idempotencyKey: 'rename' }), + method: 'PATCH', path: '/v1/seasons/sea_1', body: { expectedRevision: 5, name: 'Renamed' }, idempotencyKey: 'rename', + response: { status: 200, body: updated }, + }, + { + name: 'retrieveSeason reads inventoryMode back', + run: (sdk) => sdk.seasons.retrieveSeason('sea_1'), + method: 'GET', path: '/v1/seasons/sea_1', + response: { status: 200, body: { season: blockSeason } }, returns: { season: blockSeason }, + }, + { + name: 'listSeasons reads inventoryMode on each item', + run: (sdk) => sdk.seasons.listSeasons(), + method: 'GET', path: '/v1/seasons', + response: { status: 200, body: { seasons: [blockSeason, updated.season], nextCursor: null } }, + returns: { seasons: [blockSeason, updated.season], nextCursor: null }, + }, + ]); + expectTypeOf().toEqualTypeOf<'block' | 'per_hold'>(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('inventoryMode refusals, and opening sales before the seats are set aside, come through the typed error with their code', async () => { + const refusals = [ + { status: 409, body: { error: 'season_block_packages', code: 'season_block_packages' } }, + { status: 409, body: { error: 'season_block_on_sale', code: 'season_block_on_sale' } }, + { status: 409, body: { error: 'season_not_draft_or_active', code: 'season_not_draft_or_active' } }, + { status: 409, body: { error: 'season_block_exists', code: 'season_block_exists' } }, + { status: 422, body: { error: 'invalid_inventory_mode', code: 'invalid_inventory_mode' } }, + ]; + const notReady = { status: 409, body: { error: 'block_not_ready', code: 'block_not_ready' } }; + const stub = stubFetch([...refusals, notReady]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch }); + for (const refusal of refusals) { + const error = await sdk.seasons.updateSeason('sea_1', { expectedRevision: 5, inventoryMode: 'block' }) + .catch((thrown: unknown) => thrown); + expect(error).toBeInstanceOf(SeatLayerError); + expect((error as SeatLayerError).status).toBe(refusal.status); + expect((error as SeatLayerError).code).toBe(refusal.body.code); + } + const error = await sdk.seasons.openSeasonSales('sea_1', 5).catch((thrown: unknown) => thrown); + expect(error).toBeInstanceOf(SeatLayerError); + expect((error as SeatLayerError).status).toBe(409); + expect((error as SeatLayerError).code).toBe('block_not_ready'); + expect(stub.responses).toHaveLength(0); + }); + + it('retries the release of lapsed offers with one key', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'job_busy' } }, + { status: 202, body: { job: { jobId: 'sbj_1' } } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 2 }); + await sdk.seasons.releaseSeasonLapsedOffers('sea_1'); + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(stub.calls.map((call) => call.init.body)).toEqual([undefined, undefined]); + }); + + it('retries a bulk refund with one key', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 202, body: { job: { jobId: 'sbj_1' } } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 2 }); + await sdk.seasons.refundSeasonPerformance('sea_1', 'ev_1', { amount: 'per_date' }); + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + }); + + it('sends price types, season sale settings, holder contacts, and date cancels', async () => { + await expectOperations([ + { + name: 'replaceSeasonPackagePrices with a child price', + run: (sdk) => sdk.seasons.replaceSeasonPackagePrices('sea_1', 'spl_1', { + prices: [ + { categoryKey: 'stalls', unitPrice: 18_000 }, + { categoryKey: 'stalls', priceTypeKey: 'child', unitPrice: 9_000 }, + ], + }, { idempotencyKey: 'prices-2' }), + method: 'PUT', path: '/v1/seasons/sea_1/plans/spl_1/prices', + body: { prices: [ + { categoryKey: 'stalls', unitPrice: 18_000 }, + { categoryKey: 'stalls', priceTypeKey: 'child', unitPrice: 9_000 }, + ] }, + idempotencyKey: 'prices-2', + }, + { + name: 'updateSeason with checkout mode and cutoffs', + run: (sdk) => sdk.seasons.updateSeason('sea_1', { + expectedRevision: 3, checkoutMode: 'seatlayer', transferCutoffHours: 24, returnCutoffHours: null, + }, { idempotencyKey: 'sell-1' }), + method: 'PATCH', path: '/v1/seasons/sea_1', + body: { expectedRevision: 3, checkoutMode: 'seatlayer', transferCutoffHours: 24, returnCutoffHours: null }, + idempotencyKey: 'sell-1', + }, + { + name: 'createSeasonHolderImport with holder contacts', + run: (sdk) => sdk.seasons.createSeasonHolderImport('sea_1', { + successorPlanActivationId: 'spa_1', + rows: [{ + rowId: 'r1', holderRef: 'h1', priorPlanActivationId: 'spa_0', priorContractRef: 'c1', labels: ['A-1'], + holderEmail: 'ana@example.com', holderName: 'Ana Silva', + }], + }, { idempotencyKey: 'import-2' }), + method: 'POST', path: '/v1/seasons/sea_1/imports', + body: { + successorPlanActivationId: 'spa_1', + rows: [{ + rowId: 'r1', holderRef: 'h1', priorPlanActivationId: 'spa_0', priorContractRef: 'c1', labels: ['A-1'], + holderEmail: 'ana@example.com', holderName: 'Ana Silva', + }], + }, + idempotencyKey: 'import-2', response: { status: 201, body: { import: { importId: 'sim_1' } } }, + }, + { + name: 'createSeasonAmendment cancel with a refund', + run: (sdk) => sdk.seasons.createSeasonAmendment('sea_1', { + eventKey: 'ev_night_3', kind: 'cancel', refund: 'per_date', reason: 'Storm', + }, { idempotencyKey: 'night-3-storm' }), + method: 'POST', path: '/v1/seasons/sea_1/amendments', + body: { eventKey: 'ev_night_3', kind: 'cancel', refund: 'per_date', reason: 'Storm' }, + idempotencyKey: 'night-3-storm', + response: { status: 201, body: { amendment: { kind: 'cancel' }, refundJob: { jobId: 'sbj_1', kind: 'bulk_refund' } } }, + }, + { + name: 'createSeasonAmendment postpone', + run: (sdk) => sdk.seasons.createSeasonAmendment('sea_1', { eventKey: 'ev_1', kind: 'postpone' }), + method: 'POST', path: '/v1/seasons/sea_1/amendments', + body: { eventKey: 'ev_1', kind: 'postpone' }, idempotencyKey: 'generated', + response: { status: 201, body: { amendment: { kind: 'postpone' } } }, + }, + ]); + }); + + it('types price types on prices, holds, and renewal seats, and the new amendment kinds', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf[number]['priceTypeKey']>().toEqualTypeOf(); + expectTypeOf<'performance_added'>().toMatchTypeOf(); + expectTypeOf<'performance_removed'>().toMatchTypeOf(); + expectTypeOf<'cancel'>().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('maps renewal by API: the quote, the phone intent, the checkout, and a decline reason', async () => { + const quote = { + offerId: 'sro_a/b', state: 'offered', totalMinor: 13_500, currency: 'GBP', + lines: [{ label: 'A-1', category: 'standard', priceTypeKey: 'adult', amountMinor: 9_000 }], + performances: [{ eventKey: 'ev_1', name: 'Night 1', startsAt: 1_790_000_000_000 }], + problem: null, + }; + const started = { + orderId: 'ord_1', totalMinor: 13_500, currency: 'GBP', expiresAt: 1_790_000_900_000, + payment: { url: 'https://checkout.provider.example/pay' }, + }; + const declined = { offer: { offerId: 'sro_a/b', state: 'declined', declineReason: 'Moving away' } }; + await expectOperations([ + { + name: 'retrieveSeasonRenewalQuote', + run: (sdk) => sdk.seasons.retrieveSeasonRenewalQuote('sea_a/b', 'sro_a/b'), + method: 'GET', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro_a%2Fb/quote', + response: { status: 200, body: quote }, returns: quote, + }, + { + name: 'createSeasonRenewalIntent', + run: (sdk) => sdk.seasons.createSeasonRenewalIntent('sea_a/b', 'sro_a/b'), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro_a%2Fb/intent', + response: { status: 200, body: { offer: { offerId: 'sro_a/b', state: 'intent_received' } } }, + }, + { + name: 'createSeasonRenewalCheckout', + run: (sdk) => sdk.seasons.createSeasonRenewalCheckout('sea_a/b', 'sro_a/b', { + returnUrl: 'https://tickets.example/renewed', buyerEmail: 'ana@example.com', + buyerName: 'Ana Silva', provider: 'stripe', + }, { idempotencyKey: 'renew-ana-2027' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro_a%2Fb/checkout', + body: { + returnUrl: 'https://tickets.example/renewed', buyerEmail: 'ana@example.com', + buyerName: 'Ana Silva', provider: 'stripe', + }, + idempotencyKey: 'renew-ana-2027', response: { status: 201, body: started }, returns: started, + }, + { + name: 'createSeasonRenewalCheckout without a key', + run: (sdk) => sdk.seasons.createSeasonRenewalCheckout('sea_1', 'sro_1', { + returnUrl: 'https://tickets.example/renewed', + }), + method: 'POST', path: '/v1/seasons/sea_1/renewal-offers/sro_1/checkout', + body: { returnUrl: 'https://tickets.example/renewed' }, idempotencyKey: 'generated', + response: { status: 201, body: started }, + }, + { + name: 'declineSeasonRenewalOffer with a reason', + run: (sdk) => sdk.seasons.declineSeasonRenewalOffer('sea_a/b', 'sro_a/b', { reason: 'Moving away' }), + method: 'POST', path: '/v1/seasons/sea_a%2Fb/renewal-offers/sro_a%2Fb/decline', + body: { reason: 'Moving away' }, response: { status: 200, body: declined }, + }, + { + name: 'declineSeasonRenewalOffer without a reason', + run: (sdk) => sdk.seasons.declineSeasonRenewalOffer('sea_1', 'sro_1'), + method: 'POST', path: '/v1/seasons/sea_1/renewal-offers/sro_1/decline', body: {}, + }, + ]); + }); + + it('retries a renewal checkout with one key, but sends the intent once', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 201, body: { orderId: 'ord_1' } }, + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 201, body: { orderId: 'ord_1' } }, + { status: 503, body: { error: 'unavailable' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 2 }); + const params = { returnUrl: 'https://tickets.example/renewed' }; + + await sdk.seasons.createSeasonRenewalCheckout('sea_1', 'sro_1', params); + await sdk.seasons.createSeasonRenewalCheckout('sea_1', 'sro_1', params, { idempotencyKey: 'renew-1' }); + await expect(sdk.seasons.createSeasonRenewalIntent('sea_1', 'sro_1')).rejects.toBeInstanceOf(SeatLayerError); + + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(stub.calls.map((call) => new URL(call.url).pathname)).toEqual([ + ...Array(4).fill('/v1/seasons/sea_1/renewal-offers/sro_1/checkout'), + '/v1/seasons/sea_1/renewal-offers/sro_1/intent', + ]); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(headers[2]!['Idempotency-Key']).toBe('renew-1'); + expect(headers[3]!['Idempotency-Key']).toBe('renew-1'); + expect(headers[4]!['Idempotency-Key']).toBeUndefined(); + expect(stub.calls[4]!.init.body).toBeUndefined(); + expect(stub.responses).toHaveLength(0); + }); + + it('types the renewal quote, the started checkout, and the decline reason', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf<'prices_missing'>().toMatchTypeOf>(); + expectTypeOf().toEqualTypeOf(); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stubFetch([]).fetch }); + expectTypeOf(sdk.seasons.createSeasonRenewalCheckout).returns.resolves + .toEqualTypeOf(); + expectTypeOf(sdk.holders.createHolderRenewalCheckout).returns.resolves + .toEqualTypeOf(); + }); + + it('names the job voiding a cancelled date, and sizes a holder import call by its dates', async () => { + expect(seasonDateCancelVoidJobId('amd_1')).toBe('sbj_void_amd_1'); + expectTypeOf<'date_cancel_void'>().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + expect([1, 20, 21, 50].map(seasonHolderImportRowsPerCall)).toEqual([100, 100, 95, 43]); + + const stub = stubFetch([{ status: 422, body: { + error: 'invalid_rows', code: 'invalid_rows', message: 'At most 43 rows a call for this package.', maxRows: 43, + } }]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch }); + const error = await sdk.seasons.createSeasonHolderImport('sea_1', { successorPlanActivationId: 'spa_1', rows: [] }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(SeatLayerValidationError); + expect((error as SeatLayerValidationError).code).toBe('invalid_rows'); + expect((error as SeatLayerValidationError).body.maxRows).toBe(43); + }); + + it('exposes exactly 90 season operations (87 + the 3 season seats calls), plus the polling helpers and the import alias', () => { + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stubFetch([]).fetch }); + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(sdk.seasons)) + .filter((name) => name !== 'constructor'); + const helpers = methods.filter((name) => name.startsWith('waitFor') || name === 'importSeasonHolders'); + expect(helpers.sort()).toEqual([ + 'importSeasonHolders', 'waitForSeasonBooking', 'waitForSeasonLifecycle', 'waitForSeasonRenewal', + ]); + expect(methods.length - helpers.length).toBe(90); + }); + + it('types CSV exports as text and the 204 plan delete as void', () => { + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stubFetch([]).fetch }); + expectTypeOf(sdk.seasons.exportSeasonHoldersCsv).returns.resolves.toEqualTypeOf(); + expectTypeOf(sdk.seasons.exportSeasonOrdersCsv).returns.resolves.toEqualTypeOf(); + expectTypeOf(sdk.seasons.deleteSeasonPlan).returns.resolves.toEqualTypeOf(); + }); + + it('asks for CSV and still maps an export failure to the typed error', async () => { + const stub = stubFetch([ + { status: 200, rawBody: 'a,b\n', headers: { 'content-type': 'text/csv' } }, + { status: 404, body: { error: 'not_found', message: 'No such Season.' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch }); + await expect(sdk.seasons.exportSeasonOrdersCsv('sea_1')).resolves.toBe('a,b\n'); + await expect(sdk.seasons.exportSeasonOrdersCsv('sea_missing')).rejects.toBeInstanceOf(SeatLayerError); + expect((stub.calls[0]!.init.headers as Record).Accept).toContain('text/csv'); + }); + + it('sends no body when an optional season body is left out', async () => { + await expectOperations([ + { + name: 'createSeasonCheckoutTest without params', + run: (sdk) => sdk.seasons.createSeasonCheckoutTest('sea_1'), + method: 'POST', path: '/v1/seasons/sea_1/checkout-tests', idempotencyKey: 'generated', + }, + { + name: 'refundSeasonOrder in full', + run: (sdk) => sdk.seasons.refundSeasonOrder('sea_1', 'ord_1'), + method: 'POST', path: '/v1/seasons/sea_1/orders/ord_1/refund', idempotencyKey: 'generated', + }, + { + name: 'createSeasonRenewalNotices for every offer', + run: (sdk) => sdk.seasons.createSeasonRenewalNotices('sea_1'), + method: 'POST', path: '/v1/seasons/sea_1/renewal-notices', + }, + ]); + }); + + it('retries a header-replay price PUT with one key, but never retries unsupported or one-time-secret calls', async () => { + const stub = stubFetch([ + { status: 503, body: { error: 'unavailable' } }, + { status: 200, body: { planKey: 'spl_1', currency: 'GBP', prices: [] } }, + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 200, body: { holders: [], nextCursor: null } }, + { status: 503, body: { error: 'unavailable' } }, + { status: 429, body: { error: 'rate_limited' }, headers: { 'retry-after': '0' } }, + { status: 503, body: { error: 'unavailable' } }, + ]); + const sdk = new SeatLayer({ secretKey: 'sk_test_abc', fetch: stub.fetch, maxRetries: 2 }); + + await sdk.seasons.replaceSeasonPackagePrices('sea_1', 'spl_1', { prices: [] }); + await sdk.seasons.createSeasonHolderImportFromSeason('sea_1', { + sourceSeasonId: 'sea_0', successorPlanActivationId: 'spa_1', + }); + await expect(sdk.seasons.resendSeasonOrderTickets('sea_1', 'ord_1')).rejects.toBeInstanceOf(SeatLayerError); + await expect(sdk.seasons.rotateSeasonRenewalOfferLink('sea_1', 'sro_1')) + .rejects.toBeInstanceOf(SeatLayerRateLimitError); + await expect(sdk.seasons.returnSeasonOccurrenceInventory('sea_1', 'ev_1', { + actionId: 'sra_1', bookingRef: 'order_1', planActivationId: 'spa_1', labels: ['A-1'], + source: { bookingActionId: 'sba_1' }, + })).rejects.toBeInstanceOf(SeatLayerError); + + const headers = stub.calls.map((call) => call.init.headers as Record); + expect(stub.calls.map((call) => call.init.method)).toEqual(['PUT', 'PUT', 'POST', 'POST', 'POST', 'POST', 'POST']); + expect(headers[0]!['Idempotency-Key']).toMatch(GENERATED_KEY); + expect(headers[1]!['Idempotency-Key']).toBe(headers[0]!['Idempotency-Key']); + expect(headers[3]!['Idempotency-Key']).toBe(headers[2]!['Idempotency-Key']); + for (const index of [4, 5, 6]) expect(headers[index]!['Idempotency-Key']).toBeUndefined(); + expect(stub.responses).toHaveLength(0); + }); });