From e95be12344e7496f7862cae13135d6d669c780b3 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:52:30 +0530 Subject: [PATCH 01/14] Season operations: the 32 missing season methods --- src/HttpClient.php | 29 ++- src/Resources/Seasons.php | 525 ++++++++++++++++++++++++++++++++++++++ tests/SeasonTest.php | 248 +++++++++++++++++- 3 files changed, 798 insertions(+), 4 deletions(-) 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/Seasons.php b/src/Resources/Seasons.php index a848176..b831915 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -495,4 +495,529 @@ public function exportSeasonSupportSnapshot(string $seasonKey): array { return $this->get($seasonKey, '/export'); } + + /** + * Dry-run or commit renewal rights derived from a previous season's holders. + * + * @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. + * + * @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. + * + * @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. + * + * @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 per offer. + * + * @return array + */ + public function retrieveSeasonHostedRenewals(string $seasonKey): array + { + return $this->get($seasonKey, '/renewal-hosted'); + } + + /** + * 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. + * + * @param list|null $offerIds Only these offers; null means every open offer. + * @return array + */ + public function createSeasonRenewalNotices(string $seasonKey, ?array $offerIds = null): array + { + return $this->post( + $seasonKey, + '/renewal-notices', + $offerIds === null ? [] : ['offerIds' => $offerIds], + ); + } + + /** + * 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 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'); + } } diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 2f1976c..a869634 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -9,7 +9,29 @@ 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. */ + private function client(int $status = 200, string $body = '{}'): SeatLayer + { + $this->calls = []; + $transport = function ( + string $method, + string $url, + array $headers, + ?string $payload, + float $_timeout, + ) use ($status, $body): array { + $this->calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $payload]; + + return ['status' => $status, 'body' => $body, 'headers' => []]; + }; + + return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); + } + + public function testMapsAll80SeasonOperationsAndReplayClasses(): void { /** @var list,body:?string}> $calls */ $calls = []; @@ -81,8 +103,70 @@ 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); - self::assertCount(48, $calls); + self::assertCount(80, $calls); $actual = array_map(static fn (array $call): array => [ $call['method'], substr($call['url'], strlen('https://api.seatlayer.io')), @@ -124,10 +208,36 @@ 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'], ], $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]; foreach ($calls as $index => $call) { if (in_array($index, $replayIndexes, true)) { self::assertArrayHasKey('Idempotency-Key', $call['headers']); @@ -135,5 +245,137 @@ 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']); + } + + 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']); } } From 85d779cf4b6f663daa4ba68fcf45f29d29bb5b19 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:52:30 +0530 Subject: [PATCH 02/14] Holders, orders, event sales and event series operations --- src/Resources/EventSeries.php | 92 ++++++++++ src/Resources/Events.php | 195 ++++++++++++++++++++++ src/Resources/Holders.php | 185 +++++++++++++++++++++ src/Resources/Orders.php | 176 ++++++++++++++++++++ src/SeatLayer.php | 9 + tests/SalesOperationsTest.php | 304 ++++++++++++++++++++++++++++++++++ 6 files changed, 961 insertions(+) create mode 100644 src/Resources/EventSeries.php create mode 100644 src/Resources/Holders.php create mode 100644 src/Resources/Orders.php create mode 100644 tests/SalesOperationsTest.php diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php new file mode 100644 index 0000000..d7197cb --- /dev/null +++ b/src/Resources/EventSeries.php @@ -0,0 +1,92 @@ +,from?:string,until?:string,count?:int,skip?:list,dates?:list},timezone?:string,venue?:string,durationMinutes?:int,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: either a `schedule` or + * exact `dates` (`YYYY-MM-DDTHH:MM` in the series' zone). + * + * @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} $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. + * + * @param array{name?:string,prices?:array{categories: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..eefc6b8 100644 --- a/src/Resources/Events.php +++ b/src/Resources/Events.php @@ -291,4 +291,199 @@ 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. + * + * @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..fadb6c2 --- /dev/null +++ b/src/Resources/Holders.php @@ -0,0 +1,185 @@ + + */ + 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. + * + * @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'), + ); + } +} diff --git a/src/Resources/Orders.php b/src/Resources/Orders.php new file mode 100644 index 0000000..f60a6da --- /dev/null +++ b/src/Resources/Orders.php @@ -0,0 +1,176 @@ + + */ + public function listOrders( + ?string $eventKey = null, + ?string $seasonKey = null, + ?string $status = null, + ?int $from = null, + ?int $to = null, + ?string $cursor = null, + ?int $limit = null, + ): array { + /** @var array */ + return (array) $this->http->get('/v1/orders', array_filter([ + 'eventKey' => $eventKey, + 'seasonKey' => $seasonKey, + '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/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/SalesOperationsTest.php b/tests/SalesOperationsTest.php new file mode 100644 index 0000000..1a5937c --- /dev/null +++ b/tests/SalesOperationsTest.php @@ -0,0 +1,304 @@ +,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'); + + 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], + ], $this->recorded()); + self::assertSame('holder-update-1', $this->calls[2]['headers']['Idempotency-Key']); + self::assertSame('transfer-1', $this->calls[8]['headers']['Idempotency-Key']); + } + + public function testOrderOperations(): void + { + $orders = $this->client()->orders; + + $orders->listOrders('ev/1', 'sea/a', 'paid', 1, 2, 'c/1', 10); + $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&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', + ); + + 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, + ], + ], $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 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']); + } + } +} From 9693debd8c6edb5acee32e8c013102be72a38450 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:52:30 +0530 Subject: [PATCH 03/14] Docs: README and changelog for the season 2a server operations --- CHANGELOG.md | 18 +++++++++++++ README.md | 72 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3814be..0d4e6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased + +- Added the 32 remaining Season organizer operations to `$seatlayer->seasons`, which now + has 80: 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, the sales, + seats and seat-count reports, and holder and order CSV exports. +- 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 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. + ## 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..2660be3 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ 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 80 trusted organizer operations: the 48 released in `0.7.0`, +plus performances and packages, package prices, checkout tests, occurrence returns and +reclaims, holders, season orders, hosted renewals, sales and seat reports, and CSV exports. After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal($seasonKey)` sends no evidence body; SeatLayer @@ -103,6 +104,33 @@ 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. +`exportSeasonHoldersCsv()` and `exportSeasonOrdersCsv()` return the CSV text as a string, +not decoded JSON. `deleteSeasonPlan()` returns nothing (the API answers `204`). + +## 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 @@ -320,14 +348,24 @@ 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. Thirty-six +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`, and `cancelSeasonOrderDate`; +- `holders->updateHolder`, `holders->createHolderTransfer`, `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 +376,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 +404,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` | | `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` | 80 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 and hosted renewals, occurrence amendments, returns and reclaims, reports and CSV exports, audit, outbox, and support export | Full reference: [SeatLayer PHP server SDK guide](https://docs.seatlayer.io/server-sdk/php/) From 2b4dce56084ece7cb7f95ddc01ee4fdf1724e218 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 20:43:34 +0530 Subject: [PATCH 04/14] Holders: createHolderReturn (give back one seat on one date) --- CHANGELOG.md | 1 + README.md | 8 ++++---- src/Resources/Holders.php | 24 +++++++++++++++++++++++- tests/SalesOperationsTest.php | 26 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4e6e1..319bb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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 diff --git a/README.md b/README.md index 2660be3..32c7919 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ 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. Thirty-six +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Thirty-seven mutations use exact header replay: - `charts->create`, `charts->copy`, `templates->instantiateTemplate`, `events->create`, @@ -360,8 +360,8 @@ mutations use exact header replay: `replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, `refundSeasonOrder`, `cancelSeasonOrder`, and `cancelSeasonOrderDate`; -- `holders->updateHolder`, `holders->createHolderTransfer`, `orders->refundEventOrder`, - `orders->cancelEventOrder`, `eventSeries->createEventSeries`, +- `holders->updateHolder`, `holders->createHolderTransfer`, `holders->createHolderReturn`, + `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 @@ -414,7 +414,7 @@ occurrence, holder, order, reporting, outbox, and support operations. | `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` | +| `holders` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` | | `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` | diff --git a/src/Resources/Holders.php b/src/Resources/Holders.php index fadb6c2..d19cfe5 100644 --- a/src/Resources/Holders.php +++ b/src/Resources/Holders.php @@ -6,7 +6,7 @@ use SeatLayer\HttpClient; -/** Season ticket holders, the customers behind them, and holder ticket transfers. */ +/** Season ticket holders, the customers behind them, and holder ticket transfers and returns. */ final class Holders { public function __construct(private readonly HttpClient $http) @@ -182,4 +182,26 @@ public function acceptHolderTransfer(string $holderRef, string $transferId): arr 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. + * + * @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, + ); + } } diff --git a/tests/SalesOperationsTest.php b/tests/SalesOperationsTest.php index 1a5937c..f157e9e 100644 --- a/tests/SalesOperationsTest.php +++ b/tests/SalesOperationsTest.php @@ -69,6 +69,7 @@ public function testHolderOperations(): void $holders->listHolderTransfers('H/1'); $holders->cancelHolderTransfer('H/1', 'xfer/1'); $holders->acceptHolderTransfer('H/1', 'xfer/1'); + $holders->createHolderReturn('H/1', ['tkt_0123456789abcdef01234567'], 'return-1'); self::assertSame([ ['GET', '/v1/customers?email=ada%40example.com&holderRef=H%2F1&cursor=c%2F1&limit=10', null, false], @@ -93,9 +94,11 @@ public function testHolderOperations(): void ['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], ], $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']); } public function testOrderOperations(): void @@ -272,6 +275,29 @@ public function testEventSeriesOperations(): void self::assertSame('series-update-1', $this->calls[4]['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 testReplayMutationsRetryWithOneKeyAndUnsupportedOnesAreSentOnce(): void { $sdk = $this->client(429, '{"error":"rate_limited"}', ['retry-after' => '0']); From cf5e59808caa57ccbf57f82a83b278200687ac21 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:13:43 +0530 Subject: [PATCH 05/14] Seasons: bulk refund and jobs, price type and sale setting shapes with wire tests, events seriesKey --- CHANGELOG.md | 14 ++++++-- README.md | 18 +++++++---- src/Resources/Events.php | 8 +++-- src/Resources/Seasons.php | 68 ++++++++++++++++++++++++++++++++++++--- tests/ClientTest.php | 29 +++++++++++++++++ tests/SeasonTest.php | 64 ++++++++++++++++++++++++++++++++++-- 6 files changed, 182 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 319bb7b..8377797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,12 @@ ## Unreleased -- Added the 32 remaining Season organizer operations to `$seatlayer->seasons`, which now - has 80: performances and packages (`createSeasonPerformances`, `attachSeasonPerformances`, +- Added the 35 remaining Season organizer operations to `$seatlayer->seasons`, which now + has 83: 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, the sales, - seats and seat-count reports, and holder and order CSV exports. + seats and seat-count reports, holder and order CSV exports, and bulk refunds 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` @@ -18,6 +18,14 @@ - 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. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 32c7919..95410ae 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,12 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`$seatlayer->seasons` exposes 80 trusted organizer operations: the 48 released in `0.7.0`, -plus performances and packages, package prices, checkout tests, occurrence returns and -reclaims, holders, season orders, hosted renewals, sales and seat reports, and CSV exports. +`$seatlayer->seasons` exposes 83 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, sales and seat reports, CSV exports, and +the bulk refund of a cancelled date (`refundSeasonPerformance`) with its jobs +(`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal($seasonKey)` sends no evidence body; SeatLayer @@ -234,6 +237,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 @@ -348,7 +353,7 @@ 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. Thirty-seven +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Thirty-eight mutations use exact header replay: - `charts->create`, `charts->copy`, `templates->instantiateTemplate`, `events->create`, @@ -359,7 +364,8 @@ mutations use exact header replay: `markSeasonPerformancePast`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, `replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, - `refundSeasonOrder`, `cancelSeasonOrder`, and `cancelSeasonOrderDate`; + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, and + `refundSeasonPerformance`; - `holders->updateHolder`, `holders->createHolderTransfer`, `holders->createHolderReturn`, `orders->refundEventOrder`, `orders->cancelEventOrder`, `eventSeries->createEventSeries`, `eventSeries->addEventSeriesDates`, and `eventSeries->updateEventSeries`. @@ -421,7 +427,7 @@ occurrence, holder, order, reporting, outbox, and support operations. | `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` | 80 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 and hosted renewals, occurrence amendments, returns and reclaims, reports and CSV exports, audit, outbox, and support export | +| `seasons` | 83 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 and hosted renewals, occurrence amendments, returns and reclaims, bulk refunds 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/Resources/Events.php b/src/Resources/Events.php index eefc6b8..2b87d92 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) { diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index b831915..0ac3ca5 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -81,7 +81,13 @@ 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 until it starts. + * + * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int} $params * @return array */ public function updateSeason(string $seasonKey, array $params, ?string $idempotencyKey = null): array @@ -298,6 +304,10 @@ public function validateSeasonBuyerRehearsal(string $seasonKey): array } /** + * Import incumbent holders. Each row takes `rowId`, `holderRef`, `priorPlanActivationId`, + * `priorContractRef` and `labels`, plus optional `existingBookingRef`, `holderEmail` and + * `holderName`. + * * @param array{successorPlanActivationId:string,dryRun?:bool,rows:list>} $params * @return array */ @@ -416,7 +426,11 @@ 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`, refunds its SeatLayer orders as a + * job (the answer's `refundJob`). + * + * @param array{eventKey:string,kind:'reschedule'|'replace'|'cancel_exception'|'postpone'|'cancel',startsAt?:int,name?:string,planActivationId?:string,refund?:'none'|'per_date'|'full',reason?:string} $params * @return array */ public function createSeasonAmendment( @@ -659,9 +673,10 @@ public function listSeasonCheckoutTests(string $seasonKey): array } /** - * Replace a package's price table, per chart category, in minor units. + * 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 + * @param array{prices:list,currency?:string} $params * @return array */ public function replaceSeasonPackagePrices( @@ -680,7 +695,8 @@ public function replaceSeasonPackagePrices( } /** - * List every package price table for the Season. + * List every package price table for the Season. Each price carries its `priceTypeKey` and + * `priceTypeLabel`. * * @return array */ @@ -1020,4 +1036,46 @@ 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` refunds what is left of each order. + * Retried safely with one Idempotency-Key, which replays the same job. + * + * @param array{amount:'per_date'|'full',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, + ); + } + + /** + * 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 and up to 500 failed items. + * + * @return array + */ + public function retrieveSeasonBulkJob(string $seasonKey, string $jobId): array + { + return $this->get($seasonKey, '/jobs/' . HttpClient::encode($jobId)); + } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index f49e218..31c411b 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' => 'full']); + + 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/SeasonTest.php b/tests/SeasonTest.php index a869634..d657833 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -31,7 +31,7 @@ private function client(int $status = 200, string $body = '{}'): SeatLayer return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); } - public function testMapsAll80SeasonOperationsAndReplayClasses(): void + public function testMapsAll83SeasonOperationsAndReplayClasses(): void { /** @var list,body:?string}> $calls */ $calls = []; @@ -165,8 +165,11 @@ public function testMapsAll80SeasonOperationsAndReplayClasses(): void $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'); - self::assertCount(80, $calls); + self::assertCount(83, $calls); $actual = array_map(static fn (array $call): array => [ $call['method'], substr($call['url'], strlen('https://api.seatlayer.io')), @@ -234,10 +237,12 @@ public function testMapsAll80SeasonOperationsAndReplayClasses(): void ['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'], ], $actual); self::assertNull($calls[26]['body']); - $replayIndexes = [2, 4, 5, 10, 18, 27, 29, 38, 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68]; + $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]; foreach ($calls as $index => $call) { if (in_array($index, $replayIndexes, true)) { self::assertArrayHasKey('Idempotency-Key', $call['headers']); @@ -247,6 +252,59 @@ public function testMapsAll80SeasonOperationsAndReplayClasses(): void } 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']); + } + + 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, + ]); + $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' => 'full']); + + self::assertSame([ + '{"prices":[{"categoryKey":"stalls","unitPrice":18000},' + . '{"categoryKey":"stalls","priceTypeKey":"child","unitPrice":9000}]}', + '{"expectedRevision":3,"checkoutMode":"seatlayer","transferCutoffHours":24,"returnCutoffHours":null}', + '{"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":"full"}', + ], 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 testNewSeasonOperationsSendExactBodies(): void From a662a517ff7b346507d5f835e9c7ceb3a843255a Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:36:57 +0530 Subject: [PATCH 06/14] Seasons and holders: renewal by API (2.20) --- CHANGELOG.md | 13 +++++-- README.md | 37 ++++++++++++++---- src/Resources/Holders.php | 42 +++++++++++++++++++- src/Resources/Seasons.php | 66 ++++++++++++++++++++++++++++++-- tests/SalesOperationsTest.php | 47 +++++++++++++++++++++++ tests/SeasonTest.php | 72 +++++++++++++++++++++++++++++++---- 6 files changed, 255 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8377797..167b137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ ## Unreleased -- Added the 35 remaining Season organizer operations to `$seatlayer->seasons`, which now - has 83: performances and packages (`createSeasonPerformances`, `attachSeasonPerformances`, +- Added the 38 remaining Season organizer operations to `$seatlayer->seasons`, which now + has 86: 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, the sales, + 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 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 @@ -26,6 +27,12 @@ - `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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 95410ae..262bdfc 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,11 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`$seatlayer->seasons` exposes 83 trusted organizer operations: the 48 released in `0.7.0`, +`$seatlayer->seasons` exposes 86 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, sales and seat reports, CSV exports, 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`) with its jobs (`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). @@ -110,6 +111,26 @@ header-replay catalogue mutations retry automatically. `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'); +``` + ## Orders, holders, event sales, and event series - `$seatlayer->orders` lists orders across events and seasons, reads one order and its @@ -353,7 +374,7 @@ 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. Thirty-eight +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Forty mutations use exact header replay: - `charts->create`, `charts->copy`, `templates->instantiateTemplate`, `events->create`, @@ -364,10 +385,10 @@ mutations use exact header replay: `markSeasonPerformancePast`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, `replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, - `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, and - `refundSeasonPerformance`; + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, + `refundSeasonPerformance`, and `createSeasonRenewalCheckout`; - `holders->updateHolder`, `holders->createHolderTransfer`, `holders->createHolderReturn`, - `orders->refundEventOrder`, `orders->cancelEventOrder`, `eventSeries->createEventSeries`, + `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 @@ -420,14 +441,14 @@ occurrence, holder, order, reporting, outbox, and support operations. | `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` | +| `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` | 83 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 and hosted renewals, occurrence amendments, returns and reclaims, bulk refunds and jobs, reports and CSV exports, audit, outbox, and support export | +| `seasons` | 86 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 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/Resources/Holders.php b/src/Resources/Holders.php index d19cfe5..35b91ea 100644 --- a/src/Resources/Holders.php +++ b/src/Resources/Holders.php @@ -6,7 +6,7 @@ use SeatLayer\HttpClient; -/** Season ticket holders, the customers behind them, and holder ticket transfers and returns. */ +/** Season ticket holders, the customers behind them, and holder ticket transfers, returns and renewals. */ final class Holders { public function __construct(private readonly HttpClient $http) @@ -204,4 +204,44 @@ public function createHolderReturn( $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/Seasons.php b/src/Resources/Seasons.php index 0ac3ca5..8db695f 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -399,13 +399,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), ); } @@ -992,6 +999,59 @@ public function resendSeasonRenewalOffer(string $seasonKey, string $offerId): ar ); } + /** + * 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. * diff --git a/tests/SalesOperationsTest.php b/tests/SalesOperationsTest.php index f157e9e..47f9654 100644 --- a/tests/SalesOperationsTest.php +++ b/tests/SalesOperationsTest.php @@ -70,6 +70,14 @@ public function testHolderOperations(): void $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], @@ -95,10 +103,20 @@ public function testHolderOperations(): void ['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 @@ -298,6 +316,35 @@ public function testHolderReturnRetriesWithOneKey(): void } } + 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']); diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index d657833..1327353 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -5,6 +5,7 @@ namespace SeatLayer\Tests; use PHPUnit\Framework\TestCase; +use SeatLayer\RateLimitException; use SeatLayer\SeatLayer; final class SeasonTest extends TestCase @@ -12,8 +13,12 @@ final class SeasonTest extends TestCase /** @var list,body:?string}> */ private array $calls = []; - /** A client whose transport records every request and answers with one fixed response. */ - private function client(int $status = 200, string $body = '{}'): SeatLayer + /** + * 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 ( @@ -22,16 +27,16 @@ private function client(int $status = 200, string $body = '{}'): SeatLayer array $headers, ?string $payload, float $_timeout, - ) use ($status, $body): array { + ) use ($status, $body, $responseHeaders): array { $this->calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $payload]; - return ['status' => $status, 'body' => $body, 'headers' => []]; + return ['status' => $status, 'body' => $body, 'headers' => $responseHeaders]; }; return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); } - public function testMapsAll83SeasonOperationsAndReplayClasses(): void + public function testMapsAll86SeasonOperationsAndReplayClasses(): void { /** @var list,body:?string}> $calls */ $calls = []; @@ -168,8 +173,16 @@ public function testMapsAll83SeasonOperationsAndReplayClasses(): void $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'); - self::assertCount(83, $calls); + self::assertCount(86, $calls); $actual = array_map(static fn (array $call): array => [ $call['method'], substr($call['url'], strlen('https://api.seatlayer.io')), @@ -239,10 +252,13 @@ public function testMapsAll83SeasonOperationsAndReplayClasses(): void ['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'], ], $actual); self::assertNull($calls[26]['body']); - $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]; + $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]; foreach ($calls as $index => $call) { if (in_array($index, $replayIndexes, true)) { self::assertArrayHasKey('Idempotency-Key', $call['headers']); @@ -254,6 +270,48 @@ public function testMapsAll83SeasonOperationsAndReplayClasses(): void 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'], + ); + } + + 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 From 624b96f3fdf8f4e4e21cf129ef47da5bc53cdcc0 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 01:23:41 +0530 Subject: [PATCH 07/14] Seasons, series and orders: contract changes from the 2.23 gaps and the p2a-api review --- CHANGELOG.md | 6 ++++++ src/Resources/EventSeries.php | 8 +++++--- src/Resources/Orders.php | 5 +++++ src/Resources/Seasons.php | 27 +++++++++++++++++++-------- tests/ClientTest.php | 2 +- tests/SalesOperationsTest.php | 12 ++++++++++-- tests/SeasonTest.php | 8 +++++--- 7 files changed, 51 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167b137..53559d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ - 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`. ## 0.8.0 (2026-09-10) diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php index d7197cb..8d2e2b1 100644 --- a/src/Resources/EventSeries.php +++ b/src/Resources/EventSeries.php @@ -57,10 +57,12 @@ public function retrieveEventSeries(string $eventKey): array } /** - * Add dates to an event series, copied from its first date: either a `schedule` or - * exact `dates` (`YYYY-MM-DDTHH:MM` in the series' zone). + * 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} $params + * @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 diff --git a/src/Resources/Orders.php b/src/Resources/Orders.php index f60a6da..e03bcee 100644 --- a/src/Resources/Orders.php +++ b/src/Resources/Orders.php @@ -15,6 +15,9 @@ public function __construct(private readonly HttpClient $http) /** * List orders across events and seasons. `$from` and `$to` are epoch milliseconds. + * `$holderRef` lists one holder's orders: the Season order with that id and renewals offered + * to that reference. Each order carries `holderRef` and `customerId`; `buyer.email` is null + * for a box office sale taken without one. * * @return array */ @@ -26,11 +29,13 @@ public function listOrders( ?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, diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index 8db695f..44f45fc 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -86,8 +86,10 @@ public function retrieveSeason(string $seasonKey): array * `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 until it starts. + * `audience` `public` lists the Season on your SeatLayer website; an ended Season cannot + * become public (`409 sales_ended`). * - * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int} $params + * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int,audience?:'embed_only'|'public'} $params * @return array */ public function updateSeason(string $seasonKey, array $params, ?string $idempotencyKey = null): array @@ -179,7 +181,14 @@ 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. + * + * @return array + */ public function openSeasonSales(string $seasonKey, int $expectedRevision): array { return $this->sales($seasonKey, 'open', $expectedRevision); @@ -434,10 +443,11 @@ public function listSeasonOccurrences(string $seasonKey): array /** * 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`, refunds its SeatLayer orders as a - * job (the answer's `refundJob`). + * `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. * - * @param array{eventKey:string,kind:'reschedule'|'replace'|'cancel_exception'|'postpone'|'cancel',startsAt?:int,name?:string,planActivationId?:string,refund?:'none'|'per_date'|'full',reason?:string} $params + * @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( @@ -1099,10 +1109,11 @@ public function retrieveSeasonSeatCounts(string $seasonKey): array /** * Refund every SeatLayer order holding a cancelled date, as a job. `per_date` refunds the - * date's share of each order (rounded down); `full` refunds what is left of each order. - * Retried safely with one Idempotency-Key, which replays the same job. + * 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'|'full',reason?:string} $params + * @param array{amount:'per_date',reason?:string} $params * @return array */ public function refundSeasonPerformance( diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 31c411b..d2d1327 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -428,7 +428,7 @@ public function testBulkRefundOfADateRetriesWithOneKey(): void ['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' => 'full']); + $result = $sdk->seasons->refundSeasonPerformance('sea_1', 'ev_1', ['amount' => 'per_date']); self::assertSame(['job' => ['jobId' => 'sbj_1']], $result); self::assertCount(2, $this->calls); diff --git a/tests/SalesOperationsTest.php b/tests/SalesOperationsTest.php index 47f9654..391a5f6 100644 --- a/tests/SalesOperationsTest.php +++ b/tests/SalesOperationsTest.php @@ -123,7 +123,7 @@ public function testOrderOperations(): void { $orders = $this->client()->orders; - $orders->listOrders('ev/1', 'sea/a', 'paid', 1, 2, 'c/1', 10); + $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); @@ -140,7 +140,12 @@ public function testOrderOperations(): void $orders->resendEventOrderTickets('ev/1', 'ord/1'); self::assertSame([ - ['GET', '/v1/orders?eventKey=ev%2F1&seasonKey=sea%2Fa&status=paid&from=1&to=2&cursor=c%2F1&limit=10', null, false], + [ + '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], [ @@ -269,6 +274,7 @@ public function testEventSeriesOperations(): void ['sales' => 'off', 'prices' => ['categories' => [['key' => 'stalls', 'price' => 25]]]], 'series-update-1', ); + $series->addEventSeriesDates('ser/1', ['retryFailed' => true]); self::assertSame([ [ @@ -287,6 +293,8 @@ public function testEventSeriesOperations(): void '{"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']); diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 1327353..4417cae 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -327,6 +327,7 @@ public function testPriceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWi 'checkoutMode' => 'seatlayer', 'transferCutoffHours' => 24, 'returnCutoffHours' => null, + 'audience' => 'public', ]); $seasons->createSeasonHolderImport('sea_1', [ 'successorPlanActivationId' => 'pa_1', @@ -346,18 +347,19 @@ public function testPriceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWi 'night-3-storm', ); $seasons->createSeasonAmendment('sea_1', ['eventKey' => 'ev_1', 'kind' => 'postpone']); - $seasons->refundSeasonPerformance('sea_1', 'ev_1', ['amount' => 'full']); + $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}', + '{"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":"full"}', + '{"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. From 242769f4d71ee510f09d4304786135a136ce7b12 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 04:04:21 +0530 Subject: [PATCH 08/14] Event series: create from an event, and every date keeps the plain title (2.16c) createEventSeries takes fromEventKey: an event already set up becomes the first date under its own key and every other date is a copy of it; name and chartKey are then left out. Every date of a series carries the series' plain name. Matches seatmap slice 2.16c. --- CHANGELOG.md | 2 ++ src/Resources/EventSeries.php | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53559d8..ab4cc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ - `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}`. ## 0.8.0 (2026-09-10) diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php index 8d2e2b1..c59013f 100644 --- a/src/Resources/EventSeries.php +++ b/src/Resources/EventSeries.php @@ -20,9 +20,15 @@ public function __construct(private readonly HttpClient $http) } /** - * Plan an event series from a schedule (`dryRun`), or make it as a job. + * Plan an event series from a schedule (`dryRun`), or make it as a job. Every date + * carries the series name as its name. * - * @param array{name:string,chartKey:string,schedule:array{rule:'none'|'daily'|'weekly'|'biweekly'|'dates',times:list,from?:string,until?:string,count?:int,skip?:list,dates?:list},timezone?:string,venue?:string,durationMinutes?:int,dryRun?:bool} $params + * Give `name` and `chartKey`, or `fromEventKey`: an event you already set up becomes + * the first date under its own key, and every other date is a copy of it (floor plan, + * prices, checkout, poster and its own seat blocks, none of its orders, holds or sales). + * The series takes the event's name, zone and floor plan. + * + * @param array{name:string,chartKey:string,schedule:array{rule:'none'|'daily'|'weekly'|'biweekly'|'dates',times:list,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 From 6b72fa6620aa297d06800866a1b87d48ff8746c8 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:02:33 +0530 Subject: [PATCH 09/14] Seasons: renewal at scale contract changes (2.8) Adds releaseSeasonLapsedOffers (POST /v1/seasons/{key}/renewal-offers/release-lapsed, no body, header replay, 202 with the job). retrieveSeasonHostedRenewals pages with cursor and limit (1-500) and answers nextCursor, and listSeasonRenewalOffers pages the same way (1-100). createSeasonRenewalOffers answers 201 {offers} for 100 contracts or fewer and 202 {job, batchId} above that. The bring-across (createSeasonHolderImportFromSeason) answers 202 with a holder_import job above 100 holders. Renewal emails take up to 20,000 offer ids; above that, 422 too_many_offer_ids. --- CHANGELOG.md | 12 ++++-- README.md | 17 +++++--- src/Resources/Seasons.php | 71 +++++++++++++++++++++++++++----- tests/SeasonTest.php | 86 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4cc71..faa4238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ ## Unreleased -- Added the 38 remaining Season organizer operations to `$seatlayer->seasons`, which now - has 86: performances and packages (`createSeasonPerformances`, `attachSeasonPerformances`, +- 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 with their jobs. + 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` @@ -41,6 +42,11 @@ - 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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 262bdfc..5f32ff7 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,13 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`$seatlayer->seasons` exposes 86 trusted organizer operations: the 48 released in `0.7.0`, +`$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`) with its jobs +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, @@ -108,6 +109,12 @@ 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`). @@ -374,7 +381,7 @@ 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. Forty +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`, @@ -386,7 +393,7 @@ mutations use exact header replay: `replaceSeasonPackagePrices`, `createSeasonCheckoutTest`, `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, - `refundSeasonPerformance`, and `createSeasonRenewalCheckout`; + `refundSeasonPerformance`, `releaseSeasonLapsedOffers`, and `createSeasonRenewalCheckout`; - `holders->updateHolder`, `holders->createHolderTransfer`, `holders->createHolderReturn`, `holders->createHolderRenewalCheckout`, `orders->refundEventOrder`, `orders->cancelEventOrder`, `eventSeries->createEventSeries`, `eventSeries->addEventSeriesDates`, and `eventSeries->updateEventSeries`. @@ -448,7 +455,7 @@ occurrence, holder, order, reporting, outbox, and support operations. | `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` | 86 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 and jobs, reports and CSV exports, 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/Resources/Seasons.php b/src/Resources/Seasons.php index 44f45fc..65596ae 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -340,6 +340,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 */ @@ -356,10 +362,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 */ @@ -530,6 +547,12 @@ public function exportSeasonSupportSnapshot(string $seasonKey): array /** * 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 */ @@ -938,13 +961,21 @@ public function resendSeasonOrderTickets(string $seasonKey, string $orderId, ?st } /** - * Retrieve renewal links, payments and emails per offer. + * 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): array - { - return $this->get($seasonKey, '/renewal-hosted'); + 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)); } /** @@ -979,7 +1010,8 @@ public function updateSeasonRenewalOfferContact(string $seasonKey, string $offer } /** - * Email renewal invitations now, and reminders before the deadline. + * 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. * @return array @@ -1130,6 +1162,24 @@ public function refundSeasonPerformance( ); } + /** + * 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). * @@ -1141,7 +1191,8 @@ public function listSeasonBulkJobs(string $seasonKey): array } /** - * Retrieve one bulk job with its counts and up to 500 failed items. + * Retrieve one bulk job with its counts, its `summary` once it completes, and up to 500 + * failed items. * * @return array */ diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 4417cae..9130ba4 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -36,7 +36,7 @@ private function client(int $status = 200, string $body = '{}', array $responseH return new SeatLayer('sk_test_abc', 'https://api.seatlayer.io', 3, 30.0, $transport); } - public function testMapsAll86SeasonOperationsAndReplayClasses(): void + public function testMapsAll87SeasonOperationsAndReplayClasses(): void { /** @var list,body:?string}> $calls */ $calls = []; @@ -181,8 +181,9 @@ public function testMapsAll86SeasonOperationsAndReplayClasses(): void 'buyerName' => 'Ana Silva', 'provider' => 'stripe', ], 'renew-1'); + $seasons->releaseSeasonLapsedOffers($key, 'release-lapsed-1'); - self::assertCount(86, $calls); + self::assertCount(87, $calls); $actual = array_map(static fn (array $call): array => [ $call['method'], substr($call['url'], strlen('https://api.seatlayer.io')), @@ -255,10 +256,11 @@ public function testMapsAll86SeasonOperationsAndReplayClasses(): void ['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'], ], $actual); self::assertNull($calls[26]['body']); - $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]; + $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]; foreach ($calls as $index => $call) { if (in_array($index, $replayIndexes, true)) { self::assertArrayHasKey('Idempotency-Key', $call['headers']); @@ -278,6 +280,84 @@ public function testMapsAll86SeasonOperationsAndReplayClasses(): void . '"buyerName":"Ana Silva","provider":"stripe"}', $calls[85]['body'], ); + self::assertSame('release-lapsed-1', $calls[86]['headers']['Idempotency-Key']); + self::assertNull($calls[86]['body']); + } + + /** + * 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 testRenewalDeclineReasonReachesTheWireAndTheCheckoutRetriesWithOneKey(): void From 1668bf85bf85f81d5cb0bb957a340ffafd2261e2 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:48:11 +0530 Subject: [PATCH 10/14] Seasons: renewal notices for one offer batch (2.8b) --- CHANGELOG.md | 1 + src/Resources/Seasons.php | 17 ++++++++++------- tests/SeasonTest.php | 13 +++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faa4238..1b6e3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ - `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. ## 0.8.0 (2026-09-10) diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index 65596ae..ef6b790 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -1014,15 +1014,18 @@ public function updateSeasonRenewalOfferContact(string $seasonKey, string $offer * 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): array - { - return $this->post( - $seasonKey, - '/renewal-notices', - $offerIds === null ? [] : ['offerIds' => $offerIds], - ); + 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)); } /** diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 9130ba4..5466020 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -360,6 +360,19 @@ public function testRenewalAtScaleAnswersJobs(): void } } + 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; From fe6bddc25b38b20471feebebcbb8a1f7df9f5e38 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 07:11:20 +0530 Subject: [PATCH 11/14] Seasons: single tickets on a season (2.15c) --- CHANGELOG.md | 1 + src/Resources/Seasons.php | 11 ++++++++--- tests/SeasonTest.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6e3c9..7ff7776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ - `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) diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index ef6b790..7ec84df 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -65,7 +65,10 @@ 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. + * + * @param array{name:string,edition?:?string,eventKeys?:list,sourcePerformanceGroupKeys?:list,singleTickets?:bool} $params * @return array */ public function createSeason(array $params, ?string $idempotencyKey = null): array @@ -87,9 +90,11 @@ public function retrieveSeason(string $seasonKey): array * 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 until it starts. * `audience` `public` lists the Season on your SeatLayer website; an ended Season cannot - * become public (`409 sales_ended`). + * 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. * - * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int,audience?:'embed_only'|'public'} $params + * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int,audience?:'embed_only'|'public',singleTickets?:bool} $params * @return array */ public function updateSeason(string $seasonKey, array $params, ?string $idempotencyKey = null): array diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 5466020..5e0e650 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -360,6 +360,34 @@ public function testRenewalAtScaleAnswersJobs(): void } } + 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 testRenewalNoticesForOneOfferBatchSendItsBatchId(): void { $answer = ['scheduled' => 240, 'withoutEmail' => 3]; From 96df7b80a7942840ab4a9cf2c77caa102d6a4701 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 12:36:26 +0530 Subject: [PATCH 12/14] Seasons: date cancel void job, 2-50 dates, singles job refusal, create_abandoned (p2b) The public API changes in p2b (b88ed794e..ee7df2921): Seasons::dateCancelVoidJobId names the date_cancel_void job a date cancel starts; Seasons::holderImportRowsPerCall sizes an import call by its dates (a call over it answers 422 invalid_rows with maxRows); updateSeason documents singleTicketDates.jobRefused; createEventSeries documents 409 create_abandoned. --- CHANGELOG.md | 13 +++++++++++++ src/Resources/EventSeries.php | 4 +++- src/Resources/Seasons.php | 35 ++++++++++++++++++++++++++++++++--- tests/SalesOperationsTest.php | 23 +++++++++++++++++++++++ tests/SeasonTest.php | 27 +++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff7776..fc69d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- 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, diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php index c59013f..32a182d 100644 --- a/src/Resources/EventSeries.php +++ b/src/Resources/EventSeries.php @@ -26,7 +26,9 @@ public function __construct(private readonly HttpClient $http) * Give `name` and `chartKey`, or `fromEventKey`: an event you already set up becomes * the first date under its own key, and every other date is a copy of it (floor plan, * prices, checkout, poster and its own seat blocks, none of its orders, holds or sales). - * The series takes the event's name, zone and floor plan. + * The series takes the event's name, zone and floor plan. A series from an event that never + * started and was given up answers `409 create_abandoned` for that Idempotency-Key every + * time: send the request again with a new key. * * @param array{name:string,chartKey:string,schedule:array{rule:'none'|'daily'|'weekly'|'biweekly'|'dates',times:list,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 diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index 7ec84df..bc8b00e 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -13,6 +13,27 @@ 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))); + } + private static function path(string $seasonKey, string $suffix = ''): string { return '/v1/seasons/' . HttpClient::encode($seasonKey) . $suffix; @@ -92,7 +113,10 @@ public function retrieveSeason(string $seasonKey): array * `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. + * 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. * * @param array{expectedRevision:int,name?:string,edition?:?string,checkoutMode?:'seatlayer'|'server',transferCutoffHours?:?int,returnCutoffHours?:?int,audience?:'embed_only'|'public',singleTickets?:bool} $params * @return array @@ -320,7 +344,9 @@ public function validateSeasonBuyerRehearsal(string $seasonKey): array /** * Import incumbent holders. Each row takes `rowId`, `holderRef`, `priorPlanActivationId`, * `priorContractRef` and `labels`, plus optional `existingBookingRef`, `holderEmail` and - * `holderName`. + * `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 @@ -467,7 +493,10 @@ public function listSeasonOccurrences(string $seasonKey): array * 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. + * 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()`. * * @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 diff --git a/tests/SalesOperationsTest.php b/tests/SalesOperationsTest.php index 391a5f6..acecfcd 100644 --- a/tests/SalesOperationsTest.php +++ b/tests/SalesOperationsTest.php @@ -301,6 +301,29 @@ public function testEventSeriesOperations(): void 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']); diff --git a/tests/SeasonTest.php b/tests/SeasonTest.php index 5e0e650..896265a 100644 --- a/tests/SeasonTest.php +++ b/tests/SeasonTest.php @@ -6,7 +6,9 @@ use PHPUnit\Framework\TestCase; use SeatLayer\RateLimitException; +use SeatLayer\Resources\Seasons; use SeatLayer\SeatLayer; +use SeatLayer\ValidationException; final class SeasonTest extends TestCase { @@ -488,6 +490,31 @@ public function testPriceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWi 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; From 1f50652415ff4fe5001dd0457d03e85c675c56d6 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 18:09:29 +0530 Subject: [PATCH 13/14] Docs: partial series prices, club switches, club-default cut-offs (p2b audit) updateEventSeries sends only the prices that change: each date keeps its own price for every category or tier left out. A season's null transfer or return cut-off means the club default from Settings > Ticket site, or until the date starts when the club has none. Holder transfers and returns through the secret key are not stopped by the club's customer switches; cut-offs still apply. --- CHANGELOG.md | 7 +++++++ src/Resources/EventSeries.php | 5 ++++- src/Resources/Holders.php | 6 +++++- src/Resources/Seasons.php | 3 ++- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc69d7f..3e97a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- `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 diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php index 32a182d..c983bd5 100644 --- a/src/Resources/EventSeries.php +++ b/src/Resources/EventSeries.php @@ -86,7 +86,10 @@ public function addEventSeriesDates(string $eventKey, array $params, ?string $id /** * Change the name, prices or sales of every upcoming date. * - * @param array{name?:string,prices?:array{categories:list}>},sales?:'on'|'off'} $params + * `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. + * + * @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 diff --git a/src/Resources/Holders.php b/src/Resources/Holders.php index 35b91ea..67ebe71 100644 --- a/src/Resources/Holders.php +++ b/src/Resources/Holders.php @@ -129,6 +129,9 @@ public function retrieveHolderTicket(string $holderRef, string $ticketId): array /** * 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 */ @@ -187,7 +190,8 @@ public function acceptHolderTransfer(string $holderRef, string $transferId): arr * 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. + * same return. Turning give-backs off in Settings › Ticket site doesn't stop your server; + * cut-offs still apply. * * @param list $ticketIds * @return array diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index bc8b00e..ca0b9b3 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -109,7 +109,8 @@ public function retrieveSeason(string $seasonKey): array * * `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 until it starts. + * 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. From 62bf35d92a5055e24e3ff98e6d21bf6abcd6a101 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 19:58:24 +0530 Subject: [PATCH 14/14] Docs: a cancelled season date stays closed and can't be amended (p2b part C) A date its season cancelled never opens again: moving its sales to testable or live, or reopening it, answers 409 season_date_cancelled, and an event series "sales on" leaves it closed as a failed item of the job (season_date_cancelled; not in failedDates). A cancelled date can't be moved, postponed, replaced or marked as taken place: 409 performance_cancelled. --- CHANGELOG.md | 6 ++++++ src/Resources/EventSeries.php | 4 ++++ src/Resources/Events.php | 7 ++++++- src/Resources/Seasons.php | 6 ++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e97a2a..9cfbf44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 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`. diff --git a/src/Resources/EventSeries.php b/src/Resources/EventSeries.php index c983bd5..a3c4141 100644 --- a/src/Resources/EventSeries.php +++ b/src/Resources/EventSeries.php @@ -89,6 +89,10 @@ public function addEventSeriesDates(string $eventKey, array $params, ?string $id * `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 */ diff --git a/src/Resources/Events.php b/src/Resources/Events.php index 2b87d92..ea9e2eb 100644 --- a/src/Resources/Events.php +++ b/src/Resources/Events.php @@ -222,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'); @@ -382,7 +386,8 @@ public function preflightEventSales(string $eventKey, string $orderId): array /** * Move an event's sales to testable, live, paused or ended. * - * A compare-and-set on `$expectedRevision`, sent once. + * 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 diff --git a/src/Resources/Seasons.php b/src/Resources/Seasons.php index ca0b9b3..534cc17 100644 --- a/src/Resources/Seasons.php +++ b/src/Resources/Seasons.php @@ -497,7 +497,8 @@ public function listSeasonOccurrences(string $seasonKey): array * 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()`. + * `retrieveSeasonBulkJob()`. A cancelled date can't be moved (`reschedule`), postponed or + * replaced: `409 performance_cancelled`. * * @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 @@ -823,7 +824,8 @@ public function deleteSeasonPlanPerformance( } /** - * Mark a performance past so it stops blocking new Season sales. + * Mark a performance past so it stops blocking new Season sales. A cancelled date answers + * `409 performance_cancelled`. * * @return array */