diff --git a/CHANGELOG.md b/CHANGELOG.md index b3814be..b3b94cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,105 @@ # 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: `Seasons::setAsideSeasonBlock()` (`POST /v1/seasons/:key/block`), + `retrieveSeasonBlock()` and `releaseSeasonBlock()` (`POST /v1/seasons/:key/block/release`). Each + write answers `job` and `block`; follow the job with `retrieveSeasonBulkJob()` (kinds + `season_block_set_aside` and `season_block_release`, read by id, not listed). Both writes are + retried safely with one Idempotency-Key, generated when none is given. +- `inventoryMode` on season create and update (opt a season into season seats), read back on every + season. +- Webhook names `season.block.ready` and `season.block.released` (`Webhooks` takes any name the + API accepts). +- A season-seats season's date cancel (`createSeasonAmendment()`) or past date + (`markSeasonPerformancePast()`) can answer `202` while the date leaves every season seat: + `Seasons::isDateLeavePending()` recognises it. +- New refusals come through `SeatLayerException` and its subclasses with `errorCode`: + `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; its + `categories` is typed `non-empty-list`. +- 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, named by + `Seasons::dateCancelVoidJobId($amendmentId)` (`sbj_void_…`) and followed with + `retrieveSeasonBulkJob`; `season.ticket.voided` says `date_cancelled` for each season ticket + it voids. +- A Season package holds 2 to 50 dates. `Seasons::holderImportRowsPerCall($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` in the exception's `body`. +- `updateSeason`'s `singleTicketDates` can carry `jobRefused`, 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` (a `ConflictException`) + with a message to send it again with a new idempotency key. + +- Added the 39 remaining Season organizer operations to `$seatlayer->seasons`, which now + has 87: performances and packages (`createSeasonPerformances`, `attachSeasonPerformances`, + `updateSeasonPlan`, `deleteSeasonPlan`, `publishSeason`, and more), package prices, + checkout tests, occurrence returns and reclaims, `listSeasonHolders`, season order refunds, + cancellations and resends, hosted renewals, renewal links, contacts and notices, renewal + quotes, intents and checkouts, the sales, + seats and seat-count reports, holder and order CSV exports, and bulk refunds and lapsed-offer + releases with their jobs. +- Added `$seatlayer->holders` (holder reads and updates, `listCustomers`, session sign-out, + tickets, and ticket transfers), `$seatlayer->orders` (orders across events and seasons, and + one event's orders with refund, cancel, resend, and CSV export), and `$seatlayer->eventSeries` + (plan a series from a schedule, add dates, change every upcoming date). +- Added `holders->createHolderReturn`: give back a holder's seat on one date, one per ticket. +- Added event sales settings to `$seatlayer->events`: buyer checkout, payment gateway, sales + readiness, preflight and transitions, booking fee, and discount codes. +- Replay-backed operations send an `Idempotency-Key` (yours, or a generated one reused on + retry), as before; every other new mutation is sent once. The CSV exports return the CSV + text as a string, and the two `204` deletes return nothing. +- Added `seasons->refundSeasonPerformance` (refund every order of a cancelled date as a job, + with header replay), `seasons->listSeasonBulkJobs`, and `seasons->retrieveSeasonBulkJob`. +- `replaceSeasonPackagePrices` types `priceTypeKey` on each price (`adult`, `child`, + `concession`, `senior`, `student`). +- `createSeasonAmendment` types the `postpone` and `cancel` kinds, with `refund` and `reason`. +- `updateSeason` types `checkoutMode`, `transferCutoffHours`, and `returnCutoffHours`. +- `createSeasonHolderImport` documents `holderEmail` and `holderName` on each row. +- `events->list` and `events->listAll` take `seriesKey` to list one event series' dates. +- Added `seasons->retrieveSeasonRenewalQuote`: what renewing an offer costs, and whether SeatLayer checkout can take it now. +- Added `seasons->createSeasonRenewalIntent`: record a holder's intent for a renewal taken by phone or at the box office, so your own checkout can commit. +- Added `seasons->createSeasonRenewalCheckout`: a SeatLayer checkout that renews an offer with no hosted page, with header replay. +- Added `holders->createHolderRenewalCheckout`: the same checkout for one of a holder's offers, with header replay. +- Added `holders->declineHolderRenewalOffer`: decline one of a holder's offers, with an optional reason. +- `seasons->declineSeasonRenewalOffer` takes an optional `$reason`, kept on the offer as `declineReason`. +- `updateSeason` takes `audience` (`embed_only` or `public`). +- `openSeasonSales` answers `warnings` (`checkout_test_missing` until a checkout test or rehearsal has passed) and can refuse with `409 plan_not_published` or `409 checkout_mode_not_chosen`. +- A cancelled date refunds per date only: an amendment's `refund` is `none` or `per_date`, and `refundSeasonPerformance` takes `amount` `per_date` (`full` answers `422 date_refund_per_date_only`; refund a whole order with the order refund). +- `addEventSeriesDates` takes `['retryFailed' => true]` to make the failed dates again (`409 series_busy`, `422 nothing_to_retry`). +- `listOrders` takes `$holderRef` (last argument); orders carry `holderRef` and `customerId`, and `buyer.email` can be null (a box office sale with no email). +- Season holds, give-backs and date cancels can refuse with `performance_cancelled`, `refund_not_started` (503), `missing_manage_capability` (403) or `invalid_refund`; a holder's status can be `reserved`; hosted renewal readiness carries `missingPrices`. +- `eventSeries->createEventSeries` takes `['fromEventKey' => …, 'schedule' => …]`: an event you already set up 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}`. +- Added `seasons->releaseSeasonLapsedOffers`: release the seats of every lapsed renewal offer as a job (`202` with `job`), with header replay. An offer extended before its turn is skipped and keeps its seat. +- `retrieveSeasonHostedRenewals` and `listSeasonRenewalOffers` take `$cursor` and `$limit` (last arguments) and answer `nextCursor`, null on the last page: up to 500 offers a page for hosted renewals (the default), 100 for the offer list. A bad cursor or limit answers `400 invalid_cursor` or `invalid_limit`. +- `createSeasonRenewalOffers` answers `201` with `offers` for 100 contracts or fewer, and `202` with `job` and `batchId` above that: a `renewal_run` job makes the offers. Follow it with `retrieveSeasonBulkJob`, then page the offers with `listSeasonRenewalOffers`. +- `createSeasonHolderImportFromSeason` answers `202` with a `holder_import` `job` when a commit brings across more than 100 holders; the completed job's `summary` has the same counts. 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; above that it answers `422 too_many_offer_ids`. +- `createSeasonRenewalNotices` takes `$batchId` (last argument) 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` in their params (default `false`, Season only; anything else answers `422 invalid_single_tickets`). Every season read carries `singleTickets`, and a season on sale may carry `singleTicketsNotOpen`. `updateSeason`, `openSeasonSales` and `resumeSeasonSales` answer `singleTicketDates` (`direction`, `opened`, `closed`, `notOpened`, `jobId`) when they open or stop the dates' own sales; a `season_singles` job does it for more than 12 upcoming dates. + ## 0.8.0 (2026-09-10) - Added Event hosting regions: `$region` on event create, `$defaultRegion` on workspace create and update, and the `EventHostingRegion` constants. Events created without a region run in Western Europe. diff --git a/README.md b/README.md index a34ffee..8e6bc68 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,14 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -Version `0.7.0` exposes all 48 trusted organizer operations through -`$seatlayer->seasons`. +`$seatlayer->seasons` exposes 87 trusted organizer operations: the 48 released in `0.7.0`, +plus performances and packages, package prices (each may name a `priceTypeKey`: `adult`, +`child`, `concession`, `senior` or `student`), checkout tests, occurrence returns and +reclaims, holders, season orders, hosted renewals, renewal quotes, intents and checkouts, +sales and seat reports, CSV exports, and +the bulk refund of a cancelled date (`refundSeasonPerformance`) and the release of every +lapsed renewal offer's seats (`releaseSeasonLapsedOffers`) with their jobs +(`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal($seasonKey)` sends no evidence body; SeatLayer @@ -103,6 +109,103 @@ returned operation identity. Buyer-session minting and domain-exact booking, cancellation, and renewal actions remain single-attempt; only declared header-replay catalogue mutations retry automatically. +Above 100 contracts, `createSeasonRenewalOffers()` answers `202` with a `renewal_run` `job` +and its `batchId` instead of `offers`; above 100 holders, `createSeasonHolderImportFromSeason()` +answers `202` with a `holder_import` `job`. Follow either with `retrieveSeasonBulkJob()`. +`listSeasonRenewalOffers()` and `retrieveSeasonHostedRenewals()` read one page at a time: pass +the answer's `nextCursor` back as `$cursor` until it is null. + +`exportSeasonHoldersCsv()` and `exportSeasonOrdersCsv()` return the CSV text as a string, +not decoded JSON. `deleteSeasonPlan()` returns nothing (the API answers `204`). + +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 `payment.url`. For a renewal taken by phone or at the +box office, `createSeasonRenewalIntent()` records the holder's intent before you commit the +offer. A decline can carry the holder's reason. + +```php +$quote = $seatlayer->seasons->retrieveSeasonRenewalQuote($seasonKey, $offerId); +if ($quote['problem'] === null) { + $started = $seatlayer->seasons->createSeasonRenewalCheckout( + $seasonKey, + $offerId, + ['returnUrl' => 'https://tickets.yourclub.example/renewed'], + "renew-{$offerId}", + ); + header('Location: ' . $started['payment']['url']); +} + +$seatlayer->holders->declineHolderRenewalOffer('holder_123', $offerId, 'Moving away'); +``` + +### 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'` to +`createSeason()` or `updateSeason()`. The season needs one package, and its sales must not have +opened. + +```php +$seatlayer->seasons->updateSeason($seasonKey, ['expectedRevision' => $revision, 'inventoryMode' => 'block']); + +$job = $seatlayer->seasons->setAsideSeasonBlock($seasonKey, [ + 'expectedRevision' => $revision, + 'keepForSingleTickets' => ['sectionIds' => ['sec_family']], +])['job']; +while (in_array($job['state'], ['planning', 'running'], true)) { + sleep(2); + $job = $seatlayer->seasons->retrieveSeasonBulkJob($seasonKey, $job['jobId'])['job']; +} +$block = $seatlayer->seasons->retrieveSeasonBlock($seasonKey)['block']; + +// Release unsold season seats to single sale. Leave out 'at' to release now. +$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 `SeatLayer\Resources\Seasons::isDateLeavePending($answer)`, then + send the same request again after `Retry-After`. +- Refusals come through `SeatLayerException` with `errorCode`, 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. + +## Orders, holders, event sales, and event series + +- `$seatlayer->orders` lists orders across events and seasons, reads one order and its + tickets, and lists, refunds, cancels, or resends one event's orders. + `exportEventOrdersCsv()` returns the CSV text. +- `$seatlayer->holders` reads and updates season ticket holders, finds customers with + `listCustomers()`, signs a holder out, lists their seasons and tickets, and sends, + cancels, or accepts ticket transfers. +- `$seatlayer->events` also holds each event's sales settings: buyer checkout, payment + gateway, sales readiness, preflight and transitions, booking fee, and discount codes. +- `$seatlayer->eventSeries` plans a series of dated events from a schedule, adds dates, + and changes every upcoming date at once. + +```php +$paid = $seatlayer->orders->listEventOrders($eventKey, status: 'paid'); +$seatlayer->orders->refundEventOrder( + $eventKey, + $orderId, + amountMinor: 500, + idempotencyKey: "refund-{$refundId}", +); +$csv = $seatlayer->orders->exportEventOrdersCsv($eventKey); // CSV text +``` + ## Test vs live Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only @@ -206,6 +309,8 @@ exactly when you don't want that, and you can control it explicitly: $seatlayer->events->list(limit: 50, counts: false); ``` +Pass `seriesKey: 'ser_1'` to list only the dates of one event series. + ## Keeping a hold alive When an order takes longer than the checkout window (an invoice, a phone sale), extend rather than @@ -320,14 +425,25 @@ support requests. ## Reliability **Retries and idempotency.** Reads (`GET`/`HEAD`) retry connection failures, 408, 429 and 5xx with -exponential backoff and full jitter; `Retry-After` wins when the server sends it. Fourteen mutations -use exact header replay: `charts->create`, `charts->copy`, -`templates->instantiateTemplate`, `events->create`, `workspaces->create`, -`performanceGroups->create`, `seasons->createSeason`, `seasons->updateSeason`, -`seasons->deleteSeason`, `seasons->createSeasonPlan`, `seasons->duplicateSeasonToLive`, -`seasons->createSeasonHolderImport`, `seasons->createSeasonRenewalOffers`, and -`seasons->createSeasonAmendment`. They generate an `Idempotency-Key` when absent and reuse that key -across every attempt. You can supply a stable provisioning key instead: +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Forty-one +mutations use exact header replay: + +- `charts->create`, `charts->copy`, `templates->instantiateTemplate`, `events->create`, + `workspaces->create`, and `performanceGroups->create`; +- on `seasons`: `createSeason`, `updateSeason`, `deleteSeason`, `publishSeason`, + `createSeasonPlan`, `updateSeasonPlan`, `deleteSeasonPlan`, `duplicateSeasonToLive`, + `createSeasonPerformances`, `attachSeasonPerformances`, `deleteSeasonPerformance`, + `markSeasonPerformancePast`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, + `replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `createSeasonHolderImport`, + `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, + `refundSeasonPerformance`, `releaseSeasonLapsedOffers`, and `createSeasonRenewalCheckout`; +- `holders->updateHolder`, `holders->createHolderTransfer`, `holders->createHolderReturn`, + `holders->createHolderRenewalCheckout`, `orders->refundEventOrder`, `orders->cancelEventOrder`, `eventSeries->createEventSeries`, + `eventSeries->addEventSeriesDates`, and `eventSeries->updateEventSeries`. + +They generate an `Idempotency-Key` when absent and reuse that key across every attempt. You can +supply a stable provisioning key instead: ```php $seatlayer->events->create( @@ -338,7 +454,8 @@ $seatlayer->events->create( ``` All remaining SDK mutations are single-attempt: holds, bookings, lifecycle changes, channel -changes, show-once secret creation, and raw requests. Some have a server-side domain idempotency +changes, event sales settings and discount codes, ticket and renewal emails, show-once secret +creation, and raw requests. Some have a server-side domain idempotency contract, but the SDK does not retry them automatically. Reconcile bookings with their required `bookingRef`; never retry an unknown booking outcome as though the transport had made it safe. @@ -365,21 +482,24 @@ suite runs without a network. ## API surface 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. +Seasons cover catalogue, plan, performance, price, sales, buyer-session, booking, renewal, +occurrence, holder, order, reporting, outbox, and support operations. | Resource | Methods | | --- | --- | | `charts` | `list` `listAll` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | | `templates` | `instantiateTemplate` | -| `events` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` | +| `events` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `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` | | `channels` | `listChannels` `createChannel` `updateChannel` `updateAssignments` `listAllocation` `retrieveAccessPreview` `retrieveReport` `pause` `unpause` `archive` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` | | `inventory` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` `listBookings` `retrieveBooking` | | `sessions` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` | | `webhooks` | `list` `create` `update` `delete` `listDeliveries` | | `workspaces` | `list` `create` `retrieve` `update` | | `performanceGroups` | `list` `create` `retrieve` `delete` `activate` `close` `retrieveLifecycle` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` `retrieveHold` `bookHold` `retrieveBooking` | -| `seasons` | 48 operations for catalogue and Plan lifecycle, sales windows, buyer access and booking, holder imports, renewals, occurrence amendments, reports, audit, outbox, and support export | +| `seasons` | 87 operations for catalogue and Plan lifecycle, performances and packages, package prices, checkout tests, sales windows, buyer access and booking, holder imports, holders, season orders, renewals, hosted renewals and renewal checkouts, occurrence amendments, returns and reclaims, bulk refunds, lapsed-offer releases and jobs, reports and CSV exports, audit, outbox, and support export | Full reference: [SeatLayer PHP server SDK guide](https://docs.seatlayer.io/server-sdk/php/) diff --git a/src/HttpClient.php b/src/HttpClient.php index 104ab11..11dfcd3 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -98,6 +98,7 @@ private function performRequest( bool $headerReplay, ?string $idempotencyKey, ?string $contentType = null, + bool $rawText = false, ): mixed { $url = $this->baseUrl . $path; if ($query !== null) { @@ -109,7 +110,8 @@ private function performRequest( $headers = [ 'Authorization' => 'Bearer ' . $this->secretKey, - 'Accept' => 'application/json', + // Errors are JSON even on a text route, so a text read accepts both. + 'Accept' => $rawText ? 'text/csv, application/json' : 'application/json', 'User-Agent' => 'seatlayer-php', ]; @@ -142,6 +144,9 @@ private function performRequest( $status = $response['status']; if ($status >= 200 && $status < 300) { + if ($rawText) { + return $response['body']; + } if ($status === 204 || $response['body'] === '') { return null; } @@ -176,6 +181,18 @@ public function get(string $path, ?array $query = null): mixed return $this->request('GET', $path, $query); } + /** + * GET a text body, such as a CSV export, and return it as sent, without JSON decoding. + * + * @param array|null $query + */ + public function getText(string $path, ?array $query = null): string + { + $body = $this->performRequest('GET', $path, $query, null, false, null, null, true); + + return is_string($body) ? $body : ''; + } + /** @param array|null $body */ public function post(string $path, ?array $body = null, ?string $idempotencyKey = null): mixed { @@ -234,6 +251,16 @@ public function put(string $path, array $body): mixed return $this->performRequest('PUT', $path, null, $body, false, null); } + /** + * PUT a JSON object without automatic retries, including `{}` when empty. + * + * @param array $body + */ + public function putObject(string $path, array $body = []): mixed + { + return $this->performRequest('PUT', $path, null, (object) $body, false, null); + } + /** Upload raw poster bytes without JSON/base64 transformation. */ public function putBinary(string $path, string $bytes, string $contentType): mixed { diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php new file mode 100644 index 0000000..a3c4141 --- /dev/null +++ b/src/Resources/EventSeries.php @@ -0,0 +1,109 @@ +,from?:string,until?:string,count?:int,skip?:list,dates?:list},timezone?:string,venue?:string,durationMinutes?:int,dryRun?:bool}|array{fromEventKey:string,schedule:array{rule:'none'|'daily'|'weekly'|'biweekly'|'dates',times:list,from?:string,until?:string,count?:int,skip?:list,dates?:list},name?:string,timezone?:string,dryRun?:bool} $params + * @return array + */ + public function createEventSeries(array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postWithHeaderReplay('/v1/event-series', $params, $idempotencyKey); + } + + /** + * List event series, one page at a time. + * + * @return array + */ + public function listEventSeries(?int $limit = null, ?string $cursor = null): array + { + /** @var array */ + return (array) $this->http->get('/v1/event-series', array_filter([ + 'limit' => $limit, + 'cursor' => $cursor, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Retrieve an event series, its dates and its job. + * + * @return array + */ + public function retrieveEventSeries(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/event-series/' . HttpClient::encode($eventKey)); + } + + /** + * Add dates to an event series, copied from its first date: a `schedule`, exact `dates` + * (`YYYY-MM-DDTHH:MM` in the series' zone), or `['retryFailed' => true]` to make again every + * date that failed to be made (`422 nothing_to_retry` when none did, `409 series_busy` while + * another change runs). + * + * @param array{schedule:array{rule:'none'|'daily'|'weekly'|'biweekly'|'dates',times:list,from?:string,until?:string,count?:int,skip?:list,dates?:list}}|array{dates:list}|array{retryFailed:true} $params + * @return array + */ + public function addEventSeriesDates(string $eventKey, array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + '/v1/event-series/' . HttpClient::encode($eventKey) . '/dates', + $params, + $idempotencyKey, + ); + } + + /** + * Change the name, prices or sales of every upcoming date. + * + * `prices.categories` takes only the categories whose price changes (for a tiered category, + * only the tiers sent). Every price left out keeps each date's own value. + * + * `sales` `on` never opens a date its season cancelled: that date is a failed item of the job + * (`season_date_cancelled`), counted in the job's `failed` but not in `failedDates`, which + * are the dates that could not be made. + * + * @param array{name?:string,prices?:array{categories:non-empty-list}>},sales?:'on'|'off'} $params + * @return array + */ + public function updateEventSeries(string $eventKey, array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'PATCH', + '/v1/event-series/' . HttpClient::encode($eventKey), + $params, + $idempotencyKey, + ); + } +} diff --git a/src/Resources/Events.php b/src/Resources/Events.php index 81bfa6b..ea9e2eb 100644 --- a/src/Resources/Events.php +++ b/src/Resources/Events.php @@ -19,7 +19,8 @@ public function __construct(private readonly HttpClient $http) * * Live availability `counts` cost one round-trip per event server-side. They * are on by default because most callers want them; pass `counts: false` when - * paging a whole catalogue, where you almost certainly do not. + * paging a whole catalogue, where you almost certainly do not. `seriesKey` lists + * only the dates of one event series. * * @return array */ @@ -29,6 +30,7 @@ public function list( ?int $limit = null, ?string $cursor = null, bool $counts = true, + ?string $seriesKey = null, ): array { $query = [ 'workspaceId' => $workspaceId, @@ -39,6 +41,7 @@ public function list( if (!$counts) { $query['counts'] = '0'; } + $query['seriesKey'] = $seriesKey; /** @var array */ return (array) $this->http->get('/v1/events', $query); @@ -56,10 +59,11 @@ public function listAll( ?string $externalRef = null, ?int $limit = null, bool $counts = false, + ?string $seriesKey = null, ): Generator { $cursor = null; do { - $page = $this->list($workspaceId, $externalRef, $limit, $cursor, $counts); + $page = $this->list($workspaceId, $externalRef, $limit, $cursor, $counts, $seriesKey); $events = $page['events'] ?? []; if (is_array($events)) { foreach ($events as $event) { @@ -218,6 +222,10 @@ public function close(string $eventKey): mixed return $this->http->post('/v1/events/' . HttpClient::encode($eventKey) . '/close'); } + /** + * Open a paused event's sales again. A date its season cancelled never opens again: + * `409 season_date_cancelled`. + */ public function reopen(string $eventKey): mixed { return $this->http->post('/v1/events/' . HttpClient::encode($eventKey) . '/reopen'); @@ -291,4 +299,200 @@ public function retrieveLog(string $eventKey, ?int $limit = null, ?int $before = ['limit' => $limit, 'before' => $before], ); } + + /** + * Retrieve who takes payment for an event, and what blocks a change. + * + * @return array + */ + public function retrieveEventBuyerCheckout(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/buyer-checkout'); + } + + /** + * Choose SeatLayer checkout or your own for an event. + * + * A compare-and-set on `$expectedRevision`, sent once. + * + * @param 'seatlayer'|'integrator' $buyerCheckout + * @return array + */ + public function replaceEventBuyerCheckout(string $eventKey, string $buyerCheckout, int $expectedRevision): array + { + /** @var array */ + return (array) $this->http->put( + '/v1/events/' . HttpClient::encode($eventKey) . '/buyer-checkout', + ['buyerCheckout' => $buyerCheckout, 'expectedRevision' => $expectedRevision], + ); + } + + /** + * Retrieve an event's payment gateway and the ones it could use, optionally checked + * against another currency. + * + * @return array + */ + public function retrieveEventPaymentGateway(string $eventKey, ?string $currency = null): array + { + /** @var array */ + return (array) $this->http->get( + '/v1/events/' . HttpClient::encode($eventKey) . '/payment-gateway', + array_filter(['currency' => $currency], static fn (mixed $value): bool => $value !== null), + ); + } + + /** + * Assign one of the organization's connected gateways to an event. Sent once. + * + * @param array{connectionId?:?string,provider?:?string} $params + * @return array + */ + public function replaceEventPaymentGateway(string $eventKey, array $params = []): array + { + /** @var array */ + return (array) $this->http->putObject( + '/v1/events/' . HttpClient::encode($eventKey) . '/payment-gateway', + $params, + ); + } + + /** + * Retrieve what an event still needs before its next sales step. + * + * @return array + */ + public function retrieveEventSalesReadiness(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/sales/readiness'); + } + + /** + * Record a test order as the event's sales preflight. Sent once. + * + * @return array + */ + public function preflightEventSales(string $eventKey, string $orderId): array + { + /** @var array */ + return (array) $this->http->post( + '/v1/events/' . HttpClient::encode($eventKey) . '/sales/preflight', + ['orderId' => $orderId], + ); + } + + /** + * Move an event's sales to testable, live, paused or ended. + * + * A 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`. + * + * @param 'setup'|'testable'|'live'|'paused'|'ended' $to + * @return array + */ + public function transitionEventSales( + string $eventKey, + string $to, + int $expectedRevision, + ?string $reason = null, + ): array { + /** @var array */ + return (array) $this->http->post( + '/v1/events/' . HttpClient::encode($eventKey) . '/sales/transition', + array_filter([ + 'to' => $to, + 'expectedRevision' => $expectedRevision, + 'reason' => $reason, + ], static fn (mixed $value): bool => $value !== null), + ); + } + + /** + * Retrieve an event's booking fee. + * + * @return array + */ + public function retrieveEventBookingFee(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/booking-fee'); + } + + /** + * Set an event's booking fee in minor units, or pass null to clear it. Sent once. + * + * @return array + */ + public function replaceEventBookingFee(string $eventKey, ?int $bookingFeeMinor): array + { + /** @var array */ + return (array) $this->http->put( + '/v1/events/' . HttpClient::encode($eventKey) . '/booking-fee', + ['bookingFeeMinor' => $bookingFeeMinor], + ); + } + + /** + * List an event's discount codes. + * + * @return array + */ + public function listEventDiscountCodes(string $eventKey): array + { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/discount-codes'); + } + + /** + * Create a discount code for an event. Sent once. + * + * @param array{code:string,kind:'percent'|'amount',percent?:int,amountMinor?:int,startsAt?:?int,endsAt?:?int,maxUses?:?int,active?:bool} $params + * @return array + */ + public function createEventDiscountCode(string $eventKey, array $params): array + { + /** @var array */ + return (array) $this->http->post( + '/v1/events/' . HttpClient::encode($eventKey) . '/discount-codes', + $params, + ); + } + + /** + * Change or pause a discount code. Sent once. + * + * @param array{code?:string,kind?:'percent'|'amount',percent?:int,amountMinor?:int,startsAt?:?int,endsAt?:?int,maxUses?:?int,active?:bool,state?:'active'|'paused'} $params + * @return array + */ + public function updateEventDiscountCode(string $eventKey, string $codeId, array $params): array + { + /** @var array */ + return (array) $this->http->patch( + '/v1/events/' . HttpClient::encode($eventKey) . '/discount-codes/' . HttpClient::encode($codeId), + $params, + ); + } + + /** + * End a discount code for good. + * + * @return array + */ + public function endEventDiscountCode(string $eventKey, string $codeId): array + { + /** @var array */ + return (array) $this->http->post( + '/v1/events/' . HttpClient::encode($eventKey) . '/discount-codes/' . HttpClient::encode($codeId) . '/end', + ); + } + + /** Delete a discount code nobody has used. The API answers 204. */ + public function deleteEventDiscountCode(string $eventKey, string $codeId): void + { + $this->http->delete( + '/v1/events/' . HttpClient::encode($eventKey) . '/discount-codes/' . HttpClient::encode($codeId), + ); + } } diff --git a/src/Resources/Holders.php b/src/Resources/Holders.php new file mode 100644 index 0000000..67ebe71 --- /dev/null +++ b/src/Resources/Holders.php @@ -0,0 +1,251 @@ + + */ + public function listCustomers( + ?string $email = null, + ?string $holderRef = null, + ?string $cursor = null, + ?int $limit = null, + ): array { + /** @var array */ + return (array) $this->http->get('/v1/customers', array_filter([ + 'email' => $email, + 'holderRef' => $holderRef, + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Retrieve the customer behind a holder reference. + * + * @return array + */ + public function retrieveHolder(string $holderRef): array + { + /** @var array */ + return (array) $this->http->get(self::path($holderRef)); + } + + /** + * Set a holder's email, name or phone. Pass null for name or phone to clear it. + * + * @param array{email?:string,name?:?string,phone?:?string} $params + * @return array + */ + public function updateHolder(string $holderRef, array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'PATCH', + self::path($holderRef), + $params, + $idempotencyKey, + ); + } + + /** + * Sign a holder's customer out of the ticket site: one session, or every session when + * `$sessionId` is null. + * + * @return array + */ + public function revokeHolderSessions(string $holderRef, ?string $sessionId = null): array + { + /** @var array */ + return (array) $this->http->postObject( + self::path($holderRef, '/sessions/revoke'), + array_filter(['sessionId' => $sessionId], static fn (mixed $value): bool => $value !== null), + ); + } + + /** + * List the seasons a holder holds seats in. + * + * @return array + */ + public function listHolderSeasons(string $holderRef): array + { + /** @var array */ + return (array) $this->http->get(self::path($holderRef, '/seasons')); + } + + /** + * List a holder's tickets, date by date. `$from` and `$to` are epoch milliseconds. + * + * @return array + */ + public function listHolderTickets( + string $holderRef, + ?string $seasonKey = null, + ?int $from = null, + ?int $to = null, + ?string $cursor = null, + ?int $limit = null, + ): array { + /** @var array */ + return (array) $this->http->get(self::path($holderRef, '/tickets'), array_filter([ + 'seasonKey' => $seasonKey, + 'from' => $from, + 'to' => $to, + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Retrieve one of a holder's tickets with its QR. + * + * @return array + */ + public function retrieveHolderTicket(string $holderRef, string $ticketId): array + { + /** @var array */ + return (array) $this->http->get(self::path($holderRef, '/tickets/' . HttpClient::encode($ticketId))); + } + + /** + * Send some of a holder's season tickets to an email. + * + * Turning customer sends off in Settings › Ticket site doesn't stop your server; cut-offs + * still apply. + * + * @param list $ticketIds + * @return array + */ + public function createHolderTransfer( + string $holderRef, + array $ticketIds, + string $toEmail, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($holderRef, '/transfers'), + ['ticketIds' => $ticketIds, 'toEmail' => $toEmail], + $idempotencyKey, + ); + } + + /** + * List the transfers a holder sent or was sent. + * + * @return array + */ + public function listHolderTransfers(string $holderRef): array + { + /** @var array */ + return (array) $this->http->get(self::path($holderRef, '/transfers')); + } + + /** + * Cancel a transfer that has not been accepted. + * + * @return array + */ + public function cancelHolderTransfer(string $holderRef, string $transferId): array + { + /** @var array */ + return (array) $this->http->post( + self::path($holderRef, '/transfers/' . HttpClient::encode($transferId) . '/cancel'), + ); + } + + /** + * Accept a transfer for the recipient holder. + * + * @return array + */ + public function acceptHolderTransfer(string $holderRef, string $transferId): array + { + /** @var array */ + return (array) $this->http->post( + self::path($holderRef, '/transfers/' . HttpClient::encode($transferId) . '/accept'), + ); + } + + /** + * Give back a holder's seat on one date, one per ticket. + * + * Takes 1 to 20 tickets, all of one season. A repeat with the same key answers with the + * same return. Turning give-backs off in Settings › Ticket site doesn't stop your server; + * cut-offs still apply. + * + * @param list $ticketIds + * @return array + */ + public function createHolderReturn( + string $holderRef, + array $ticketIds, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($holderRef, '/returns'), + ['ticketIds' => $ticketIds], + $idempotencyKey, + ); + } + + /** + * Start a SeatLayer checkout that renews one of a holder's offers (`renewalOffer` on + * `listHolderSeasons`). + * + * Send the buyer to `payment.url`. A repeat with the same key answers the same order. + * + * @param array{returnUrl:string,buyerEmail?:string,buyerName?:string,provider?:string} $params + * @return array + */ + public function createHolderRenewalCheckout( + string $holderRef, + string $offerId, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($holderRef, '/renewal-offers/' . HttpClient::encode($offerId) . '/checkout'), + $params, + $idempotencyKey, + ); + } + + /** + * Decline one of a holder's renewal offers and release its seats. + * + * `$reason` (1 to 200 characters) is kept on the offer as `declineReason`; declining again + * answers the same offer, with the first reason. Single-attempt. + * + * @return array + */ + public function declineHolderRenewalOffer(string $holderRef, string $offerId, ?string $reason = null): array + { + /** @var array */ + return (array) $this->http->postObject( + self::path($holderRef, '/renewal-offers/' . HttpClient::encode($offerId) . '/decline'), + array_filter(['reason' => $reason], static fn (mixed $value): bool => $value !== null), + ); + } +} diff --git a/src/Resources/Orders.php b/src/Resources/Orders.php new file mode 100644 index 0000000..e03bcee --- /dev/null +++ b/src/Resources/Orders.php @@ -0,0 +1,181 @@ + + */ + public function listOrders( + ?string $eventKey = null, + ?string $seasonKey = null, + ?string $status = null, + ?int $from = null, + ?int $to = null, + ?string $cursor = null, + ?int $limit = null, + ?string $holderRef = null, + ): array { + /** @var array */ + return (array) $this->http->get('/v1/orders', array_filter([ + 'eventKey' => $eventKey, + 'seasonKey' => $seasonKey, + 'holderRef' => $holderRef, + 'status' => $status, + 'from' => $from, + 'to' => $to, + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Retrieve an order and its lines. + * + * @return array + */ + public function retrieveOrder(string $orderId): array + { + /** @var array */ + return (array) $this->http->get('/v1/orders/' . HttpClient::encode($orderId)); + } + + /** + * List an order's tickets with scan links. + * + * @return array + */ + public function listOrderTickets(string $orderId): array + { + /** @var array */ + return (array) $this->http->get('/v1/orders/' . HttpClient::encode($orderId) . '/tickets'); + } + + /** + * List one event's orders. `$from` and `$to` are epoch milliseconds. + * + * @return array + */ + public function listEventOrders( + string $eventKey, + ?string $q = null, + ?string $status = null, + ?string $paidBy = null, + ?int $from = null, + ?int $to = null, + ?string $cursor = null, + ?int $limit = null, + ): array { + /** @var array */ + return (array) $this->http->get('/v1/events/' . HttpClient::encode($eventKey) . '/orders', array_filter([ + 'q' => $q, + 'status' => $status, + 'paidBy' => $paidBy, + 'from' => $from, + 'to' => $to, + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Retrieve one event order with its guests and tickets. + * + * @return array + */ + public function retrieveEventOrder(string $eventKey, string $orderId): array + { + /** @var array */ + return (array) $this->http->get( + '/v1/events/' . HttpClient::encode($eventKey) . '/orders/' . HttpClient::encode($orderId), + ); + } + + /** Download one event's orders as CSV. Returns the CSV text as sent. */ + public function exportEventOrdersCsv( + string $eventKey, + ?string $q = null, + ?string $status = null, + ?string $paidBy = null, + ?int $from = null, + ?int $to = null, + ): string { + return $this->http->getText('/v1/events/' . HttpClient::encode($eventKey) . '/orders.csv', array_filter([ + 'q' => $q, + 'status' => $status, + 'paidBy' => $paidBy, + 'from' => $from, + 'to' => $to, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Refund an event order in full, or in part with `$amountMinor`, through its payment provider. + * + * @return array + */ + public function refundEventOrder( + string $eventKey, + string $orderId, + ?int $amountMinor = null, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + '/v1/events/' . HttpClient::encode($eventKey) . '/orders/' . HttpClient::encode($orderId) . '/refund', + array_filter(['amountMinor' => $amountMinor], static fn (mixed $value): bool => $value !== null), + $idempotencyKey, + ); + } + + /** + * Cancel an event order without moving money. + * + * @param array{reason:'lost_dispute'|'refunded_elsewhere'|'other',note?:string,refundConfirmed?:bool,usedTicketsAcknowledged?:bool,notifyBuyer?:bool} $params + * @return array + */ + public function cancelEventOrder( + string $eventKey, + string $orderId, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + '/v1/events/' . HttpClient::encode($eventKey) . '/orders/' . HttpClient::encode($orderId) . '/cancel', + $params, + $idempotencyKey, + ); + } + + /** + * Email an event order's tickets again, optionally to another address. + * + * Single-attempt: the API does not promise replay, so a retry could send a second email. + * + * @return array + */ + public function resendEventOrderTickets(string $eventKey, string $orderId, ?string $email = null): array + { + /** @var array */ + return (array) $this->http->postObject( + '/v1/events/' . HttpClient::encode($eventKey) . '/orders/' . HttpClient::encode($orderId) . '/resend', + array_filter(['email' => $email], static fn (mixed $value): bool => $value !== null), + ); + } +} diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index a848176..2ef5bc8 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -13,6 +13,41 @@ public function __construct(private readonly HttpClient $http) { } + /** + * 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`. + */ + public static function dateCancelVoidJobId(string $amendmentId): 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`. + */ + public static function holderImportRowsPerCall(int $dates): int + { + return max(1, min(100, intdiv(2300, max(1, $dates) + 3))); + } + + /** + * True for the `202` a season-seats season's date cancel (`createSeasonAmendment()`) or past + * date (`markSeasonPerformancePast()`) answers while the date is still leaving every season + * seat: `state` `finishing`, `code` `block_date_leave_pending`, with `eventKey`, `shardsLeft` + * and `shardCount`. It finishes by itself: send the same request again after `Retry-After` to + * read how it ended. + * + * @param array $answer + */ + public static function isDateLeavePending(array $answer): bool + { + return ($answer['state'] ?? null) === 'finishing' && ($answer['code'] ?? null) === 'block_date_leave_pending'; + } + private static function path(string $seasonKey, string $suffix = ''): string { return '/v1/seasons/' . HttpClient::encode($seasonKey) . $suffix; @@ -65,7 +100,14 @@ public function validateSeason(array $selection): array } /** - * @param array{name:string,edition?:?string,eventKeys?:list,sourcePerformanceGroupKeys?:list} $params + * Create a draft Season. `singleTickets` true makes each date also sell its own single + * tickets once the Season is on sale; the default is Season only. `inventoryMode` `block` + * turns season seats on for this Season; the default is `per_hold`, and anything else answers + * `422 invalid_inventory_mode`. Every Season answer carries `inventoryMode`: 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`. + * + * @param array{name:string,edition?:?string,eventKeys?:list,sourcePerformanceGroupKeys?:list,singleTickets?:bool,inventoryMode?:'block'|'per_hold'} $params * @return array */ public function createSeason(array $params, ?string $idempotencyKey = null): array @@ -81,7 +123,26 @@ public function retrieveSeason(string $seasonKey): array } /** - * @param array{expectedRevision:int,name?:string,edition?:?string} $params + * Rename a draft Season, or set how it sells. + * + * `checkoutMode` is who takes the money; it cannot go back to undecided and is fixed once a + * live booking exists. The cutoffs are hours before a date starts (0 to 720) after which its + * tickets can no longer be sent to others, or a seat given back; `null` means the club + * default (Settings › Ticket site), or until it starts when the club has none. + * `audience` `public` lists the Season on your SeatLayer website; an ended Season cannot + * become public (`409 sales_ended`). `singleTickets` turned on while the Season is on sale + * opens each upcoming date's own sales through its readiness checks; turned off, they stop. + * The answer's `singleTicketDates` says which dates opened or stopped. Its `jobRefused`, in + * the rare case it is there, says why the `season_singles` job could not start: the change + * still stands, and sending `singleTickets` true again opens any date still not open. A + * retry with the same `expectedRevision` and Idempotency-Key is not applied twice. + * `inventoryMode` `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 + * the Season's `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`. + * + * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int,audience?:'embed_only'|'public',singleTickets?:bool,inventoryMode?:'block'|'per_hold'} $params * @return array */ public function updateSeason(string $seasonKey, array $params, ?string $idempotencyKey = null): array @@ -173,7 +234,15 @@ private function sales(string $seasonKey, string $action, int $expectedRevision) return $this->post($seasonKey, '/sales/' . $action, ['expectedRevision' => $expectedRevision]); } - /** @return array */ + /** + * Open sales. The answer's `warnings` say what is advised but not required, such as + * `checkout_test_missing` until a checkout test or rehearsal has passed. Refused with + * `409 plan_not_published` or `409 checkout_mode_not_chosen` until a package is published and + * a checkout mode is chosen, and with `409 block_not_ready` when season seats are turned on + * (`inventoryMode` `block`) but the seats are not set aside yet. + * + * @return array + */ public function openSeasonSales(string $seasonKey, int $expectedRevision): array { return $this->sales($seasonKey, 'open', $expectedRevision); @@ -298,6 +367,12 @@ public function validateSeasonBuyerRehearsal(string $seasonKey): array } /** + * Import incumbent holders. Each row takes `rowId`, `holderRef`, `priorPlanActivationId`, + * `priorContractRef` and `labels`, plus optional `existingBookingRef`, `holderEmail` and + * `holderName`. A call takes at most `Seasons::holderImportRowsPerCall($dates)` rows (100 up + * to 20 dates, 43 at 50); more answers `422 invalid_rows` with `maxRows` on the exception's + * `body`. + * * @param array{successorPlanActivationId:string,dryRun?:bool,rows:list>} $params * @return array */ @@ -321,6 +396,12 @@ public function retrieveSeasonHolderImport(string $seasonKey, string $importId): } /** + * Make one renewal offer per eligible prior Contract. For 100 contracts or fewer the API + * answers `201` with `offers`. Above 100 it answers `202` with `job` (a `renewal_run` job) + * and `batchId`: follow the job with `retrieveSeasonBulkJob()`, then page the offers with + * `listSeasonRenewalOffers()`. Retried safely with one Idempotency-Key, which answers the + * same offers or job. It can refuse with `409 job_mismatch` or `503 season_not_configured`. + * * @param array{deadlineAt:int,successorPlanActivationId?:string,contractIds?:list} $params * @return array */ @@ -337,10 +418,21 @@ public function createSeasonRenewalOffers( ); } - /** @return array */ - public function listSeasonRenewalOffers(string $seasonKey): array - { - return $this->get($seasonKey, '/renewal-offers'); + /** + * One page of renewal offers, newest first. Pass `$cursor` from the previous page's + * `nextCursor`, which is null on the last page. `$limit` is 1-100 and defaults to 100. + * + * @return array + */ + public function listSeasonRenewalOffers( + string $seasonKey, + ?string $cursor = null, + ?int $limit = null, + ): array { + return $this->get($seasonKey, '/renewal-offers', array_filter([ + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); } /** @return array */ @@ -389,13 +481,20 @@ public function commitSeasonRenewalOffer( ); } - /** @return array */ - public function declineSeasonRenewalOffer(string $seasonKey, string $offerId): array + /** + * Decline a renewal offer and release its seats at once. + * + * `$reason` (1 to 200 characters) is kept on the offer as `declineReason`; a repeated + * decline keeps the first reason. Null sends none. + * + * @return array + */ + public function declineSeasonRenewalOffer(string $seasonKey, string $offerId, ?string $reason = null): array { return $this->post( $seasonKey, '/renewal-offers/' . HttpClient::encode($offerId) . '/decline', - [], + array_filter(['reason' => $reason], static fn (mixed $value): bool => $value !== null), ); } @@ -416,7 +515,18 @@ public function listSeasonOccurrences(string $seasonKey): array } /** - * @param array{eventKey:string,kind:'reschedule'|'replace'|'cancel_exception',startsAt?:int,name?:string} $params + * Amend one date of a published Season. `postpone` keeps the date with no new time yet; + * `cancel` takes it off sale for good and, with `refund` `per_date`, refunds each SeatLayer + * order's share of the date as a job (the answer's `refundJob`). A whole-order refund is the + * order refund. A `cancel` also voids every live ticket for the date as a `date_cancel_void` + * job, which the answer does not name: it is + * `Seasons::dateCancelVoidJobId($answer['amendment']['amendmentId'])`, followed with + * `retrieveSeasonBulkJob()`. 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` (check with + * `Seasons::isDateLeavePending()`); send the same request again to read the amendment. + * + * @param array{eventKey:string,kind:'reschedule'|'replace'|'cancel_exception'|'postpone'|'cancel',startsAt?:int,name?:string,planActivationId?:string,refund?:'none'|'per_date',reason?:string} $params * @return array */ public function createSeasonAmendment( @@ -495,4 +605,712 @@ public function exportSeasonSupportSnapshot(string $seasonKey): array { return $this->get($seasonKey, '/export'); } + + /** + * Dry-run or commit renewal rights derived from a previous season's holders. + * + * A dry run answers `200` and a commit of 100 holders or fewer `201`, both with `import`. + * A commit of more than 100 holders answers `202` with `job` (a `holder_import` job): follow + * it with `retrieveSeasonBulkJob()`, whose `summary` has the same counts when it completes. + * Retried safely with one Idempotency-Key, which answers the same import or job. It can + * refuse with `409 job_mismatch`, `503 job_busy` or `503 season_not_configured`. + * + * @param array{sourceSeasonId:string,successorPlanActivationId:string,dryRun?:bool} $params + * @return array + */ + public function createSeasonHolderImportFromSeason( + string $seasonKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/imports/from-season'), + $params, + $idempotencyKey, + ); + } + + /** + * List the Season's performances and package membership in every lifecycle state. + * + * @return array + */ + public function listSeasonPerformances(string $seasonKey): array + { + return $this->get($seasonKey, '/performances'); + } + + /** + * Create performances inside a draft Season from a repeat rule. + * + * @param array{titlePattern:string,timezone:string,repeat:'none'|'daily'|'weekly'|'biweekly'|'dates',firstStartsAt?:int|float,count?:int,dates?:list,venue?:?string,durationMinutes?:?int,region?:string,attachToPlanKeys?:list} $params + * @return array + */ + public function createSeasonPerformances( + string $seasonKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/performances'), + $params, + $idempotencyKey, + ); + } + + /** + * Attach existing Events to a draft Season and, optionally, to its packages. + * + * @param array{eventKeys:list,attachToPlanKeys?:list} $params + * @return array + */ + public function attachSeasonPerformances( + string $seasonKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/performances/attach'), + $params, + $idempotencyKey, + ); + } + + /** + * Remove a performance from a draft Season and every package. + * + * @return array + */ + public function deleteSeasonPerformance( + string $seasonKey, + string $eventKey, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'DELETE', + self::path($seasonKey, '/performances/' . HttpClient::encode($eventKey)), + null, + $idempotencyKey, + ); + } + + /** + * Rename a draft package or change the performances it covers. + * + * @param array{name?:string,eventKeys?:list} $params + * @return array + */ + public function updateSeasonPlan( + string $seasonKey, + string $planKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'PATCH', + self::path($seasonKey, '/plans/' . HttpClient::encode($planKey)), + $params, + $idempotencyKey, + ); + } + + /** Delete a draft package that is not the Season's last one. The API answers 204. */ + public function deleteSeasonPlan(string $seasonKey, string $planKey, ?string $idempotencyKey = null): void + { + $this->http->mutationWithHeaderReplay( + 'DELETE', + self::path($seasonKey, '/plans/' . HttpClient::encode($planKey)), + null, + $idempotencyKey, + ); + } + + /** + * Activate the Season structure and publish every draft package in one retryable call. + * + * @return array + */ + public function publishSeason(string $seasonKey, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + self::path($seasonKey, '/publish'), + [], + $idempotencyKey, + ); + } + + /** + * Run a server-side hold and release to prove the Season buyer path. + * + * @param array{planKey?:string,quantity?:int} $params + * @return array + */ + public function createSeasonCheckoutTest( + string $seasonKey, + array $params = [], + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + self::path($seasonKey, '/checkout-tests'), + $params, + $idempotencyKey, + ); + } + + /** + * List recent Season checkout tests, newest first. + * + * @return array + */ + public function listSeasonCheckoutTests(string $seasonKey): array + { + return $this->get($seasonKey, '/checkout-tests'); + } + + /** + * Replace a package's price table, per chart category, in minor units. A price without a + * `priceTypeKey` is `adult`; each category takes each type once. + * + * @param array{prices:list,currency?:string} $params + * @return array + */ + public function replaceSeasonPackagePrices( + string $seasonKey, + string $planKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'PUT', + self::path($seasonKey, '/plans/' . HttpClient::encode($planKey) . '/prices'), + $params, + $idempotencyKey, + ); + } + + /** + * List every package price table for the Season. Each price carries its `priceTypeKey` and + * `priceTypeLabel`. + * + * @return array + */ + public function listSeasonPrices(string $seasonKey): array + { + return $this->get($seasonKey, '/prices'); + } + + /** + * Add a performance to a published package and extend its allocations. + * + * @return array + */ + public function attachSeasonPlanPerformance( + string $seasonKey, + string $planKey, + string $eventKey, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/plans/' . HttpClient::encode($planKey) . '/performances'), + ['eventKey' => $eventKey], + $idempotencyKey, + ); + } + + /** + * Remove a performance from a published package and release its allocations. + * + * @return array + */ + public function deleteSeasonPlanPerformance( + string $seasonKey, + string $planKey, + string $eventKey, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->mutationWithHeaderReplay( + 'DELETE', + self::path( + $seasonKey, + '/plans/' . HttpClient::encode($planKey) . '/performances/' . HttpClient::encode($eventKey), + ), + null, + $idempotencyKey, + ); + } + + /** + * Mark a performance past so it stops blocking new Season sales. 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` (check with `Seasons::isDateLeavePending()`). + * + * @return array + */ + public function markSeasonPerformancePast( + string $seasonKey, + string $eventKey, + ?bool $force = null, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + self::path($seasonKey, '/performances/' . HttpClient::encode($eventKey) . '/past'), + array_filter(['force' => $force], static fn (mixed $value): bool => $value !== null), + $idempotencyKey, + ); + } + + /** + * Return one Season occurrence allocation to ordinary Event inventory. + * + * Single-attempt: the stable `actionId` is the API's domain idempotency key. + * + * @param list $labels + * @param array{bookingActionId:string}|array{renewalOfferId:string} $source + * @return array + */ + public function returnSeasonOccurrenceInventory( + string $seasonKey, + string $eventKey, + string $actionId, + string $bookingRef, + string $planActivationId, + array $labels, + array $source, + ): array { + return $this->post($seasonKey, '/occurrences/' . HttpClient::encode($eventKey) . '/returns', [ + 'actionId' => $actionId, + 'bookingRef' => $bookingRef, + 'planActivationId' => $planActivationId, + 'labels' => $labels, + 'source' => $source, + ]); + } + + /** + * Reclaim returned occurrence inventory while it is still free. + * + * Single-attempt: the stable `actionId` is the API's domain idempotency key. + * + * @param list $labels + * @param array{bookingActionId:string}|array{renewalOfferId:string} $source + * @return array + */ + public function reclaimSeasonOccurrenceInventory( + string $seasonKey, + string $eventKey, + string $actionId, + string $returnActionId, + string $bookingRef, + string $planActivationId, + array $labels, + array $source, + ): array { + return $this->post($seasonKey, '/occurrences/' . HttpClient::encode($eventKey) . '/reclaims', [ + 'actionId' => $actionId, + 'returnActionId' => $returnActionId, + 'bookingRef' => $bookingRef, + 'planActivationId' => $planActivationId, + 'labels' => $labels, + 'source' => $source, + ]); + } + + /** + * List season ticket holders, one page at a time. + * + * @return array + */ + public function listSeasonHolders( + string $seasonKey, + ?string $q = null, + ?string $status = null, + ?string $cursor = null, + ?int $limit = null, + ): array { + return $this->get($seasonKey, '/holders', array_filter([ + 'q' => $q, + 'status' => $status, + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Refund a season order in full, or in part with `$amountMinor`, through its payment provider. + * + * @return array + */ + public function refundSeasonOrder( + string $seasonKey, + string $orderId, + ?int $amountMinor = null, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + self::path($seasonKey, '/orders/' . HttpClient::encode($orderId) . '/refund'), + array_filter(['amountMinor' => $amountMinor], static fn (mixed $value): bool => $value !== null), + $idempotencyKey, + ); + } + + /** + * Cancel a season order without moving money and release every date. + * + * @param array{reason:'lost_dispute'|'refunded_elsewhere'|'other',note?:string,refundConfirmed?:bool,usedTicketsAcknowledged?:bool,notifyBuyer?:bool} $params + * @return array + */ + public function cancelSeasonOrder( + string $seasonKey, + string $orderId, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/orders/' . HttpClient::encode($orderId) . '/cancel'), + $params, + $idempotencyKey, + ); + } + + /** + * Take back one date of a season order and put its seats back on sale. + * + * @return array + */ + public function cancelSeasonOrderDate( + string $seasonKey, + string $orderId, + string $eventKey, + ?string $actionId = null, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postObjectWithHeaderReplay( + self::path( + $seasonKey, + '/orders/' . HttpClient::encode($orderId) . '/dates/' . HttpClient::encode($eventKey) . '/cancel', + ), + array_filter(['actionId' => $actionId], static fn (mixed $value): bool => $value !== null), + $idempotencyKey, + ); + } + + /** + * Email a season order's tickets again, optionally to another address. + * + * Single-attempt: the API does not promise replay, so a retry could send a second email. + * + * @return array + */ + public function resendSeasonOrderTickets(string $seasonKey, string $orderId, ?string $email = null): array + { + return $this->post( + $seasonKey, + '/orders/' . HttpClient::encode($orderId) . '/resend', + array_filter(['email' => $email], static fn (mixed $value): bool => $value !== null), + ); + } + + /** + * Retrieve renewal links, payments and emails for one page of offers, newest first. Pass + * `$cursor` from the previous page's `nextCursor`, which is null on the last page. `$limit` + * is 1-500 and defaults to 500. + * + * @return array + */ + public function retrieveSeasonHostedRenewals( + string $seasonKey, + ?string $cursor = null, + ?int $limit = null, + ): array { + return $this->get($seasonKey, '/renewal-hosted', array_filter([ + 'cursor' => $cursor, + 'limit' => $limit, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Make a new renewal link for one holder; the old link stops working. + * + * This show-once link mint is deliberately single-attempt. + * + * @return array + */ + public function rotateSeasonRenewalOfferLink(string $seasonKey, string $offerId): array + { + return $this->post( + $seasonKey, + '/renewal-offers/' . HttpClient::encode($offerId) . '/link', + [], + ); + } + + /** + * Set the name and email a renewal offer is sent to. Pass null for a field to clear it. + * + * @param array{email?:?string,name?:?string} $params + * @return array + */ + public function updateSeasonRenewalOfferContact(string $seasonKey, string $offerId, array $params): array + { + return $this->post( + $seasonKey, + '/renewal-offers/' . HttpClient::encode($offerId) . '/contact', + $params, + ); + } + + /** + * Email renewal invitations now, and reminders before the deadline. Up to 20,000 offer ids; + * more answers `422 too_many_offer_ids`. + * + * @param list|null $offerIds Only these offers; null means every open offer. + * @param string|null $batchId Instead of offer ids: every open offer one createSeasonRenewalOffers batch made. + * @return array + */ + public function createSeasonRenewalNotices( + string $seasonKey, + ?array $offerIds = null, + ?string $batchId = null, + ): array { + return $this->post($seasonKey, '/renewal-notices', array_filter([ + 'offerIds' => $offerIds, + 'batchId' => $batchId, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** + * Send one holder a new renewal invitation. + * + * Single-attempt: the API does not promise replay, so a retry could send a second email. + * + * @return array + */ + public function resendSeasonRenewalOffer(string $seasonKey, string $offerId): array + { + return $this->post( + $seasonKey, + '/renewal-offers/' . HttpClient::encode($offerId) . '/resend', + [], + ); + } + + /** + * Retrieve what renewing an offer costs, priced as the SeatLayer checkout will charge it. + * + * `problem` is null when SeatLayer checkout can take the renewal now; otherwise it says why + * not. + * + * @return array + */ + public function retrieveSeasonRenewalQuote(string $seasonKey, string $offerId): array + { + return $this->get($seasonKey, '/renewal-offers/' . HttpClient::encode($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. + * + * Single-attempt; a repeat answers the same offer. + * + * @return array + */ + public function createSeasonRenewalIntent(string $seasonKey, string $offerId): array + { + return $this->post( + $seasonKey, + '/renewal-offers/' . HttpClient::encode($offerId) . '/intent', + [], + ); + } + + /** + * Start a SeatLayer checkout that renews an offer, with no hosted page. + * + * Send the buyer to `payment.url`; paying renews the offer. `returnUrl` must be on one of + * the organization's allowed domains. A repeat with the same key answers the same order. + * + * @param array{returnUrl:string,buyerEmail?:string,buyerName?:string,provider?:string} $params + * @return array + */ + public function createSeasonRenewalCheckout( + string $seasonKey, + string $offerId, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/renewal-offers/' . HttpClient::encode($offerId) . '/checkout'), + $params, + $idempotencyKey, + ); + } + + /** + * Retrieve season money, seats, holders and renewals as totals. + * + * @return array + */ + public function retrieveSeasonSalesReport(string $seasonKey): array + { + return $this->get($seasonKey, '/reports/sales'); + } + + /** + * Retrieve the seats still free per package and per date. + * + * @return array + */ + public function retrieveSeasonSeatsReport(string $seasonKey): array + { + return $this->get($seasonKey, '/reports/seats'); + } + + /** Download every season ticket holder as CSV. Returns the CSV text as sent. */ + public function exportSeasonHoldersCsv(string $seasonKey, ?string $q = null, ?string $status = null): string + { + return $this->http->getText(self::path($seasonKey, '/reports/holders.csv'), array_filter([ + 'q' => $q, + 'status' => $status, + ], static fn (mixed $value): bool => $value !== null)); + } + + /** Download every season order SeatLayer took as CSV. Returns the CSV text as sent. */ + public function exportSeasonOrdersCsv(string $seasonKey): string + { + return $this->http->getText(self::path($seasonKey, '/reports/orders.csv')); + } + + /** + * Retrieve season seats sold per package and per date. + * + * @return array + */ + public function retrieveSeasonSeatCounts(string $seasonKey): array + { + return $this->get($seasonKey, '/seat-counts'); + } + + /** + * Refund every SeatLayer order holding a cancelled date, as a job. `per_date` refunds the + * date's share of each order (rounded down). `full` is refused (`422 + * date_refund_per_date_only`): refund a whole order with the order refund. Retried safely + * with one Idempotency-Key, which replays the same job. + * + * @param array{amount:'per_date',reason?:string} $params + * @return array + */ + public function refundSeasonPerformance( + string $seasonKey, + string $eventKey, + array $params, + ?string $idempotencyKey = null, + ): array { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/performances/' . HttpClient::encode($eventKey) . '/refunds'), + $params, + $idempotencyKey, + ); + } + + /** + * Release the seats of every lapsed renewal offer, as a job. An offer extended before its + * turn is skipped and keeps its seat. Answers `202` with `job`; follow it with + * `retrieveSeasonBulkJob()`. Retried safely with one Idempotency-Key, which replays the + * same job. + * + * @return array + */ + public function releaseSeasonLapsedOffers(string $seasonKey, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postWithHeaderReplay( + self::path($seasonKey, '/renewal-offers/release-lapsed'), + null, + $idempotencyKey, + ); + } + + /** + * List the Season's bulk jobs, newest first (100 at most). + * + * @return array + */ + public function listSeasonBulkJobs(string $seasonKey): array + { + return $this->get($seasonKey, '/jobs'); + } + + /** + * Retrieve one bulk job with its counts, its `summary` once it completes, and up to 500 + * failed items. + * + * @return array + */ + public function retrieveSeasonBulkJob(string $seasonKey, string $jobId): array + { + return $this->get($seasonKey, '/jobs/' . HttpClient::encode($jobId)); + } + + /** + * Set the season's seats aside on every date: every seat its packages price, minus + * `keepForSingleTickets` (sections, categories or seats). Answers `202` with `job` (a + * `season_block_set_aside` job to follow with `retrieveSeasonBulkJob()`) and `block`. 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. + * + * @param array{expectedRevision:int,keepForSingleTickets?:array{sectionIds?:list,categoryKeys?:list,labels?:list}} $params + * @return array + */ + public function setAsideSeasonBlock(string $seasonKey, array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postWithHeaderReplay(self::path($seasonKey, '/block'), $params, $idempotencyKey); + } + + /** + * The season's seats set aside on every date: `block` with its `mode` (`block` once on, else + * `per_hold`), `state`, `counts` and each date's `state`. + * + * @return array + */ + public function retrieveSeasonBlock(string $seasonKey): array + { + return $this->get($seasonKey, '/block'); + } + + /** + * Release season seats to single sale: every unsold seat (`scope` `['all' => true]`), or the + * sections, categories or seats named. `eventKeys` limits it to those dates (left out: every + * date still to come); `at` (an ISO time) releases then instead of now. Answers `202` with + * `job` (a `season_block_release` job to follow with `retrieveSeasonBulkJob()`) and `block`. + * `season.block.released` is sent once it has run on every date. Retried safely with one + * Idempotency-Key, which replays the same release. + * + * @param array{expectedRevision:int,scope:array{all:true}|array{sectionIds:list}|array{categoryKeys:list}|array{labels:list},eventKeys?:list,at?:string,reason?:string} $params + * @return array + */ + public function releaseSeasonBlock(string $seasonKey, array $params, ?string $idempotencyKey = null): array + { + /** @var array */ + return (array) $this->http->postWithHeaderReplay(self::path($seasonKey, '/block/release'), $params, $idempotencyKey); + } } diff --git a/src/SeatLayer.php b/src/SeatLayer.php index 84a810d..60bfd19 100644 --- a/src/SeatLayer.php +++ b/src/SeatLayer.php @@ -7,7 +7,10 @@ use SeatLayer\Resources\Charts; use SeatLayer\Resources\Channels; use SeatLayer\Resources\Events; +use SeatLayer\Resources\EventSeries; +use SeatLayer\Resources\Holders; use SeatLayer\Resources\Inventory; +use SeatLayer\Resources\Orders; use SeatLayer\Resources\PerformanceGroups; use SeatLayer\Resources\Seasons; use SeatLayer\Resources\Sessions; @@ -26,7 +29,10 @@ final class SeatLayer public readonly Charts $charts; public readonly Channels $channels; public readonly Events $events; + public readonly EventSeries $eventSeries; + public readonly Holders $holders; public readonly Inventory $inventory; + public readonly Orders $orders; public readonly PerformanceGroups $performanceGroups; public readonly Seasons $seasons; public readonly Sessions $sessions; @@ -55,7 +61,10 @@ public function __construct( $this->charts = new Charts($this->http); $this->channels = new Channels($this->http); $this->events = new Events($this->http); + $this->eventSeries = new EventSeries($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->seasons = new Seasons($this->http); $this->sessions = new Sessions($this->http); diff --git a/tests/ClientTest.php b/tests/ClientTest.php index f49e218..d2d1327 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -422,6 +422,22 @@ public function testRetries429AndReusesIdempotencyKey(): void ); } + public function testBulkRefundOfADateRetriesWithOneKey(): void + { + $sdk = $this->client([ + ['status' => 429, 'body' => ['error' => 'rate_limited'], 'headers' => ['retry-after' => '0']], + ['status' => 202, 'body' => ['job' => ['jobId' => 'sbj_1']]], + ]); + $result = $sdk->seasons->refundSeasonPerformance('sea_1', 'ev_1', ['amount' => 'per_date']); + + self::assertSame(['job' => ['jobId' => 'sbj_1']], $result); + self::assertCount(2, $this->calls); + self::assertSame( + $this->call(0)['headers']['Idempotency-Key'], + $this->call(1)['headers']['Idempotency-Key'], + ); + } + public function testReadRetriesRemainEnabled(): void { $sdk = $this->client([ @@ -572,6 +588,19 @@ public function testListAllEventsSkipsPerEventCounts(): void self::assertStringContainsString('counts=0', $this->call(0)['url']); } + public function testEventsFilterBySeries(): void + { + $sdk = $this->client([ + ['status' => 200, 'body' => ['events' => []]], + ['status' => 200, 'body' => ['events' => []]], + ]); + $sdk->events->list(limit: 10, seriesKey: 'ser a/b'); + iterator_to_array($sdk->events->listAll(seriesKey: 'ser_1')); + + self::assertSame('https://api.seatlayer.io/v1/events?limit=10&seriesKey=ser+a%2Fb', $this->call(0)['url']); + self::assertSame('https://api.seatlayer.io/v1/events?counts=0&seriesKey=ser_1', $this->call(1)['url']); + } + public function testSinglePageKeepsCounts(): void { $sdk = $this->client([['status' => 200, 'body' => ['events' => []]]]); diff --git a/tests/SalesOperationsTest.php b/tests/SalesOperationsTest.php new file mode 100644 index 0000000..acecfcd --- /dev/null +++ b/tests/SalesOperationsTest.php @@ -0,0 +1,408 @@ +,body:?string}> */ + private array $calls = []; + + /** + * A client whose transport records every request and answers with one fixed response. + * + * @param array $responseHeaders + */ + private function client(int $status = 200, string $body = '{}', array $responseHeaders = []): SeatLayer + { + $this->calls = []; + $transport = function ( + string $method, + string $url, + array $headers, + ?string $payload, + float $_timeout, + ) use ($status, $body, $responseHeaders): array { + $this->calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $payload]; + + return ['status' => $status, 'body' => $body, 'headers' => $responseHeaders]; + }; + + return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); + } + + /** + * Each recorded request as [verb, path with query, raw JSON body, sent an Idempotency-Key]. + * + * @return array + */ + private function recorded(): array + { + return array_map(static fn (array $call): array => [ + $call['method'], + substr($call['url'], strlen('https://api.seatlayer.io')), + $call['body'], + array_key_exists('Idempotency-Key', $call['headers']), + ], $this->calls); + } + + public function testHolderOperations(): void + { + $holders = $this->client()->holders; + + $holders->listCustomers('ada@example.com', 'H/1', 'c/1', 10); + $holders->retrieveHolder('H/1'); + $holders->updateHolder('H/1', ['email' => 'ada@example.com', 'phone' => null], 'holder-update-1'); + $holders->revokeHolderSessions('H/1', 'sess_1'); + $holders->revokeHolderSessions('H/1'); + $holders->listHolderSeasons('H/1'); + $holders->listHolderTickets('H/1', 'sea/a', 1798761600000, 1799366400000, 'c/2', 20); + $holders->retrieveHolderTicket('H/1', 'tkt/1'); + $holders->createHolderTransfer('H/1', ['tkt_1', 'tkt_2'], 'friend@example.com', 'transfer-1'); + $holders->listHolderTransfers('H/1'); + $holders->cancelHolderTransfer('H/1', 'xfer/1'); + $holders->acceptHolderTransfer('H/1', 'xfer/1'); + $holders->createHolderReturn('H/1', ['tkt_0123456789abcdef01234567'], 'return-1'); + $holders->createHolderRenewalCheckout('H/1', 'offer/1', [ + 'returnUrl' => 'https://tickets.example/renewed', + 'buyerEmail' => 'cara@example.com', + 'buyerName' => 'Cara', + 'provider' => 'stripe', + ], 'renew-1'); + $holders->declineHolderRenewalOffer('H/1', 'offer/1', 'Moving away'); + $holders->declineHolderRenewalOffer('H/1', 'offer/1'); + + self::assertSame([ + ['GET', '/v1/customers?email=ada%40example.com&holderRef=H%2F1&cursor=c%2F1&limit=10', null, false], + ['GET', '/v1/holders/H%2F1', null, false], + ['PATCH', '/v1/holders/H%2F1', '{"email":"ada@example.com","phone":null}', true], + ['POST', '/v1/holders/H%2F1/sessions/revoke', '{"sessionId":"sess_1"}', false], + ['POST', '/v1/holders/H%2F1/sessions/revoke', '{}', false], + ['GET', '/v1/holders/H%2F1/seasons', null, false], + [ + 'GET', + '/v1/holders/H%2F1/tickets?seasonKey=sea%2Fa&from=1798761600000&to=1799366400000&cursor=c%2F2&limit=20', + null, + false, + ], + ['GET', '/v1/holders/H%2F1/tickets/tkt%2F1', null, false], + [ + 'POST', + '/v1/holders/H%2F1/transfers', + '{"ticketIds":["tkt_1","tkt_2"],"toEmail":"friend@example.com"}', + true, + ], + ['GET', '/v1/holders/H%2F1/transfers', null, false], + ['POST', '/v1/holders/H%2F1/transfers/xfer%2F1/cancel', null, false], + ['POST', '/v1/holders/H%2F1/transfers/xfer%2F1/accept', null, false], + ['POST', '/v1/holders/H%2F1/returns', '{"ticketIds":["tkt_0123456789abcdef01234567"]}', true], + [ + 'POST', + '/v1/holders/H%2F1/renewal-offers/offer%2F1/checkout', + '{"returnUrl":"https:\\/\\/tickets.example\\/renewed","buyerEmail":"cara@example.com",' + . '"buyerName":"Cara","provider":"stripe"}', + true, + ], + ['POST', '/v1/holders/H%2F1/renewal-offers/offer%2F1/decline', '{"reason":"Moving away"}', false], + ['POST', '/v1/holders/H%2F1/renewal-offers/offer%2F1/decline', '{}', false], + ], $this->recorded()); + self::assertSame('holder-update-1', $this->calls[2]['headers']['Idempotency-Key']); + self::assertSame('transfer-1', $this->calls[8]['headers']['Idempotency-Key']); + self::assertSame('return-1', $this->calls[12]['headers']['Idempotency-Key']); + self::assertSame('renew-1', $this->calls[13]['headers']['Idempotency-Key']); + } + + public function testOrderOperations(): void + { + $orders = $this->client()->orders; + + $orders->listOrders('ev/1', 'sea/a', 'paid', 1, 2, 'c/1', 10, 'ord/1'); + $orders->retrieveOrder('ord/1'); + $orders->listOrderTickets('ord/1'); + $orders->listEventOrders('ev/1', 'ada smith', 'paid', 'card', 1, 2, 'c/1', 25); + $orders->retrieveEventOrder('ev/1', 'ord/1'); + $orders->refundEventOrder('ev/1', 'ord/1', 500, 'refund-1'); + $orders->refundEventOrder('ev/1', 'ord/1'); + $orders->cancelEventOrder( + 'ev/1', + 'ord/1', + ['reason' => 'lost_dispute', 'usedTicketsAcknowledged' => true], + 'cancel-1', + ); + $orders->resendEventOrderTickets('ev/1', 'ord/1', 'ada@example.com'); + $orders->resendEventOrderTickets('ev/1', 'ord/1'); + + self::assertSame([ + [ + 'GET', + '/v1/orders?eventKey=ev%2F1&seasonKey=sea%2Fa&holderRef=ord%2F1&status=paid&from=1&to=2&cursor=c%2F1&limit=10', + null, + false, + ], + ['GET', '/v1/orders/ord%2F1', null, false], + ['GET', '/v1/orders/ord%2F1/tickets', null, false], + [ + 'GET', + '/v1/events/ev%2F1/orders?q=ada+smith&status=paid&paidBy=card&from=1&to=2&cursor=c%2F1&limit=25', + null, + false, + ], + ['GET', '/v1/events/ev%2F1/orders/ord%2F1', null, false], + ['POST', '/v1/events/ev%2F1/orders/ord%2F1/refund', '{"amountMinor":500}', true], + ['POST', '/v1/events/ev%2F1/orders/ord%2F1/refund', '{}', true], + [ + 'POST', + '/v1/events/ev%2F1/orders/ord%2F1/cancel', + '{"reason":"lost_dispute","usedTicketsAcknowledged":true}', + true, + ], + ['POST', '/v1/events/ev%2F1/orders/ord%2F1/resend', '{"email":"ada@example.com"}', false], + ['POST', '/v1/events/ev%2F1/orders/ord%2F1/resend', '{}', false], + ], $this->recorded()); + self::assertSame('refund-1', $this->calls[5]['headers']['Idempotency-Key']); + self::assertSame('cancel-1', $this->calls[7]['headers']['Idempotency-Key']); + } + + public function testEventOrdersCsvReturnsTheRawText(): void + { + $csv = "order,buyer\nSL-1,\"Smith, Ada\"\n"; + $orders = $this->client(200, $csv)->orders; + + self::assertSame($csv, $orders->exportEventOrdersCsv('ev/1', 'ada', 'paid', 'card', 1, 2)); + self::assertSame([ + ['GET', '/v1/events/ev%2F1/orders.csv?q=ada&status=paid&paidBy=card&from=1&to=2', null, false], + ], $this->recorded()); + self::assertStringStartsWith('text/csv', $this->calls[0]['headers']['Accept']); + } + + public function testCsvExportErrorsStillMapToTypedExceptions(): void + { + $orders = $this->client(409, '{"code":"no_orders","message":"This event uses its own checkout."}')->orders; + + try { + $orders->exportEventOrdersCsv('ev/1'); + self::fail('expected ConflictException'); + } catch (ConflictException $error) { + self::assertSame('no_orders', $error->errorCode); + } + } + + public function testEventSalesOperations(): void + { + $events = $this->client()->events; + + $events->retrieveEventBuyerCheckout('ev/1'); + $events->replaceEventBuyerCheckout('ev/1', 'seatlayer', 4); + $events->retrieveEventPaymentGateway('ev/1', 'EUR'); + $events->replaceEventPaymentGateway('ev/1', ['connectionId' => 'conn_1']); + $events->replaceEventPaymentGateway('ev/1'); + $events->retrieveEventSalesReadiness('ev/1'); + $events->preflightEventSales('ev/1', 'ord_test_1'); + $events->transitionEventSales('ev/1', 'live', 5, 'Doors open'); + $events->retrieveEventBookingFee('ev/1'); + $events->replaceEventBookingFee('ev/1', 150); + $events->replaceEventBookingFee('ev/1', null); + $events->listEventDiscountCodes('ev/1'); + $events->createEventDiscountCode( + 'ev/1', + ['code' => 'EARLY', 'kind' => 'percent', 'percent' => 10, 'maxUses' => null], + ); + $events->updateEventDiscountCode('ev/1', 'dc/1', ['state' => 'paused']); + $events->endEventDiscountCode('ev/1', 'dc/1'); + + self::assertSame([ + ['GET', '/v1/events/ev%2F1/buyer-checkout', null, false], + ['PUT', '/v1/events/ev%2F1/buyer-checkout', '{"buyerCheckout":"seatlayer","expectedRevision":4}', false], + ['GET', '/v1/events/ev%2F1/payment-gateway?currency=EUR', null, false], + ['PUT', '/v1/events/ev%2F1/payment-gateway', '{"connectionId":"conn_1"}', false], + ['PUT', '/v1/events/ev%2F1/payment-gateway', '{}', false], + ['GET', '/v1/events/ev%2F1/sales/readiness', null, false], + ['POST', '/v1/events/ev%2F1/sales/preflight', '{"orderId":"ord_test_1"}', false], + [ + 'POST', + '/v1/events/ev%2F1/sales/transition', + '{"to":"live","expectedRevision":5,"reason":"Doors open"}', + false, + ], + ['GET', '/v1/events/ev%2F1/booking-fee', null, false], + ['PUT', '/v1/events/ev%2F1/booking-fee', '{"bookingFeeMinor":150}', false], + ['PUT', '/v1/events/ev%2F1/booking-fee', '{"bookingFeeMinor":null}', false], + ['GET', '/v1/events/ev%2F1/discount-codes', null, false], + [ + 'POST', + '/v1/events/ev%2F1/discount-codes', + '{"code":"EARLY","kind":"percent","percent":10,"maxUses":null}', + false, + ], + ['PATCH', '/v1/events/ev%2F1/discount-codes/dc%2F1', '{"state":"paused"}', false], + ['POST', '/v1/events/ev%2F1/discount-codes/dc%2F1/end', null, false], + ], $this->recorded()); + } + + public function testDeleteEventDiscountCodeAcceptsAnEmpty204(): void + { + $events = $this->client(204, '')->events; + + $events->deleteEventDiscountCode('ev/1', 'dc/1'); + + self::assertSame([ + ['DELETE', '/v1/events/ev%2F1/discount-codes/dc%2F1', null, false], + ], $this->recorded()); + } + + public function testEventSeriesOperations(): void + { + $series = $this->client(202)->eventSeries; + $schedule = ['rule' => 'weekly', 'times' => ['19:30'], 'from' => '2026-11-03', 'count' => 6]; + + $series->createEventSeries( + ['name' => 'Hamlet', 'chartKey' => 'ch_1', 'schedule' => $schedule, 'timezone' => 'UTC', 'dryRun' => true], + 'series-1', + ); + $series->listEventSeries(20, 'c/1'); + $series->retrieveEventSeries('ser/1'); + $series->addEventSeriesDates('ser/1', ['dates' => ['2026-12-01T19:30']], 'dates-1'); + $series->updateEventSeries( + 'ser/1', + ['sales' => 'off', 'prices' => ['categories' => [['key' => 'stalls', 'price' => 25]]]], + 'series-update-1', + ); + $series->addEventSeriesDates('ser/1', ['retryFailed' => true]); + + self::assertSame([ + [ + 'POST', + '/v1/event-series', + '{"name":"Hamlet","chartKey":"ch_1","schedule":{"rule":"weekly","times":["19:30"],' + . '"from":"2026-11-03","count":6},"timezone":"UTC","dryRun":true}', + true, + ], + ['GET', '/v1/event-series?limit=20&cursor=c%2F1', null, false], + ['GET', '/v1/event-series/ser%2F1', null, false], + ['POST', '/v1/event-series/ser%2F1/dates', '{"dates":["2026-12-01T19:30"]}', true], + [ + 'PATCH', + '/v1/event-series/ser%2F1', + '{"sales":"off","prices":{"categories":[{"key":"stalls","price":25}]}}', + true, + ], + // Make again the dates that failed to be made. + ['POST', '/v1/event-series/ser%2F1/dates', '{"retryFailed":true}', true], + ], $this->recorded()); + self::assertSame('series-1', $this->calls[0]['headers']['Idempotency-Key']); + self::assertSame('dates-1', $this->calls[3]['headers']['Idempotency-Key']); + self::assertSame('series-update-1', $this->calls[4]['headers']['Idempotency-Key']); + } + + public function testAGivenUpSeriesCreateAnswers409CreateAbandonedOnce(): void + { + $series = $this->client(409, json_encode([ + '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.', + ], JSON_THROW_ON_ERROR))->eventSeries; + $schedule = ['rule' => 'daily', 'times' => ['20:00'], 'from' => '2026-11-06', 'until' => '2026-11-08']; + + try { + $series->createEventSeries(['fromEventKey' => 'ev_1', 'schedule' => $schedule], 'series-3'); + self::fail('expected ConflictException'); + } catch (ConflictException $error) { + self::assertSame(409, $error->status); + self::assertSame('create_abandoned', $error->errorCode); + self::assertStringContainsString('new Idempotency-Key', $error->getMessage()); + // A header-replay create may take 3 attempts here, but a 409 is the answer: sent once. + self::assertCount(1, $this->calls); + self::assertSame('series-3', $this->calls[0]['headers']['Idempotency-Key']); + } + } + + public function testHolderReturnRetriesWithOneKey(): void + { + $sdk = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0']); + + try { + $sdk->holders->createHolderReturn('a/b', ['tkt_0123456789abcdef01234567']); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + $attempt = ['POST', '/v1/holders/a%2Fb/returns', '{"ticketIds":["tkt_0123456789abcdef01234567"]}', true]; + self::assertSame([$attempt, $attempt, $attempt], $this->recorded()); + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertCount(1, array_unique($keys)); + } + + try { + $sdk->holders->createHolderReturn('a/b', ['tkt_0123456789abcdef01234567'], 'return-1'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertSame(['return-1', 'return-1', 'return-1'], array_slice($keys, 3)); + } + } + + public function testHolderRenewalCheckoutRetriesWithOneKeyAndDeclineIsSentOnce(): void + { + $sdk = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0']); + + try { + $sdk->holders->createHolderRenewalCheckout('a/b', 'offer_1', ['returnUrl' => 'https://tickets.example/r']); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + $attempt = [ + 'POST', + '/v1/holders/a%2Fb/renewal-offers/offer_1/checkout', + '{"returnUrl":"https:\\/\\/tickets.example\\/r"}', + true, + ]; + self::assertSame([$attempt, $attempt, $attempt], $this->recorded()); + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertCount(1, array_unique($keys)); + } + + try { + $sdk->holders->declineHolderRenewalOffer('a/b', 'offer_1', 'Moving away'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(4, $this->calls); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[3]['headers']); + self::assertSame('{"reason":"Moving away"}', $this->calls[3]['body']); + } + } + + public function testReplayMutationsRetryWithOneKeyAndUnsupportedOnesAreSentOnce(): void + { + $sdk = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0']); + + try { + $sdk->orders->refundEventOrder('ev/1', 'ord/1', 500); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(3, $this->calls); + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertCount(1, array_unique($keys)); + } + + try { + $sdk->orders->resendEventOrderTickets('ev/1', 'ord/1'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(4, $this->calls); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[3]['headers']); + } + + try { + $sdk->events->createEventDiscountCode('ev/1', ['code' => 'EARLY', 'kind' => 'amount', 'amountMinor' => 500]); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(5, $this->calls); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[4]['headers']); + } + } +} diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 2f1976c..94a4532 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -5,11 +5,42 @@ namespace SeatLayer\Tests; use PHPUnit\Framework\TestCase; +use SeatLayer\ConflictException; +use SeatLayer\RateLimitException; +use SeatLayer\Resources\Seasons; use SeatLayer\SeatLayer; +use SeatLayer\SeatLayerException; +use SeatLayer\ValidationException; final class SeasonTest extends TestCase { - public function testMapsAll48SeasonOperationsAndReplayClasses(): void + /** @var list,body:?string}> */ + private array $calls = []; + + /** + * A client whose transport records every request and answers with one fixed response. + * + * @param array $responseHeaders + */ + private function client(int $status = 200, string $body = '{}', array $responseHeaders = []): SeatLayer + { + $this->calls = []; + $transport = function ( + string $method, + string $url, + array $headers, + ?string $payload, + float $_timeout, + ) use ($status, $body, $responseHeaders): array { + $this->calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $payload]; + + return ['status' => $status, 'body' => $body, 'headers' => $responseHeaders]; + }; + + return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); + } + + public function testMapsAll90SeasonOperationsAndReplayClasses(): void { /** @var list,body:?string}> $calls */ $calls = []; @@ -81,8 +112,89 @@ public function testMapsAll48SeasonOperationsAndReplayClasses(): void $seasons->replaySeasonOutbox($key, 'occurrence/1'); $seasons->listSeasonAudit($key); $seasons->exportSeasonSupportSnapshot($key); + $seasons->createSeasonHolderImportFromSeason( + $key, + ['sourceSeasonId' => 'sea_2026', 'successorPlanActivationId' => 'pa_1'], + 'from-season-1', + ); + $seasons->listSeasonPerformances($key); + $seasons->createSeasonPerformances( + $key, + ['titlePattern' => 'Night {n}', 'timezone' => 'Europe/London', 'repeat' => 'weekly', 'count' => 4], + 'perf-1', + ); + $seasons->attachSeasonPerformances($key, ['eventKeys' => ['ev_1', 'ev_2']], 'attach-1'); + $seasons->deleteSeasonPerformance($key, 'ev/1', 'perf-del-1'); + $seasons->updateSeasonPlan($key, 'plan/1', ['name' => 'Full run'], 'plan-update-1'); + $seasons->deleteSeasonPlan($key, 'plan/1', 'plan-del-1'); + $seasons->publishSeason($key, 'publish-1'); + $seasons->createSeasonCheckoutTest($key, ['planKey' => 'plan_1', 'quantity' => 2], 'checkout-1'); + $seasons->listSeasonCheckoutTests($key); + $seasons->replaceSeasonPackagePrices( + $key, + 'plan/1', + ['prices' => [['categoryKey' => 'stalls', 'unitPrice' => 12000]], 'currency' => 'GBP'], + 'prices-1', + ); + $seasons->listSeasonPrices($key); + $seasons->attachSeasonPlanPerformance($key, 'plan/1', 'ev_3', 'plan-perf-1'); + $seasons->deleteSeasonPlanPerformance($key, 'plan/1', 'ev/3', 'plan-perf-del-1'); + $seasons->markSeasonPerformancePast($key, 'ev/1', true, 'past-1'); + $seasons->returnSeasonOccurrenceInventory( + $key, + 'ev/1', + 'return_1', + 'order_1', + 'pa_1', + ['A-1', 'A-2'], + ['bookingActionId' => 'book_1'], + ); + $seasons->reclaimSeasonOccurrenceInventory( + $key, + 'ev/1', + 'reclaim_1', + 'return_1', + 'order_1', + 'pa_1', + ['A-1'], + ['renewalOfferId' => 'offer_1'], + ); + $seasons->listSeasonHolders($key, 'smith & co', 'active', 'c/2', 25); + $seasons->refundSeasonOrder($key, 'ord/1', 500, 'refund-1'); + $seasons->cancelSeasonOrder($key, 'ord/1', ['reason' => 'refunded_elsewhere', 'notifyBuyer' => false], 'cancel-1'); + $seasons->cancelSeasonOrderDate($key, 'ord/1', 'ev/1', 'date_cancel_1', 'date-cancel-1'); + $seasons->resendSeasonOrderTickets($key, 'ord/1', 'buyer@example.com'); + $seasons->retrieveSeasonHostedRenewals($key); + $seasons->rotateSeasonRenewalOfferLink($key, 'offer/1'); + $seasons->updateSeasonRenewalOfferContact($key, 'offer/1', ['email' => 'new@example.com', 'name' => null]); + $seasons->createSeasonRenewalNotices($key, ['offer_1', 'offer_2']); + $seasons->resendSeasonRenewalOffer($key, 'offer/1'); + $seasons->retrieveSeasonSalesReport($key); + $seasons->retrieveSeasonSeatsReport($key); + $seasons->exportSeasonHoldersCsv($key, 'smith', 'active'); + $seasons->exportSeasonOrdersCsv($key); + $seasons->retrieveSeasonSeatCounts($key); + $seasons->refundSeasonPerformance($key, 'ev/1', ['amount' => 'per_date', 'reason' => 'Storm'], 'refund-date-1'); + $seasons->listSeasonBulkJobs($key); + $seasons->retrieveSeasonBulkJob($key, 'job/1'); + $seasons->retrieveSeasonRenewalQuote($key, 'offer/1'); + $seasons->createSeasonRenewalIntent($key, 'offer/1'); + $seasons->createSeasonRenewalCheckout($key, 'offer/1', [ + 'returnUrl' => 'https://tickets.example/renewed', + 'buyerEmail' => 'ana@example.com', + 'buyerName' => 'Ana Silva', + 'provider' => 'stripe', + ], 'renew-1'); + $seasons->releaseSeasonLapsedOffers($key, 'release-lapsed-1'); + // Season seats: set aside, read, release (phase 4). + $seasons->setAsideSeasonBlock($key, ['expectedRevision' => 7, 'keepForSingleTickets' => ['labels' => ['A-6']]], 'block-1'); + $seasons->retrieveSeasonBlock($key); + $seasons->releaseSeasonBlock($key, [ + 'expectedRevision' => 7, 'scope' => ['all' => true], 'eventKeys' => ['ev/1'], + 'at' => '2026-12-01T10:00:00Z', 'reason' => 'Unsold', + ]); - self::assertCount(48, $calls); + self::assertCount(90, $calls); $actual = array_map(static fn (array $call): array => [ $call['method'], substr($call['url'], strlen('https://api.seatlayer.io')), @@ -124,10 +236,44 @@ public function testMapsAll48SeasonOperationsAndReplayClasses(): void ['GET', '/v1/seasons/sea%2Fa/outbox'], ['POST', '/v1/seasons/sea%2Fa/outbox/occurrence%2F1/replay'], ['GET', '/v1/seasons/sea%2Fa/audit'], ['GET', '/v1/seasons/sea%2Fa/export'], + ['POST', '/v1/seasons/sea%2Fa/imports/from-season'], + ['GET', '/v1/seasons/sea%2Fa/performances'], ['POST', '/v1/seasons/sea%2Fa/performances'], + ['POST', '/v1/seasons/sea%2Fa/performances/attach'], + ['DELETE', '/v1/seasons/sea%2Fa/performances/ev%2F1'], + ['PATCH', '/v1/seasons/sea%2Fa/plans/plan%2F1'], ['DELETE', '/v1/seasons/sea%2Fa/plans/plan%2F1'], + ['POST', '/v1/seasons/sea%2Fa/publish'], + ['POST', '/v1/seasons/sea%2Fa/checkout-tests'], ['GET', '/v1/seasons/sea%2Fa/checkout-tests'], + ['PUT', '/v1/seasons/sea%2Fa/plans/plan%2F1/prices'], ['GET', '/v1/seasons/sea%2Fa/prices'], + ['POST', '/v1/seasons/sea%2Fa/plans/plan%2F1/performances'], + ['DELETE', '/v1/seasons/sea%2Fa/plans/plan%2F1/performances/ev%2F3'], + ['POST', '/v1/seasons/sea%2Fa/performances/ev%2F1/past'], + ['POST', '/v1/seasons/sea%2Fa/occurrences/ev%2F1/returns'], + ['POST', '/v1/seasons/sea%2Fa/occurrences/ev%2F1/reclaims'], + ['GET', '/v1/seasons/sea%2Fa/holders?q=smith+%26+co&status=active&cursor=c%2F2&limit=25'], + ['POST', '/v1/seasons/sea%2Fa/orders/ord%2F1/refund'], + ['POST', '/v1/seasons/sea%2Fa/orders/ord%2F1/cancel'], + ['POST', '/v1/seasons/sea%2Fa/orders/ord%2F1/dates/ev%2F1/cancel'], + ['POST', '/v1/seasons/sea%2Fa/orders/ord%2F1/resend'], + ['GET', '/v1/seasons/sea%2Fa/renewal-hosted'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/link'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/contact'], + ['POST', '/v1/seasons/sea%2Fa/renewal-notices'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/resend'], + ['GET', '/v1/seasons/sea%2Fa/reports/sales'], ['GET', '/v1/seasons/sea%2Fa/reports/seats'], + ['GET', '/v1/seasons/sea%2Fa/reports/holders.csv?q=smith&status=active'], + ['GET', '/v1/seasons/sea%2Fa/reports/orders.csv'], ['GET', '/v1/seasons/sea%2Fa/seat-counts'], + ['POST', '/v1/seasons/sea%2Fa/performances/ev%2F1/refunds'], + ['GET', '/v1/seasons/sea%2Fa/jobs'], ['GET', '/v1/seasons/sea%2Fa/jobs/job%2F1'], + ['GET', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/quote'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/intent'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/checkout'], + ['POST', '/v1/seasons/sea%2Fa/renewal-offers/release-lapsed'], + ['POST', '/v1/seasons/sea%2Fa/block'], ['GET', '/v1/seasons/sea%2Fa/block'], + ['POST', '/v1/seasons/sea%2Fa/block/release'], ], $actual); self::assertNull($calls[26]['body']); - $replayIndexes = [2, 4, 5, 10, 18, 27, 29, 38]; + $replayIndexes = [2, 4, 5, 10, 18, 27, 29, 38, 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, 85, 86, 87, 89]; foreach ($calls as $index => $call) { if (in_array($index, $replayIndexes, true)) { self::assertArrayHasKey('Idempotency-Key', $call['headers']); @@ -135,5 +281,483 @@ public function testMapsAll48SeasonOperationsAndReplayClasses(): void self::assertArrayNotHasKey('Idempotency-Key', $call['headers']); } } + self::assertSame('from-season-1', $calls[48]['headers']['Idempotency-Key']); + self::assertSame('date-cancel-1', $calls[68]['headers']['Idempotency-Key']); + self::assertSame('refund-date-1', $calls[80]['headers']['Idempotency-Key']); + self::assertSame('{"amount":"per_date","reason":"Storm"}', $calls[80]['body']); + self::assertNull($calls[83]['body']); + self::assertSame('{}', $calls[84]['body']); + self::assertSame('renew-1', $calls[85]['headers']['Idempotency-Key']); + self::assertSame( + '{"returnUrl":"https:\\/\\/tickets.example\\/renewed","buyerEmail":"ana@example.com",' + . '"buyerName":"Ana Silva","provider":"stripe"}', + $calls[85]['body'], + ); + self::assertSame('release-lapsed-1', $calls[86]['headers']['Idempotency-Key']); + self::assertNull($calls[86]['body']); + self::assertSame('block-1', $calls[87]['headers']['Idempotency-Key']); + self::assertSame('{"expectedRevision":7,"keepForSingleTickets":{"labels":["A-6"]}}', $calls[87]['body']); + self::assertNull($calls[88]['body']); + self::assertSame( + '{"expectedRevision":7,"scope":{"all":true},"eventKeys":["ev\\/1"],"at":"2026-12-01T10:00:00Z","reason":"Unsold"}', + $calls[89]['body'], + ); + } + + public function testSeasonSeatsAnswersReachTheCaller(): void + { + $answer = [ + 'job' => self::job('season_block_set_aside'), + 'block' => ['mode' => 'per_hold', 'state' => 'setting_aside', 'revision' => 7, 'counts' => ['setAside' => 5]], + ]; + $seasons = $this->client(202, json_encode($answer, JSON_THROW_ON_ERROR))->seasons; + self::assertSame($answer, $seasons->setAsideSeasonBlock('sea_1', ['expectedRevision' => 7])); + // Without a caller key one is generated, so a retry replays the same answer. + self::assertArrayHasKey('Idempotency-Key', $this->calls[0]['headers']); + + // A job of a kind this SDK did not know before reads as any other. + $release = ['job' => self::job('season_block_release')]; + $seasons = $this->client(200, json_encode($release, JSON_THROW_ON_ERROR))->seasons; + self::assertSame('season_block_release', $seasons->retrieveSeasonBulkJob('sea_1', 'sbr_1')['job']['kind']); + + $pending = [ + 'state' => 'finishing', 'code' => 'block_date_leave_pending', 'retryable' => true, + 'eventKey' => 'ev_1', 'shardsLeft' => [1], 'shardCount' => 2, 'message' => 'Still leaving one shard', + ]; + $seasons = $this->client(202, json_encode($pending, JSON_THROW_ON_ERROR), ['retry-after' => '2'])->seasons; + self::assertTrue(Seasons::isDateLeavePending($seasons->createSeasonAmendment('sea_1', ['eventKey' => 'ev_1', 'kind' => 'cancel']))); + self::assertTrue(Seasons::isDateLeavePending($seasons->markSeasonPerformancePast('sea_1', 'ev_1'))); + self::assertFalse(Seasons::isDateLeavePending(['amendment' => ['amendmentId' => 'am_1']])); + self::assertFalse(Seasons::isDateLeavePending(['state' => 'finishing', 'code' => 'season_busy'])); + self::assertFalse(Seasons::isDateLeavePending(['state' => 'done', 'code' => 'block_date_leave_pending'])); + + foreach (['season_block_not_enabled', 'block_exists', 'season_block_set_aside', 'season_inventory_locked', 'date_leave_in_progress'] as $code) { + $seasons = $this->client(409, json_encode(['error' => $code, 'code' => $code, 'message' => $code], JSON_THROW_ON_ERROR))->seasons; + try { + $seasons->releaseSeasonBlock('sea_1', ['expectedRevision' => 7, 'scope' => ['labels' => ['A-1']]]); + self::fail('expected ConflictException'); + } catch (ConflictException $error) { + self::assertSame($code, $error->errorCode); + } + } + } + + /** + * A bulk job as the API answers it. + * + * @return array + */ + private static function job(string $kind): array + { + return [ + 'jobId' => 'sbj_1', + 'kind' => $kind, + 'state' => 'planning', + 'counts' => ['total' => 240, 'done' => 0, 'skipped' => 0, 'failed' => 0], + 'createdAt' => 1, + 'updatedAt' => 1, + 'completedAt' => null, + ]; + } + + public function testRenewalOfferListsSendTheirPageCursorAndLimit(): void + { + $page = ['offers' => [], 'truncated' => false, 'nextCursor' => null]; + $seasons = $this->client(200, json_encode($page, JSON_THROW_ON_ERROR))->seasons; + + $seasons->retrieveSeasonHostedRenewals('sea/a', 'c/1', 250); + $seasons->listSeasonRenewalOffers('sea/a', 'c/2', 50); + $seasons->retrieveSeasonHostedRenewals('sea_1', limit: 500); + self::assertSame($page, $seasons->listSeasonRenewalOffers('sea_1')); + + self::assertSame([ + 'https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-hosted?cursor=c%2F1&limit=250', + 'https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-offers?cursor=c%2F2&limit=50', + 'https://api.seatlayer.io/v1/seasons/sea_1/renewal-hosted?limit=500', + 'https://api.seatlayer.io/v1/seasons/sea_1/renewal-offers', + ], array_column($this->calls, 'url')); + self::assertSame(['GET', 'GET', 'GET', 'GET'], array_column($this->calls, 'method')); + } + + public function testRenewalAtScaleAnswersJobs(): void + { + $offers = ['job' => self::job('renewal_run'), 'batchId' => 'srb_1']; + $seasons = $this->client(202, json_encode($offers, JSON_THROW_ON_ERROR))->seasons; + self::assertSame($offers, $seasons->createSeasonRenewalOffers('sea/a', ['deadlineAt' => 123], 'offers-1')); + self::assertSame('offers-1', $this->calls[0]['headers']['Idempotency-Key']); + + $imported = ['job' => self::job('holder_import')]; + $seasons = $this->client(202, json_encode($imported, JSON_THROW_ON_ERROR))->seasons; + self::assertSame($imported, $seasons->createSeasonHolderImportFromSeason( + 'sea/a', + ['sourceSeasonId' => 'sea_2026', 'successorPlanActivationId' => 'pa_1'], + 'from-season-1', + )); + self::assertSame('from-season-1', $this->calls[0]['headers']['Idempotency-Key']); + + $released = ['job' => self::job('lapsed_release')]; + $seasons = $this->client(202, json_encode($released, JSON_THROW_ON_ERROR))->seasons; + self::assertSame($released, $seasons->releaseSeasonLapsedOffers('sea/a')); + self::assertSame('POST', $this->calls[0]['method']); + self::assertSame( + 'https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-offers/release-lapsed', + $this->calls[0]['url'], + ); + self::assertNull($this->calls[0]['body']); + // Without a caller key one is generated, so a retry replays the same job. + self::assertArrayHasKey('Idempotency-Key', $this->calls[0]['headers']); + + $limited = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0'])->seasons; + try { + $limited->releaseSeasonLapsedOffers('sea_1'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(3, $this->calls); + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertCount(1, array_unique($keys)); + } + } + + public function testSingleTicketsReachTheWireOnCreateAndUpdate(): void + { + $updated = [ + 'season' => ['key' => 'sea_1', 'singleTickets' => true], + 'singleTicketDates' => [ + 'direction' => 'open', + 'opened' => ['ev_1'], + 'closed' => [], + 'notOpened' => [], + 'jobId' => null, + ], + ]; + $seasons = $this->client(200, json_encode($updated, JSON_THROW_ON_ERROR))->seasons; + + $seasons->createSeason(['name' => 'Series', 'eventKeys' => ['ev_1', 'ev_2'], 'singleTickets' => true]); + $answer = $seasons->updateSeason('sea/a', ['expectedRevision' => 5, 'singleTickets' => false], 'update-1'); + + self::assertSame($updated, $answer); + self::assertSame(['POST', 'PATCH'], array_column($this->calls, 'method')); + self::assertSame([ + 'https://api.seatlayer.io/v1/seasons', + 'https://api.seatlayer.io/v1/seasons/sea%2Fa', + ], array_column($this->calls, 'url')); + self::assertSame('{"name":"Series","eventKeys":["ev_1","ev_2"],"singleTickets":true}', $this->calls[0]['body']); + self::assertSame('{"expectedRevision":5,"singleTickets":false}', $this->calls[1]['body']); + self::assertSame('update-1', $this->calls[1]['headers']['Idempotency-Key']); + } + + public function testInventoryModeReachesTheWireOnlyWhenGivenAndReadsBack(): void + { + $season = ['season' => ['key' => 'sea_1', 'revision' => 5, 'inventoryMode' => 'block']]; + $seasons = $this->client(200, json_encode($season, JSON_THROW_ON_ERROR))->seasons; + + $created = $seasons->createSeason(['name' => 'Series', 'eventKeys' => ['ev_1', 'ev_2'], 'inventoryMode' => 'block'], 'create-1'); + $seasons->createSeason(['name' => 'Series', 'eventKeys' => ['ev_1', 'ev_2']], 'create-2'); + $turnedOn = $seasons->updateSeason('sea/a', ['expectedRevision' => 5, 'inventoryMode' => 'block'], 'update-1'); + $seasons->updateSeason('sea/a', ['expectedRevision' => 5, 'inventoryMode' => 'per_hold'], 'update-2'); + $seasons->updateSeason('sea/a', ['expectedRevision' => 5, 'name' => 'Series 2'], 'update-3'); + $retrieved = $seasons->retrieveSeason('sea/a'); + + self::assertSame(['POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'GET'], array_column($this->calls, 'method')); + self::assertSame('{"name":"Series","eventKeys":["ev_1","ev_2"],"inventoryMode":"block"}', $this->calls[0]['body']); + self::assertSame('{"name":"Series","eventKeys":["ev_1","ev_2"]}', $this->calls[1]['body']); + self::assertSame('{"expectedRevision":5,"inventoryMode":"block"}', $this->calls[2]['body']); + self::assertSame('{"expectedRevision":5,"inventoryMode":"per_hold"}', $this->calls[3]['body']); + self::assertSame('{"expectedRevision":5,"name":"Series 2"}', $this->calls[4]['body']); + self::assertSame('https://api.seatlayer.io/v1/seasons/sea%2Fa', $this->calls[5]['url']); + self::assertSame($season, $created); + self::assertSame('block', $turnedOn['season']['inventoryMode']); + self::assertSame('block', $retrieved['season']['inventoryMode']); + + $list = ['seasons' => [ + ['key' => 'sea_1', 'inventoryMode' => 'block'], + ['key' => 'sea_2', 'inventoryMode' => 'per_hold'], + ], 'nextCursor' => null]; + $seasons = $this->client(200, json_encode($list, JSON_THROW_ON_ERROR))->seasons; + self::assertSame(['block', 'per_hold'], array_column($seasons->listSeasons()['seasons'], 'inventoryMode')); + } + + public function testInventoryModeRefusalsReachTheCallerWithTheirCode(): void + { + $refusals = [ + [409, 'season_block_packages'], + [409, 'season_block_on_sale'], + [409, 'season_not_draft_or_active'], + [409, 'season_block_exists'], + [422, 'invalid_inventory_mode'], + ]; + foreach ($refusals as [$status, $code]) { + $seasons = $this->client($status, json_encode(['error' => $code, 'code' => $code, 'message' => $code], JSON_THROW_ON_ERROR))->seasons; + try { + $seasons->updateSeason('sea_1', ['expectedRevision' => 5, 'inventoryMode' => 'block']); + self::fail('expected SeatLayerException'); + } catch (SeatLayerException $error) { + self::assertSame($status, $error->status); + self::assertSame($code, $error->errorCode); + } + } + + $seasons = $this->client(409, json_encode(['error' => 'block_not_ready', 'code' => 'block_not_ready'], JSON_THROW_ON_ERROR))->seasons; + try { + $seasons->openSeasonSales('sea_1', 5); + self::fail('expected ConflictException'); + } catch (ConflictException $error) { + self::assertSame('block_not_ready', $error->errorCode); + } + } + + public function testRenewalNoticesForOneOfferBatchSendItsBatchId(): void + { + $answer = ['scheduled' => 240, 'withoutEmail' => 3]; + $seasons = $this->client(200, json_encode($answer, JSON_THROW_ON_ERROR))->seasons; + + self::assertSame($answer, $seasons->createSeasonRenewalNotices('sea/a', batchId: 'srb_1')); + self::assertCount(1, $this->calls); + self::assertSame('POST', $this->calls[0]['method']); + self::assertSame('https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-notices', $this->calls[0]['url']); + self::assertSame('{"batchId":"srb_1"}', $this->calls[0]['body']); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[0]['headers']); + } + + public function testRenewalDeclineReasonReachesTheWireAndTheCheckoutRetriesWithOneKey(): void + { + $seasons = $this->client()->seasons; + + $seasons->declineSeasonRenewalOffer('sea/a', 'offer/1', 'Moving away'); + $seasons->declineSeasonRenewalOffer('sea_1', 'offer_1'); + + self::assertSame([ + 'https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/decline', + 'https://api.seatlayer.io/v1/seasons/sea_1/renewal-offers/offer_1/decline', + ], array_column($this->calls, 'url')); + self::assertSame(['{"reason":"Moving away"}', '{}'], array_column($this->calls, 'body')); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[0]['headers']); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[1]['headers']); + + $limited = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0'])->seasons; + try { + $limited->createSeasonRenewalCheckout('sea_1', 'offer_1', ['returnUrl' => 'https://tickets.example/r']); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(3, $this->calls); + $keys = array_map(static fn (array $call): string => $call['headers']['Idempotency-Key'], $this->calls); + self::assertCount(1, array_unique($keys)); + } + + try { + $limited->createSeasonRenewalIntent('sea_1', 'offer_1'); + self::fail('expected RateLimitException'); + } catch (RateLimitException) { + self::assertCount(4, $this->calls); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[3]['headers']); + } + } + + public function testPriceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWire(): void + { + $seasons = $this->client()->seasons; + + $seasons->replaceSeasonPackagePrices('sea_1', 'plan_1', ['prices' => [ + ['categoryKey' => 'stalls', 'unitPrice' => 18000], + ['categoryKey' => 'stalls', 'priceTypeKey' => 'child', 'unitPrice' => 9000], + ]]); + $seasons->updateSeason('sea_1', [ + 'expectedRevision' => 3, + 'checkoutMode' => 'seatlayer', + 'transferCutoffHours' => 24, + 'returnCutoffHours' => null, + 'audience' => 'public', + ]); + $seasons->createSeasonHolderImport('sea_1', [ + 'successorPlanActivationId' => 'pa_1', + 'rows' => [[ + 'rowId' => 'r1', + 'holderRef' => 'h1', + 'priorPlanActivationId' => 'pa_0', + 'priorContractRef' => 'c1', + 'labels' => ['A-1'], + 'holderEmail' => 'ana@example.com', + 'holderName' => 'Ana Silva', + ]], + ]); + $seasons->createSeasonAmendment( + 'sea_1', + ['eventKey' => 'ev_night_3', 'kind' => 'cancel', 'refund' => 'per_date', 'reason' => 'Storm'], + 'night-3-storm', + ); + $seasons->createSeasonAmendment('sea_1', ['eventKey' => 'ev_1', 'kind' => 'postpone']); + $seasons->refundSeasonPerformance('sea_1', 'ev_1', ['amount' => 'per_date']); + + self::assertSame([ + '{"prices":[{"categoryKey":"stalls","unitPrice":18000},' + . '{"categoryKey":"stalls","priceTypeKey":"child","unitPrice":9000}]}', + '{"expectedRevision":3,"checkoutMode":"seatlayer","transferCutoffHours":24,"returnCutoffHours":null,' + . '"audience":"public"}', + '{"successorPlanActivationId":"pa_1","rows":[{"rowId":"r1","holderRef":"h1",' + . '"priorPlanActivationId":"pa_0","priorContractRef":"c1","labels":["A-1"],' + . '"holderEmail":"ana@example.com","holderName":"Ana Silva"}]}', + '{"eventKey":"ev_night_3","kind":"cancel","refund":"per_date","reason":"Storm"}', + '{"eventKey":"ev_1","kind":"postpone"}', + '{"amount":"per_date"}', + ], array_column($this->calls, 'body')); + self::assertSame('night-3-storm', $this->calls[3]['headers']['Idempotency-Key']); + // A refund without a caller key still gets one, so a retry replays the same job. + self::assertArrayHasKey('Idempotency-Key', $this->calls[5]['headers']); + self::assertSame('https://api.seatlayer.io/v1/seasons/sea_1/performances/ev_1/refunds', $this->calls[5]['url']); + } + + public function testNamesTheDateCancelVoidJobAndSizesAHolderImportCallByItsDates(): void + { + self::assertSame('sbj_void_amd_1', Seasons::dateCancelVoidJobId('amd_1')); + self::assertSame([100, 100, 95, 43], array_map( + static fn (int $dates): int => Seasons::holderImportRowsPerCall($dates), + [1, 20, 21, 50], + )); + + $seasons = $this->client(422, json_encode([ + 'error' => 'invalid_rows', + 'code' => 'invalid_rows', + 'message' => 'At most 43 rows a call for this package.', + 'maxRows' => 43, + ], JSON_THROW_ON_ERROR))->seasons; + try { + $seasons->createSeasonHolderImport('sea_1', ['successorPlanActivationId' => 'spa_1', 'rows' => []]); + self::fail('expected ValidationException'); + } catch (ValidationException $error) { + self::assertSame(422, $error->status); + self::assertSame('invalid_rows', $error->errorCode); + self::assertSame(43, $error->body['maxRows']); + self::assertCount(1, $this->calls); + } + } + + public function testNewSeasonOperationsSendExactBodies(): void + { + $seasons = $this->client()->seasons; + $key = 'sea/a'; + + $seasons->createSeasonHolderImportFromSeason( + $key, + ['sourceSeasonId' => 'sea_2026', 'successorPlanActivationId' => 'pa_1', 'dryRun' => true], + ); + $seasons->createSeasonPerformances($key, [ + 'titlePattern' => 'Night {n}', + 'timezone' => 'Europe/London', + 'repeat' => 'dates', + 'dates' => [1798761600000, 1799366400000], + 'venue' => null, + 'region' => 'western-europe', + 'attachToPlanKeys' => ['plan_1'], + ]); + $seasons->attachSeasonPerformances( + $key, + ['eventKeys' => ['ev_1', 'ev_2'], 'attachToPlanKeys' => ['plan_1']], + ); + $seasons->deleteSeasonPerformance($key, 'ev/1'); + $seasons->updateSeasonPlan($key, 'plan/1', ['eventKeys' => ['ev_1', 'ev_2']]); + $seasons->publishSeason($key); + $seasons->createSeasonCheckoutTest($key); + $seasons->replaceSeasonPackagePrices( + $key, + 'plan/1', + ['prices' => [['categoryKey' => 'stalls', 'unitPrice' => 12000]]], + ); + $seasons->attachSeasonPlanPerformance($key, 'plan/1', 'ev_3'); + $seasons->markSeasonPerformancePast($key, 'ev/1'); + $seasons->returnSeasonOccurrenceInventory( + $key, + 'ev/1', + 'return_1', + 'order_1', + 'pa_1', + ['A-1'], + ['bookingActionId' => 'book_1'], + ); + $seasons->reclaimSeasonOccurrenceInventory( + $key, + 'ev/1', + 'reclaim_1', + 'return_1', + 'order_1', + 'pa_1', + ['A-1'], + ['renewalOfferId' => 'offer_1'], + ); + $seasons->refundSeasonOrder($key, 'ord/1'); + $seasons->cancelSeasonOrder( + $key, + 'ord/1', + ['reason' => 'other', 'note' => 'Chargeback lost', 'refundConfirmed' => true], + ); + $seasons->cancelSeasonOrderDate($key, 'ord/1', 'ev/1'); + $seasons->resendSeasonOrderTickets($key, 'ord/1'); + $seasons->rotateSeasonRenewalOfferLink($key, 'offer/1'); + $seasons->updateSeasonRenewalOfferContact($key, 'offer/1', ['name' => 'Ada Holder']); + $seasons->createSeasonRenewalNotices($key); + $seasons->resendSeasonRenewalOffer($key, 'offer/1'); + $seasons->listSeasonHolders($key); + + self::assertSame([ + '{"sourceSeasonId":"sea_2026","successorPlanActivationId":"pa_1","dryRun":true}', + '{"titlePattern":"Night {n}","timezone":"Europe\\/London","repeat":"dates",' + . '"dates":[1798761600000,1799366400000],"venue":null,"region":"western-europe",' + . '"attachToPlanKeys":["plan_1"]}', + '{"eventKeys":["ev_1","ev_2"],"attachToPlanKeys":["plan_1"]}', + null, + '{"eventKeys":["ev_1","ev_2"]}', + '{}', + '{}', + '{"prices":[{"categoryKey":"stalls","unitPrice":12000}]}', + '{"eventKey":"ev_3"}', + '{}', + '{"actionId":"return_1","bookingRef":"order_1","planActivationId":"pa_1","labels":["A-1"],' + . '"source":{"bookingActionId":"book_1"}}', + '{"actionId":"reclaim_1","returnActionId":"return_1","bookingRef":"order_1",' + . '"planActivationId":"pa_1","labels":["A-1"],"source":{"renewalOfferId":"offer_1"}}', + '{}', + '{"reason":"other","note":"Chargeback lost","refundConfirmed":true}', + '{}', + '{}', + '{}', + '{"name":"Ada Holder"}', + '{}', + '{}', + null, + ], array_column($this->calls, 'body')); + + // Replay-backed calls without a caller key still get a generated one. + foreach ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13, 14] as $index) { + self::assertArrayHasKey('Idempotency-Key', $this->calls[$index]['headers']); + } + foreach ([10, 11, 15, 16, 17, 18, 19, 20] as $index) { + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[$index]['headers']); + } + self::assertSame('https://api.seatlayer.io/v1/seasons/sea%2Fa/holders', $this->calls[20]['url']); + } + + public function testSeasonCsvExportsReturnTheRawText(): void + { + $csv = "holderRef,name\nH-1,\"Smith, Ada\"\n"; + $seasons = $this->client(200, $csv)->seasons; + + self::assertSame($csv, $seasons->exportSeasonHoldersCsv('sea/a')); + self::assertSame($csv, $seasons->exportSeasonOrdersCsv('sea/a')); + self::assertSame('GET', $this->calls[0]['method']); + self::assertSame('https://api.seatlayer.io/v1/seasons/sea%2Fa/reports/holders.csv', $this->calls[0]['url']); + self::assertStringStartsWith('text/csv', $this->calls[0]['headers']['Accept']); + self::assertArrayNotHasKey('Idempotency-Key', $this->calls[1]['headers']); + } + + public function testDeleteSeasonPlanAcceptsAnEmpty204(): void + { + $seasons = $this->client(204, '')->seasons; + + $seasons->deleteSeasonPlan('sea/a', 'plan/1', 'plan-del-1'); + + self::assertCount(1, $this->calls); + self::assertSame('DELETE', $this->calls[0]['method']); + self::assertSame('https://api.seatlayer.io/v1/seasons/sea%2Fa/plans/plan%2F1', $this->calls[0]['url']); + self::assertNull($this->calls[0]['body']); + self::assertSame('plan-del-1', $this->calls[0]['headers']['Idempotency-Key']); } }