diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78c9d18..035a32b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,8 +71,8 @@ jobs: exit 1 fi count=$(wc -l < /tmp/contents.txt) - if [ "$count" -ne 13 ]; then - echo "::error::expected 13 files in the gem, found $count" + if [ "$count" -ne 16 ]; then + echo "::error::expected 16 files in the gem, found $count" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f4604..f73c216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,110 @@ # Changelog +## 0.9.0 (2026-11-11) + +- Season seats: `set_aside_season_block` (`POST /v1/seasons/:key/block`), `retrieve_season_block` + and `release_season_block` (`POST /v1/seasons/:key/block/release`). Each write answers "job" + and "block"; follow the job with `retrieve_season_bulk_job` (kinds `season_block_set_aside` and + `season_block_release`, read by id, not listed). Both writes are retried safely with one + Idempotency-Key, generated when none is given. +- `inventory_mode:` on season create and update (opt a season into season seats), read back on + every season (`"inventoryMode"`). +- Webhook names `season.block.ready` and `season.block.released` (`Webhooks::EVENT_NAMES`, now 58). +- A season-seats season's date cancel (`create_season_amendment`) or past date + (`mark_season_performance_past`) can answer `202` while the date leaves every season seat: + `Seasons.date_leave_pending?` recognises it. +- New refusals come through `SeatLayer::APIError` and its subclasses with `code`: + `season_block_not_enabled`, `block_exists`, `block_not_ready`, `season_not_draft_or_active`, + `block_selection_empty`, `seat_not_in_block`, `season_block_set_aside`, `season_inventory_locked`, + `date_leave_in_progress`. + `inventory_mode:` can also answer `season_block_packages` (more than one package), + `season_block_on_sale`, `season_block_exists` and `invalid_inventory_mode`. The set-aside can + also answer `season_block_packages`, `season_block_one_package`, `plan_not_published`, + `season_block_undoing`, `chart_unavailable` and `dates_missing`, and the release + `release_mismatch`. On `503 season_busy` or `season_not_configured`, send the same request again. + +- A date its season cancelled never opens again: `transition_event_sales` to `testable` or + `live` and `reopen` answer `409 season_date_cancelled`, and `update_event_series` with + `sales: "on"` leaves it closed as a failed item of the job (`season_date_cancelled`; not in + `failedDates`). +- `create_season_amendment` refuses to move (`reschedule`), postpone or replace a cancelled date, + and `mark_season_performance_past` refuses to mark one as taken place: + `409 performance_cancelled`. +- `update_event_series` sends only the prices that change: each date keeps its own price for + every category (or tier) left out, so weekend and weekday prices keep their difference. +- A season's `transferCutoffHours` / `returnCutoffHours` of `nil` means the club default + (Settings › Ticket site), or until the date starts when the club has none. +- `create_holder_transfer` and `create_holder_return` are not stopped by the club's customer + switches in Settings › Ticket site; cut-offs still apply. +- Cancelling a date (`create_season_amendment` with `kind: "cancel"`) voids its tickets as a + `date_cancel_void` job, named by `SeatLayer::Seasons.date_cancel_void_job_id(amendment_id)` + (`sbj_void_…`); follow it with `retrieve_season_bulk_job`. `season.ticket.voided` says + `date_cancelled` for each Season ticket it voids. +- A Season package holds 2 to 50 dates. `SeatLayer::Seasons.holder_import_rows_per_call(dates)` + gives the most rows one `create_season_holder_import` call takes (100 up to 20 dates, 43 at + 50); a call with more raises `ValidationError` `invalid_rows` with `"maxRows"` in its `body`. +- `update_season`'s `singleTicketDates` may carry `jobRefused`, why a `season_singles` job could + not start; the change stands, and sending `single_tickets: true` again opens any date still + not open. An `update_season` retried with its first `idempotency_key:` and + `expected_revision:` is not applied twice. +- An `event_series.create_event_series` given up for its key raises `ConflictError` + `create_abandoned` with a message to send it again with a new idempotency key. +- Added the 39 remaining Fixed Renewable Season operations to `client.seasons` (87 in all): + performances, package edits and prices, publishing, checkout tests, occurrence returns and + reclaims, holders, Season order refunds and cancellations, hosted renewals, renewal quotes, + intents and checkouts, the sales, + seats and seat-count reports, and bulk refunds and lapsed-offer releases with their jobs. +- Added three resources: `client.holders` (holders, customers, holder tickets and ticket + transfers), `client.orders` (account and event orders, refunds, cancellations and ticket + resends) and `client.event_series` (plan, read, extend and update an event series). +- Added `holders.create_holder_return`: give back a holder's seat on one date, one per ticket. +- Added the event sales settings to `client.events`: buyer checkout, payment gateway, sales + readiness, preflight and transition, booking fee, and discount codes. +- `export_season_holders_csv`, `export_season_orders_csv` and `orders.export_event_orders_csv` + return the raw CSV text. `delete_season_plan` and `events.delete_event_discount_code` return + an empty Hash (the API answers 204). +- 27 of the new operations use exact header replay (41 in the SDK now) and retry with one + `Idempotency-Key`; every other new mutation is single-attempt. +- `Webhooks::EVENT_NAMES` adds `series.created`, `series.dates_added`, `order.paid`, + `order.refunded`, `order.cancelled`, `customer.created`, `customer.email_changed`, + `season.ticket.transfer_sent`, `season.ticket.transferred` and + `season.ticket.transfer_cancelled`. +- Added `seasons.refund_season_performance` (refund every order of a cancelled date as a job, + with header replay), `seasons.list_season_bulk_jobs`, and `seasons.retrieve_season_bulk_job`. +- `create_season_amendment` takes `refund:` and `reason:`, for the new `postpone` and `cancel` + kinds. +- `update_season` takes `checkout_mode:`, `transfer_cutoff_hours:`, and `return_cutoff_hours:` + (`nil` sends an explicit null). +- `replace_season_package_prices` documents `"priceTypeKey"` on each price, and + `create_season_holder_import` documents `"holderEmail"` and `"holderName"` on each row. +- `Webhooks::EVENT_NAMES` lists all 56 public event names, adding `object.resale.*`, + `season.occurrence.*`, `season.performance.*` (including `postponed`, `cancelled`, and + `rescheduled`), `season.bulk_action.completed`, `season.order.*`, `season.ticket.issued`, + `season.ticket.voided`, and the Season lifecycle, hold, booking, renewal, operation and + amendment events. +- `events.list` and `events.list_all` take `series_key:` to list one event series' dates. +- Added `seasons.retrieve_season_renewal_quote`: what renewing an offer costs, and whether SeatLayer checkout can take it now. +- Added `seasons.create_season_renewal_intent`: record a holder's intent for a renewal taken by phone or at the box office, so your own checkout can commit. +- Added `seasons.create_season_renewal_checkout`: a SeatLayer checkout that renews an offer with no hosted page, with header replay. +- Added `holders.create_holder_renewal_checkout`: the same checkout for one of a holder's offers, with header replay. +- Added `holders.decline_holder_renewal_offer`: decline one of a holder's offers, with an optional `reason:`. +- `seasons.decline_season_renewal_offer` takes an optional `reason:`, kept on the offer as `declineReason`. +- `update_season` takes `audience:` (`"embed_only"` or `"public"`). +- `open_season_sales` 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 `refund_season_performance` takes `amount: "per_date"` (`full` answers `422 date_refund_per_date_only`; refund a whole order with the order refund). +- `add_event_series_dates` takes `retry_failed: true` to make the failed dates again (`409 series_busy`, `422 nothing_to_retry`). +- `list_orders` takes `holder_ref:`; orders carry `holderRef` and `customerId`, and the buyer's email can be nil (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`. +- `create_event_series` takes `from_event_key:`: an event you already set up becomes the first date under its own key, and every other date is a copy of it. `name:` and `chart_key:` are then left out. 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`. `update_event_series` renames each date that carries the series name, and a series name cannot hold `{n}`, `{date}` or `{time}`. +- Added `seasons.release_season_lapsed_offers`: release the seats of every lapsed renewal offer as a job (`202 {"job"}`), with header replay. An offer extended before its turn is skipped and keeps its seat. It can refuse with `503 job_busy` or `season_not_configured`; send it again. +- `retrieve_season_hosted_renewals` and `list_season_renewal_offers` take `cursor:` and `limit:` (1 to 500 and 1 to 100) and answer one page, newest first, with `nextCursor` (nil on the last page); a bad cursor or limit is refused with `400 invalid_cursor` or `invalid_limit`. +- `create_season_renewal_offers` answers `201 {"offers"}` for 100 contracts or fewer, and `202 {"job", "batchId"}` above that: a `renewal_run` job makes the offers. Follow it with `retrieve_season_bulk_job`, then page the offers with `list_season_renewal_offers`. +- `create_season_holder_import_from_season` answers `202 {"job"}` when a commit brings across more than 100 holders: a `holder_import` job, whose `summary` has the same counts once it completes. It and `create_season_renewal_offers` can refuse with `409 job_mismatch` or `503 season_not_configured`, and the bring-across also with `503 job_busy`. +- `create_season_renewal_notices` takes up to 20,000 `offer_ids:`; more is refused with `422 too_many_offer_ids`. +- `create_season_renewal_notices` takes `batch_id:` instead of `offer_ids:`: email every open offer one `create_season_renewal_offers` batch made, such as the batch a `renewal_run` job made. +- Single tickets on a season: `create_season` and `update_season` take `single_tickets:` (default `false`, Season only; anything else is refused with `422 invalid_single_tickets`). Every season read carries `singleTickets`, and a season on sale may carry `singleTicketsNotOpen`. `update_season`, `open_season_sales` and `resume_season_sales` answer `singleTicketDates` (`direction`, `opened`, `closed`, `notOpened`, `jobId`) when they open or stop the dates' own sales; a `season_singles` job does it for more than 12 upcoming dates. + ## 0.8.0 (2026-09-10) - Added Event hosting regions: `region:` on event create, `default_region:` on workspace create and update, and `EVENT_HOSTING_REGIONS` to check a value before it is sent. Events created without a region run in Western Europe. diff --git a/README.md b/README.md index 32c7dc4..78df503 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,28 @@ Nullable event-create fields distinguish omission from an explicit reset: passin ## Fixed Renewable Seasons -Version `0.7.0` exposes all 48 trusted organizer operations through -`client.seasons`. +`client.seasons` exposes all 87 trusted organizer operations: catalogue and +package lifecycle, performances, prices (each price hash may name a +`"priceTypeKey"`: `adult`, `child`, `concession`, `senior` or `student`), +checkout tests, sales windows, buyer access and booking, holder imports, +renewals with their quotes, intents and checkouts, occurrences, Season orders, reports and CSV exports, and the bulk +refund of a cancelled date (`refund_season_performance`) and the release of +lapsed renewal offers (`release_season_lapsed_offers`) with their jobs +(`list_season_bulk_jobs`, `retrieve_season_bulk_job`). + +Above 100 contracts, `create_season_renewal_offers` answers `202` with +`{"job", "batchId"}` instead of the offers: follow the `renewal_run` job with +`retrieve_season_bulk_job`, then page the offers with `list_season_renewal_offers` +(`cursor:` and `limit:`, until `nextCursor` is nil). A bring-across of more than 100 +holders (`create_season_holder_import_from_season`) answers `202` with a +`holder_import` job the same way. After the test hold/book/cancel journey and matching webhook deliveries, `validate_season_buyer_rehearsal(season_key)` sends no evidence body; SeatLayer discovers the retained chain automatically. Retrieved Season holds contain -inventory identity, not an authoritative amount. Your platform owns package -price, payment, order, tax, refunds, benefits, and ticket or pass delivery. +inventory identity, not an authoritative amount. When you sell a Season through +your own checkout, your platform owns package price, payment, order, tax, refunds, +benefits, and ticket or pass delivery. ```ruby checked = client.seasons.validate_season( @@ -111,6 +125,92 @@ 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. +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, `create_season_renewal_intent` +records the holder's intent before you commit the offer. A decline can carry the +holder's reason. + +```ruby +quote = client.seasons.retrieve_season_renewal_quote(season_key, offer_id) +if quote["problem"].nil? + started = client.seasons.create_season_renewal_checkout( + season_key, offer_id, + return_url: "https://tickets.yourclub.example/renewed", + idempotency_key: "renew-#{offer_id}" + ) + redirect_to started.dig("payment", "url") +end + +client.holders.decline_holder_renewal_offer("holder_123", offer_id, reason: "Moving away") +``` + +### Season seats + +Season seats are the seats a season sets aside on every date before its sales open. A season +buyer's hold then doesn't have to ask each date, and single-ticket buyers see those seats as +unavailable. They are off for every season until you pass `inventory_mode: "block"` to +`create_season` or `update_season`. The season needs one package, and its sales must not have +opened. + +```ruby +client.seasons.update_season(season_key, expected_revision: revision, inventory_mode: "block") + +job = client.seasons.set_aside_season_block( + season_key, + expected_revision: revision, + keep_for_single_tickets: { "sectionIds" => ["sec_family"] } +).fetch("job") +while %w[planning running].include?(job["state"]) + sleep 2 + job = client.seasons.retrieve_season_bulk_job(season_key, job["jobId"]).fetch("job") +end +block = client.seasons.retrieve_season_block(season_key).fetch("block") + +# Release unsold season seats to single sale. Leave out at: to release now. +client.seasons.release_season_block( + season_key, + expected_revision: block["revision"], + scope: { "all" => true }, + at: "2026-12-01T09:00:00Z" +) +``` + +- On an active season, block mode turns on by itself once every date has the seats, and + `season.block.ready` is sent once. On a draft, `publish_season` turns it on. + `season.block.released` is sent once a release has run. +- In block mode, a date cancel (`create_season_amendment`, kind `"cancel"`) or + `mark_season_performance_past` first takes the date off every season seat. Until that finishes + the answer is a `202`: check it with `SeatLayer::Seasons.date_leave_pending?(answer)`, then send + the same request again after `Retry-After`. +- Refusals come through `SeatLayer::APIError` with `code`, and nothing changes. For example + `409 season_block_packages` (more than one package), `409 season_block_on_sale` (sales have + opened), or `409 block_not_ready` from `open_season_sales` before block mode is on. +- A partner's seat on a date (one a sales channel sells there) stays out of the block. It is + listed in `block["notFreeOnEveryDate"]` with reason `channel`. + +The [Season seats docs](https://docs.seatlayer.io/server-api/seasons/#season-seats) list every refusal. + +## Orders, holders, event sales and event series + +`client.orders` reads, exports, refunds and cancels the orders SeatLayer took through its own +checkout; `client.holders` covers Season holders, customers, their tickets and ticket transfers; +the event sales settings (who takes payment, gateway, booking fee, sales state, discount codes) +are on `client.events`; and `client.event_series` plans many dates of one show from a schedule. +The CSV exports return the raw CSV text, not a Hash. + +```ruby +page = client.orders.list_event_orders(event_key, status: "confirmed", limit: 50) +csv = client.orders.export_event_orders_csv(event_key, status: "confirmed") # a String +client.orders.refund_event_order(event_key, order_id, amount_minor: 1500) + +client.events.transition_event_sales(event_key, to: "live", expected_revision: 4) +client.event_series.create_event_series( + name: "Hamlet", chart_key: chart_key, dry_run: true, + schedule: { "rule" => "weekly", "from" => "2027-01-04", "count" => 6, "times" => ["19:30"] } +) +``` + ## Test vs live Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only @@ -203,6 +303,8 @@ exactly when you don't want that, and you can control it explicitly: client.events.list(limit: 50, counts: false) ``` +Pass `series_key: "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 @@ -261,7 +363,7 @@ feature policy under `result["session"]`. Pass `safe_mode_options` only with `mo Subscription responses use the wire envelopes exactly: `list` returns `{"subs" => [...]}`, `create` returns `{"sub" => ..., "secret" => ...}` (the secret is shown once), and `update` -returns `{"sub" => ...}`. `SeatLayer::Webhooks::EVENT_NAMES` is the exact eight-name event set; +returns `{"sub" => ...}`. `SeatLayer::Webhooks::EVENT_NAMES` is the exact 56-name public event set; delivery history accepts `limit`, `status` (`"ok"` or `"failed"`), and `before`. Verify every delivery against the **raw** body. Re-encoding a parsed Hash changes the bytes and @@ -323,15 +425,28 @@ error carries `status`, `code`, `body` and `request_id`. Quote the request id in ## Reliability **Retries.** Reads (`GET`/`HEAD`) retry 429, 408 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.instantiate_template`, `events.create`, -`workspaces.create`, `performance_groups.create`, `seasons.create_season`, -`seasons.update_season`, `seasons.delete_season`, `seasons.create_season_plan`, -`seasons.duplicate_season_to_live`, `seasons.create_season_holder_import`, -`seasons.create_season_renewal_offers`, and `seasons.create_season_amendment`. Other 4xx responses -are never retried. - -**Idempotency.** Those 14 replay-backed operations carry an `Idempotency-Key`, generated when you +`Retry-After` wins when the server sends it. Forty-one mutations use exact header replay: + +- `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, + `workspaces.create`, and `performance_groups.create`; +- 26 on `seasons`: `create_season`, `update_season`, `delete_season`, `create_season_plan`, + `update_season_plan`, `delete_season_plan`, `publish_season`, `duplicate_season_to_live`, + `create_season_holder_import`, `create_season_holder_import_from_season`, + `create_season_renewal_offers`, `create_season_amendment`, `create_season_performances`, + `attach_season_performances`, `delete_season_performance`, `create_season_checkout_test`, + `replace_season_package_prices`, `attach_season_plan_performance`, + `delete_season_plan_performance`, `mark_season_performance_past`, `refund_season_order`, + `cancel_season_order`, `cancel_season_order_date`, `refund_season_performance`, + `release_season_lapsed_offers`, and `create_season_renewal_checkout`; +- `holders.update_holder`, `holders.create_holder_transfer`, `holders.create_holder_return`, and + `holders.create_holder_renewal_checkout`; +- `orders.refund_event_order` and `orders.cancel_event_order`; +- `event_series.create_event_series`, `event_series.add_event_series_dates`, and + `event_series.update_event_series`. + +Other 4xx responses are never retried. + +**Idempotency.** Those 41 replay-backed operations carry an `Idempotency-Key`, generated when you do not supply one and reused across attempts. All remaining SDK mutations are single-attempt. Some have a server-side domain idempotency contract, but the SDK does not retry them automatically. This includes inventory holds and bookings, show-once credential or secret creation, unsupported @@ -369,14 +484,17 @@ outbox, and support operations. | --- | --- | | `charts` | `list` `list_all` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | | `templates` | `instantiate_template` | -| `events` | `list` `list_all` `create` `retrieve` `retrieve_configuration_binding` `update_configuration_binding` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_report` `retrieve_log` | +| `events` | `list` `list_all` `create` `retrieve` `retrieve_configuration_binding` `update_configuration_binding` `update` `delete` `update_poster` `delete_poster` `update_chart` `close` `reopen` `archive` `retrieve_hold_ttl` `update_hold_ttl` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_report` `retrieve_log` `retrieve_event_buyer_checkout` `replace_event_buyer_checkout` `retrieve_event_payment_gateway` `replace_event_payment_gateway` `retrieve_event_sales_readiness` `preflight_event_sales` `transition_event_sales` `retrieve_event_booking_fee` `replace_event_booking_fee` `list_event_discount_codes` `create_event_discount_code` `update_event_discount_code` `end_event_discount_code` `delete_event_discount_code` | | `inventory` | `hold` `hold_best_available` `book_best_available` `extend_hold` `retrieve_hold` `release` `book` `box_office_book` `unbook` `list_bookings` `retrieve_booking` `block` `unblock` `unblock_all` `retrieve_availability` `update_availability` | | `channels` | `list_channels` `create_channel` `update_channel` `update_assignments` `list_allocation` `retrieve_access_preview` `retrieve_report` `pause` `unpause` `archive` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` `create_access_link` `list_access_links` `rotate_access_link` `revoke_access_link` | | `sessions` | `create_manage_session` `revoke_manage_session` `create_designer_session` `revoke_designer_session` | | `webhooks` | `list` `create` `update` `delete` `list_deliveries` | | `workspaces` | `list` `create` `retrieve` `update` | | `performance_groups` | `list` `create` `retrieve` `delete` `activate` `close` `retrieve_lifecycle` `create_buyer_access_session` `list_buyer_access_sessions` `revoke_buyer_access_session` `retrieve_hold` `book_hold` `retrieve_booking` | -| `seasons` | 48 operations for catalogue and Plan lifecycle, sales windows, buyer access and booking, holder imports, renewals, occurrence amendments, reports, audit, outbox, and support export | +| `seasons` | 87 operations for catalogue and Plan lifecycle, performances, package prices, checkout tests, sales windows, buyer access and booking, holder imports, holders, renewals, renewal quotes and checkouts, occurrence amendments, returns and reclaims, Season orders, bulk refunds, lapsed-offer releases and jobs, reports and CSV exports, audit, outbox, and support export | +| `holders` | `list_customers` `retrieve_holder` `update_holder` `revoke_holder_sessions` `list_holder_seasons` `list_holder_tickets` `retrieve_holder_ticket` `create_holder_transfer` `list_holder_transfers` `cancel_holder_transfer` `accept_holder_transfer` `create_holder_return` `create_holder_renewal_checkout` `decline_holder_renewal_offer` | +| `orders` | `list_orders` `retrieve_order` `list_order_tickets` `list_event_orders` `retrieve_event_order` `export_event_orders_csv` `refund_event_order` `cancel_event_order` `resend_event_order_tickets` | +| `event_series` | `create_event_series` `list_event_series` `retrieve_event_series` `add_event_series_dates` `update_event_series` | Full reference: [SeatLayer Ruby server SDK guide](https://docs.seatlayer.io/server-sdk/ruby/) @@ -384,17 +502,14 @@ Full reference: [SeatLayer Ruby server SDK guide](https://docs.seatlayer.io/serv Some API surface is intentionally unwrapped, not merely pending: -- **Hosted-checkout orders and refunds.** Reading or refunding a SeatLayer-hosted-checkout sale is - not a server-SDK capability. Those records only exist for organisations using hosted checkout; if - you run your own commerce store you refund in that store, through your own gateway. -- **Connecting or assigning payment gateways.** Connecting one is a dashboard flow, so shipping only - the assignment half across seven SDKs would hand you a method that cannot yet succeed. +- **Connecting a payment gateway.** Connecting one is a dashboard flow. Once it is connected, + `events.replace_event_payment_gateway` assigns it to an event. - **Realtime seat updates.** Live seat state reaches the *browser* through the widget's own socket. There is no server-side subscribe; a secret-key caller gets authoritative state from `events.retrieve_report` and `inventory.retrieve_availability`. -None of these are reachable through `request` as a supported path either; they are excluded from -the public manifest, not just from the wrapper. +Neither is reachable through `request` as a supported path either; both are excluded from the +public manifest, not just from the wrapper. ## Frequently asked questions diff --git a/lib/seatlayer.rb b/lib/seatlayer.rb index 5c67334..a7e8ef0 100644 --- a/lib/seatlayer.rb +++ b/lib/seatlayer.rb @@ -8,6 +8,9 @@ require_relative "seatlayer/channels" require_relative "seatlayer/performance_groups" require_relative "seatlayer/seasons" +require_relative "seatlayer/holders" +require_relative "seatlayer/orders" +require_relative "seatlayer/event_series" require_relative "seatlayer/webhook" # Official Ruby server SDK for the SeatLayer reserved-seating API. @@ -22,7 +25,7 @@ module SeatLayer # The SeatLayer client. class Client attr_reader :charts, :events, :inventory, :channels, :performance_groups, :sessions, - :seasons, :webhooks, :workspaces, :templates + :seasons, :webhooks, :workspaces, :templates, :holders, :orders, :event_series def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL, max_retries: HTTPClient::DEFAULT_MAX_RETRIES, @@ -36,6 +39,9 @@ def initialize(secret_key, base_url: HTTPClient::DEFAULT_BASE_URL, @channels = Channels.new(@http) @performance_groups = PerformanceGroups.new(@http) @seasons = Seasons.new(@http) + @holders = Holders.new(@http) + @orders = Orders.new(@http) + @event_series = EventSeries.new(@http) @sessions = Sessions.new(@http) @templates = Templates.new(@http) @webhooks = Webhooks.new(@http) diff --git a/lib/seatlayer/event_series.rb b/lib/seatlayer/event_series.rb new file mode 100644 index 0000000..dacd63e --- /dev/null +++ b/lib/seatlayer/event_series.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module SeatLayer + # Event series: many dates of one show, planned from a schedule and run as + # one. A series is addressed by the key of its first event. + class EventSeries < Resource + # Plan an event series from a +schedule+ Hash, or create it as a job. + # Pass dry_run: true to get the dates back without creating any. + # Every date carries +name+ as its name. + # + # Give +name+ and +chart_key+, or +from_event_key+: 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. + # + # A series from an event that never started and was given up answers 409 + # create_abandoned for that key every time: send it again with a new + # +idempotency_key+. + def create_event_series( # rubocop:disable Metrics/ParameterLists + schedule:, name: nil, chart_key: nil, timezone: nil, venue: nil, + duration_minutes: nil, dry_run: nil, idempotency_key: nil, from_event_key: nil + ) + if from_event_key.nil? && (name.nil? || chart_key.nil?) + raise ArgumentError, "give name: and chart_key:, or from_event_key:" + end + + body = compact({ "fromEventKey" => from_event_key, "name" => name, "chartKey" => chart_key, + "schedule" => schedule, "timezone" => timezone, "venue" => venue, + "durationMinutes" => duration_minutes, "dryRun" => dry_run }) + @client.post("/v1/event-series", body, idempotency_key: idempotency_key, + retry_policy: :header_replay) + end + + # One page of event series. + def list_event_series(limit: nil, cursor: nil) + @client.get("/v1/event-series", compact({ "limit" => limit, "cursor" => cursor })) + end + + # Read one event series. + def retrieve_event_series(event_key) + @client.get(path(event_key)) + end + + # Add dates to a series, copied from its first date. Pass one of a +schedule+ + # Hash, a +dates+ list of "YYYY-MM-DDTHH:MM" strings, or +retry_failed: 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). + def add_event_series_dates(event_key, schedule: nil, dates: nil, retry_failed: false, + idempotency_key: nil) + @client.post(path(event_key, "/dates"), + compact({ "schedule" => schedule, "dates" => dates, + "retryFailed" => (retry_failed ? true : nil) }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Rename a series, change its prices, or turn sales "on" or "off" on every + # upcoming date. +prices+ ({ "categories" => [...] }) takes only the + # categories whose price changes (for a tiered category, only the tiers + # sent); every price left out keeps each date's own value. Sales "on" never + # opens a date its season cancelled: that date is a failed item of the job + # (season_date_cancelled), counted in the job's "failed" but not in + # "failedDates", which are the dates that could not be made. + def update_event_series(event_key, name: nil, prices: nil, sales: nil, idempotency_key: nil) + @client.mutation_with_header_replay( + "PATCH", path(event_key), + compact({ "name" => name, "prices" => prices, "sales" => sales }), + idempotency_key: idempotency_key + ) + end + + private + + def path(event_key, suffix = "") + "/v1/event-series/#{encode(event_key)}#{suffix}" + end + end +end diff --git a/lib/seatlayer/holders.rb b/lib/seatlayer/holders.rb new file mode 100644 index 0000000..59f49a8 --- /dev/null +++ b/lib/seatlayer/holders.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module SeatLayer + # Season holders and customers: contact details, sessions, tickets, ticket + # transfers between holders, returns, and renewals. + class Holders < Resource + # One page of customers, or the one matching +email+ or +holder_ref+. + def list_customers(email: nil, holder_ref: nil, cursor: nil, limit: nil) + @client.get("/v1/customers", compact({ "email" => email, "holderRef" => holder_ref, + "cursor" => cursor, "limit" => limit })) + end + + # Read one holder. + def retrieve_holder(holder_ref) + @client.get(path(holder_ref)) + end + + # Set a holder's email, name or phone. Pass +nil+ for +name+ or +phone+ to + # send an explicit JSON null. + def update_holder(holder_ref, email: nil, name: UNSET, phone: UNSET, idempotency_key: nil) + body = compact({ "email" => email }) + body.merge!(supplied({ "name" => name, "phone" => phone })) + @client.mutation_with_header_replay( + "PATCH", path(holder_ref), body, idempotency_key: idempotency_key + ) + end + + # Sign a holder out of every session, or only out of +session_id+. + # Single-attempt. + def revoke_holder_sessions(holder_ref, session_id: nil) + @client.post(path(holder_ref, "/sessions/revoke"), compact({ "sessionId" => session_id })) + end + + # List the Seasons a holder belongs to. + def list_holder_seasons(holder_ref) + @client.get(path(holder_ref, "/seasons")) + end + + # One page of a holder's tickets. +from+ and +to+ are epoch milliseconds. + def list_holder_tickets(holder_ref, season_key: nil, from: nil, to: nil, cursor: nil, + limit: nil) + @client.get(path(holder_ref, "/tickets"), + compact({ "seasonKey" => season_key, "from" => from, "to" => to, + "cursor" => cursor, "limit" => limit })) + end + + # Read one of a holder's tickets. + def retrieve_holder_ticket(holder_ref, ticket_id) + @client.get(path(holder_ref, "/tickets/#{encode(ticket_id)}")) + end + + # Offer tickets to another person by email. Turning customer sends off in + # Settings › Ticket site doesn't stop your server; cut-offs still apply. + def create_holder_transfer(holder_ref, ticket_ids:, to_email:, idempotency_key: nil) + @client.post(path(holder_ref, "/transfers"), + { "ticketIds" => ticket_ids, "toEmail" => to_email }, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # List a holder's ticket transfers. + def list_holder_transfers(holder_ref) + @client.get(path(holder_ref, "/transfers")) + end + + # Cancel a transfer that has not been accepted yet. Single-attempt. + def cancel_holder_transfer(holder_ref, transfer_id) + @client.post(path(holder_ref, "/transfers/#{encode(transfer_id)}/cancel")) + end + + # Accept a transfer for its recipient. Single-attempt. + def accept_holder_transfer(holder_ref, transfer_id) + @client.post(path(holder_ref, "/transfers/#{encode(transfer_id)}/accept")) + end + + # Give back a holder's seat on one date, one per ticket: 1 to 20 tickets, + # all of one Season. A repeat with the same key answers with the same return. + # Turning give-backs off in Settings › Ticket site doesn't stop your server; + # cut-offs still apply. + def create_holder_return(holder_ref, ticket_ids:, idempotency_key: nil) + @client.post(path(holder_ref, "/returns"), { "ticketIds" => ticket_ids }, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Start a SeatLayer checkout that renews one of a holder's offers (+renewalOffer+ + # on +list_holder_seasons+). Send the buyer to +payment.url+. Retried with one + # Idempotency-Key, which answers the same order. + def create_holder_renewal_checkout(holder_ref, offer_id, return_url:, buyer_email: nil, + buyer_name: nil, provider: nil, idempotency_key: nil) + @client.post(path(holder_ref, "/renewal-offers/#{encode(offer_id)}/checkout"), + compact({ "returnUrl" => return_url, "buyerEmail" => buyer_email, + "buyerName" => buyer_name, "provider" => provider }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # 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. + def decline_holder_renewal_offer(holder_ref, offer_id, reason: nil) + @client.post(path(holder_ref, "/renewal-offers/#{encode(offer_id)}/decline"), + compact({ "reason" => reason })) + end + + private + + def path(holder_ref, suffix = "") + "/v1/holders/#{encode(holder_ref)}#{suffix}" + end + end +end diff --git a/lib/seatlayer/http_client.rb b/lib/seatlayer/http_client.rb index c927b8c..c8a919e 100644 --- a/lib/seatlayer/http_client.rb +++ b/lib/seatlayer/http_client.rb @@ -64,17 +64,8 @@ def self.encode(segment) def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil, idempotency_key: nil, retry_policy: :none) - url = @base_url + path - if query - pairs = query.compact - url += "?#{URI.encode_www_form(pairs)}" unless pairs.empty? - end - - headers = { - "Authorization" => "Bearer #{@secret_key}", - "Accept" => "application/json", - "User-Agent" => "seatlayer-ruby" - } + url = build_url(path, query) + headers = default_headers payload = build_payload(body, raw_body, content_type, headers) # Only operations with exact server-side response replay get an automatic @@ -97,6 +88,13 @@ def get(path, query = nil) request("GET", path, query: query) end + # Internal path for CSV exports. Same auth, retries and error mapping as + # #get, but a success body comes back as the raw String, never parsed. + def get_text(path, query = nil) + headers = default_headers.merge("Accept" => "text/csv") + execute("GET", build_url(path, query), headers, nil, retry_allowed: true, text: true) + end + def post(path, body = nil, idempotency_key: nil, retry_policy: :none) request("POST", path, body: body, idempotency_key: idempotency_key, retry_policy: retry_policy) end @@ -125,6 +123,22 @@ def delete(path, query = nil) private + def build_url(path, query) + url = @base_url + path + return url unless query + + pairs = query.compact + pairs.empty? ? url : "#{url}?#{URI.encode_www_form(pairs)}" + end + + def default_headers + { + "Authorization" => "Bearer #{@secret_key}", + "Accept" => "application/json", + "User-Agent" => "seatlayer-ruby" + } + end + def build_payload(body, raw_body, content_type, headers) raise ArgumentError, "body and raw_body are mutually exclusive" if !body.nil? && !raw_body.nil? @@ -140,7 +154,7 @@ def build_payload(body, raw_body, content_type, headers) # The retry loop, extracted from #request so each piece stays readable: the # public method builds the call, this one decides how many times to make it. - def execute(method, url, headers, payload, retry_allowed:) + def execute(method, url, headers, payload, retry_allowed:, text: false) last_error = nil @max_retries.times do |attempt| @@ -155,7 +169,7 @@ def execute(method, url, headers, payload, retry_allowed:) end status = response[:status] - return parse_success(status, response[:body]) if status >= 200 && status < 300 + return parse_success(status, response[:body], text) if status >= 200 && status < 300 error_body = decode_error_body(response[:body]) retry_after = parse_retry_after(response[:headers], error_body) @@ -183,7 +197,8 @@ def decode_error_body(body) {} end - def parse_success(status, body) + def parse_success(status, body, text) + return body.to_s if text return {} if status == 204 || body.nil? || body.empty? JSON.parse(body) diff --git a/lib/seatlayer/inventory.rb b/lib/seatlayer/inventory.rb index 7b2e7a2..c9cf6e4 100644 --- a/lib/seatlayer/inventory.rb +++ b/lib/seatlayer/inventory.rb @@ -205,9 +205,26 @@ def revoke_designer_session(session_id) # Manage webhook subscriptions. To VERIFY a delivery, see SeatLayer::Webhook. class Webhooks < Resource + # All 58 public event names, in the order the API lists them. EVENT_NAMES = [ - "seat.booked", "seat.released", "seat.blocked", "hold.expired", - "hold.created", "hold.extended", "event.created", "event.soldout" + "seat.booked", "seat.released", "seat.blocked", "hold.expired", "hold.created", + "hold.extended", "event.created", "event.soldout", "object.resale.listed", + "object.resale.unlisted", "object.resale.sold", "customer.created", "customer.email_changed", + "order.paid", "order.refunded", "order.cancelled", "season.ticket.transfer_sent", + "season.ticket.transferred", "season.ticket.transfer_cancelled", "season.ticket.issued", + "season.ticket.voided", "season.structure.activated", "season.structure.closed", + "season.structure.archived", "season.plan.published", "season.plan.superseded", + "season.sales.opened", "season.sales.paused", "season.sales.ended", "season.hold.committed", + "season.hold.released", "season.hold.expired", "season.booking.confirmed", + "season.booking.cancelled", "season.booking.emergency_requested", + "season.occurrence.returned", "season.occurrence.reclaimed", "season.renewal.offered", + "season.renewal.intent_received", "season.renewal.renewed", "season.renewal.declined", + "season.renewal.lapsed", "season.renewal.released", "season.operation.partial_terminal", + "season.amendment.applied", "season.performance.added", "season.performance.removed", + "season.performance.past", "season.performance.postponed", "season.performance.cancelled", + "season.performance.rescheduled", "season.bulk_action.completed", "season.block.ready", + "season.block.released", "season.order.paid", "season.order.refunded", "series.created", + "series.dates_added" ].freeze def list diff --git a/lib/seatlayer/orders.rb b/lib/seatlayer/orders.rb new file mode 100644 index 0000000..b3b86bb --- /dev/null +++ b/lib/seatlayer/orders.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module SeatLayer + # Orders SeatLayer took through its own checkout: reads, CSV export, + # refunds, cancellations and ticket resends. + class Orders < Resource + # One page of orders. +from+ and +to+ are epoch milliseconds. +holder_ref+ 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 + # nil for a box office sale taken without one. + def list_orders(event_key: nil, season_key: nil, status: nil, from: nil, to: nil, + cursor: nil, limit: nil, holder_ref: nil) + @client.get("/v1/orders", compact({ "eventKey" => event_key, "seasonKey" => season_key, + "holderRef" => holder_ref, + "status" => status, "from" => from, "to" => to, + "cursor" => cursor, "limit" => limit })) + end + + # Read one order. + def retrieve_order(order_id) + @client.get("/v1/orders/#{encode(order_id)}") + end + + # List one order's tickets. + def list_order_tickets(order_id) + @client.get("/v1/orders/#{encode(order_id)}/tickets") + end + + # One page of an event's orders. +search+ is sent as the API's +q+ + # parameter; +from+ and +to+ are epoch milliseconds. + def list_event_orders(event_key, search: nil, status: nil, paid_by: nil, from: nil, to: nil, + cursor: nil, limit: nil) + @client.get("/v1/events/#{encode(event_key)}/orders", + compact({ "q" => search, "status" => status, "paidBy" => paid_by, + "from" => from, "to" => to, "cursor" => cursor, "limit" => limit })) + end + + # Read one of an event's orders. + def retrieve_event_order(event_key, order_id) + @client.get("/v1/events/#{encode(event_key)}/orders/#{encode(order_id)}") + end + + # Download an event's orders as CSV, with the same filters as + # +list_event_orders+. Returns the raw CSV text. + def export_event_orders_csv(event_key, search: nil, status: nil, paid_by: nil, from: nil, + to: nil) + @client.get_text("/v1/events/#{encode(event_key)}/orders.csv", + compact({ "q" => search, "status" => status, "paidBy" => paid_by, + "from" => from, "to" => to })) + end + + # Refund an event order, in full or by +amount_minor+. + def refund_event_order(event_key, order_id, amount_minor: nil, idempotency_key: nil) + @client.post("/v1/events/#{encode(event_key)}/orders/#{encode(order_id)}/refund", + compact({ "amountMinor" => amount_minor }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Cancel an event order. + def cancel_event_order(event_key, order_id, reason:, note: nil, refund_confirmed: nil, + used_tickets_acknowledged: nil, notify_buyer: nil, idempotency_key: nil) + body = compact({ "reason" => reason, "note" => note, "refundConfirmed" => refund_confirmed, + "usedTicketsAcknowledged" => used_tickets_acknowledged, + "notifyBuyer" => notify_buyer }) + @client.post("/v1/events/#{encode(event_key)}/orders/#{encode(order_id)}/cancel", body, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Send an event order's tickets again. Single-attempt: never retried automatically. + def resend_event_order_tickets(event_key, order_id, email: nil) + @client.post("/v1/events/#{encode(event_key)}/orders/#{encode(order_id)}/resend", + compact({ "email" => email })) + end + end +end diff --git a/lib/seatlayer/resources.rb b/lib/seatlayer/resources.rb index fd04db5..7f4291d 100644 --- a/lib/seatlayer/resources.rb +++ b/lib/seatlayer/resources.rb @@ -157,11 +157,14 @@ class Events < Resource # # Live availability counts cost one round-trip per event server-side. They # are on by default because most callers of a single page want them; pass - # counts: false when paging a whole catalogue. - def list(workspace_id: nil, external_ref: nil, limit: nil, cursor: nil, counts: true) + # counts: false when paging a whole catalogue. +series_key+ lists only + # the dates of one event series. + def list(workspace_id: nil, external_ref: nil, limit: nil, cursor: nil, counts: true, + series_key: nil) query = compact({ "workspaceId" => workspace_id, "externalRef" => external_ref, "limit" => limit, "cursor" => cursor }) query["counts"] = "0" unless counts + query["seriesKey"] = series_key unless series_key.nil? @client.get("/v1/events", query) end @@ -249,6 +252,8 @@ def close(event_key) @client.post("/v1/events/#{encode(event_key)}/close") end + # Open a paused event's sales again. A date its season cancelled never opens + # again: 409 season_date_cancelled. def reopen(event_key) @client.post("/v1/events/#{encode(event_key)}/reopen") end @@ -289,5 +294,101 @@ def retrieve_log(event_key, limit: nil, before: nil) @client.get("/v1/events/#{encode(event_key)}/log", compact({ "limit" => limit, "before" => before })) end + + # Read who takes payment for the event, and what blocks a change. + def retrieve_event_buyer_checkout(event_key) + @client.get("/v1/events/#{encode(event_key)}/buyer-checkout") + end + + # Choose who takes payment: "seatlayer" or "integrator". Single-attempt; + # +expected_revision+ stops two writers overwriting each other. + def replace_event_buyer_checkout(event_key, buyer_checkout:, expected_revision:) + @client.put("/v1/events/#{encode(event_key)}/buyer-checkout", + { "buyerCheckout" => buyer_checkout, "expectedRevision" => expected_revision }) + end + + # Read the payment gateway the event uses, optionally for one +currency+. + def retrieve_event_payment_gateway(event_key, currency: nil) + @client.get("/v1/events/#{encode(event_key)}/payment-gateway", + compact({ "currency" => currency })) + end + + # Assign a payment gateway connection to the event. Pass +nil+ to send an + # explicit JSON null. Single-attempt: never retried automatically. + def replace_event_payment_gateway(event_key, connection_id: UNSET, provider: UNSET) + @client.put("/v1/events/#{encode(event_key)}/payment-gateway", + supplied({ "connectionId" => connection_id, "provider" => provider })) + end + + # Read what the event still needs before it can sell. + def retrieve_event_sales_readiness(event_key) + @client.get("/v1/events/#{encode(event_key)}/sales/readiness") + end + + # Check one order against the event's sales setup. Single-attempt. + def preflight_event_sales(event_key, order_id:) + @client.post("/v1/events/#{encode(event_key)}/sales/preflight", { "orderId" => order_id }) + end + + # Move the event's sales to another state (setup, testable, live, paused or ended). + # A date its season cancelled never opens again: moving it to testable or live + # answers 409 season_date_cancelled. + def transition_event_sales(event_key, to:, expected_revision:, reason: nil) + @client.post("/v1/events/#{encode(event_key)}/sales/transition", + compact({ "to" => to, "expectedRevision" => expected_revision, + "reason" => reason })) + end + + # Read the event's booking fee. + def retrieve_event_booking_fee(event_key) + @client.get("/v1/events/#{encode(event_key)}/booking-fee") + end + + # Set the booking fee in minor units; +nil+ clears it and stays an explicit + # JSON null. Single-attempt: never retried automatically. + def replace_event_booking_fee(event_key, booking_fee_minor:) + @client.put("/v1/events/#{encode(event_key)}/booking-fee", + { "bookingFeeMinor" => booking_fee_minor }) + end + + # List the event's discount codes. + def list_event_discount_codes(event_key) + @client.get("/v1/events/#{encode(event_key)}/discount-codes") + end + + # Create a discount code. +kind+ is "percent" or "amount". Pass +nil+ for + # +starts_at+, +ends_at+ or +max_uses+ to send an explicit JSON null. + # Single-attempt: never retried automatically. + # rubocop:disable-next Metrics/ParameterLists + def create_event_discount_code(event_key, code:, kind:, percent: nil, amount_minor: nil, + starts_at: UNSET, ends_at: UNSET, max_uses: UNSET, active: nil) + body = compact({ "code" => code, "kind" => kind, "percent" => percent, + "amountMinor" => amount_minor, "active" => active }) + body.merge!(supplied({ "startsAt" => starts_at, "endsAt" => ends_at, "maxUses" => max_uses })) + @client.post("/v1/events/#{encode(event_key)}/discount-codes", body) + end + + # Change a discount code. Only the fields you pass are sent; pass +nil+ for + # +starts_at+, +ends_at+ or +max_uses+ to send an explicit JSON null. + # Single-attempt: never retried automatically. + # rubocop:disable-next Metrics/ParameterLists + def update_event_discount_code(event_key, code_id, code: nil, kind: nil, percent: nil, + amount_minor: nil, starts_at: UNSET, ends_at: UNSET, + max_uses: UNSET, active: nil, state: nil) + body = compact({ "code" => code, "kind" => kind, "percent" => percent, + "amountMinor" => amount_minor, "active" => active, "state" => state }) + body.merge!(supplied({ "startsAt" => starts_at, "endsAt" => ends_at, "maxUses" => max_uses })) + @client.patch("/v1/events/#{encode(event_key)}/discount-codes/#{encode(code_id)}", body) + end + + # End a discount code now, keeping its history. + def end_event_discount_code(event_key, code_id) + @client.post("/v1/events/#{encode(event_key)}/discount-codes/#{encode(code_id)}/end") + end + + # Delete a discount code. The API answers 204, so this returns an empty Hash. + def delete_event_discount_code(event_key, code_id) + @client.delete("/v1/events/#{encode(event_key)}/discount-codes/#{encode(code_id)}") + end end end diff --git a/lib/seatlayer/seasons.rb b/lib/seatlayer/seasons.rb index 7d35074..f326846 100644 --- a/lib/seatlayer/seasons.rb +++ b/lib/seatlayer/seasons.rb @@ -5,7 +5,36 @@ module SeatLayer # # Browser selection belongs in the distinct SeasonPicker. Give it only the # show-once scoped token returned by +create_season_buyer_access_session+. + # + # One thin method per public Season operation (90 of them), so the class is + # long by design rather than doing too much. + # rubocop:disable-next Metrics/ClassLength class Seasons < Resource + # 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 +retrieve_season_bulk_job+. Each Season ticket it voids is + # told by "season.ticket.voided" with reason "date_cancelled". + def self.date_cancel_void_job_id(amendment_id) + "sbj_void_#{amendment_id}" + end + + # The most rows one +create_season_holder_import+ 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". + def self.holder_import_rows_per_call(dates) + (2300 / ([dates, 1].max + 3)).floor.clamp(1, 100) + end + + # True for the 202 a season-seats season's date cancel (+create_season_amendment+) + # or past date (+mark_season_performance_past+) answers while the date is still + # leaving every season seat: "state" "finishing", "code" "block_date_leave_pending", + # with "eventKey", "shardsLeft" and "shardCount". It finishes by itself: send the + # same request again after Retry-After to read how it ended. + def self.date_leave_pending?(answer) + answer.is_a?(Hash) && answer["state"] == "finishing" && answer["code"] == "block_date_leave_pending" + end + def list_seasons(workspace_id: nil, structure_state: nil, limit: nil, cursor: nil) @client.get("/v1/seasons", compact({ "workspaceId" => workspace_id, "structureState" => structure_state, @@ -18,10 +47,18 @@ def validate_season(event_keys: nil, source_performance_group_keys: nil) selection(event_keys, source_performance_group_keys)) end + # Create a draft Season. +single_tickets+ true makes each date also sell its own + # single tickets once the Season is on sale; the default is Season only. + # +inventory_mode+ "block" turns season seats on for this Season; the default is + # "per_hold", and anything else is refused with 422 invalid_inventory_mode. Every + # Season answer carries "inventoryMode": how the season's seats are decided, "block" + # once season seats are turned on for it (inventoryMode "block" on create or update) + # or it sells from its block, else "per_hold". def create_season(name:, event_keys: nil, source_performance_group_keys: nil, - edition: UNSET, idempotency_key: nil) + edition: UNSET, idempotency_key: nil, single_tickets: nil, inventory_mode: nil) body = selection(event_keys, source_performance_group_keys).merge("name" => name) body.merge!(supplied({ "edition" => edition })) + body.merge!(compact({ "singleTickets" => single_tickets, "inventoryMode" => inventory_mode })) @client.post("/v1/seasons", body, idempotency_key: idempotency_key, retry_policy: :header_replay) end @@ -30,10 +67,33 @@ def retrieve_season(season_key) @client.get(path(season_key)) end - def update_season(season_key, expected_revision:, name: nil, edition: UNSET, - idempotency_key: nil) - body = compact({ "expectedRevision" => expected_revision, "name" => name }) - body.merge!(supplied({ "edition" => edition })) + # Rename a draft, or set how the Season sells. +checkout_mode+ ("seatlayer" or + # "server") cannot go back to undecided and is fixed once a live booking exists. + # The cutoffs are hours (0 to 720) before a date starts; pass +nil+ to send an + # explicit JSON null, which means the club default (Settings › Ticket site), or + # until the date starts when the club has none. +audience+ "public" lists + # the Season on your SeatLayer website ("embed_only" does not); an ended Season + # cannot become public (409 sales_ended). +single_tickets+ 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, + # and carries "jobRefused" when its season_singles job could not start: the change + # still stands, and sending +single_tickets+ true again opens any date still not open. + # A retry with the same +expected_revision+ and +idempotency_key+ is not applied twice. + # +inventory_mode+ "block" turns season seats on for this Season; "per_hold" turns them + # off again, refused with 409 season_block_exists once seats are set aside. It does not + # change the Season's "revision". Refused with 409 season_block_packages (more than one + # package), 409 season_block_on_sale (sales already opened without season seats), + # 409 season_not_draft_or_active, or 422 invalid_inventory_mode. + def update_season( # rubocop:disable Metrics/ParameterLists + season_key, expected_revision:, name: nil, edition: UNSET, checkout_mode: nil, + transfer_cutoff_hours: UNSET, return_cutoff_hours: UNSET, audience: nil, idempotency_key: nil, + single_tickets: nil, inventory_mode: nil + ) + body = compact({ "expectedRevision" => expected_revision, "name" => name, + "checkoutMode" => checkout_mode, "audience" => audience, + "singleTickets" => single_tickets, "inventoryMode" => inventory_mode }) + body.merge!(supplied({ "edition" => edition, "transferCutoffHours" => transfer_cutoff_hours, + "returnCutoffHours" => return_cutoff_hours })) @client.mutation_with_header_replay( "PATCH", path(season_key), body, idempotency_key: idempotency_key ) @@ -82,6 +142,11 @@ def supersede_season_plan(season_key, plan_key, expected_revision:) { "expectedRevision" => expected_revision }) end + # 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 checkout_mode_not_chosen until a package + # is published and a checkout mode is chosen, and with 409 block_not_ready when + # season seats are turned on (inventory_mode "block") but the seats are not set aside yet. def open_season_sales(season_key, expected_revision:) sales(season_key, "open", expected_revision) end @@ -147,6 +212,11 @@ def validate_season_buyer_rehearsal(season_key) @client.post(path(season_key, "/buyer-rehearsals/validate")) end + # Import incumbent holders. Each row hash takes "rowId", "holderRef", + # "priorPlanActivationId", "priorContractRef" and "labels", plus optional + # "existingBookingRef", "holderEmail" and "holderName". A call takes at most + # +Seasons.holder_import_rows_per_call(dates)+ rows (100 up to 20 dates, 43 at 50); + # more answers 422 invalid_rows with "maxRows" on the error's +body+. def create_season_holder_import(season_key, successor_plan_activation_id:, rows:, dry_run: UNSET, idempotency_key: nil) body = { "successorPlanActivationId" => successor_plan_activation_id, "rows" => rows } @@ -159,6 +229,10 @@ def retrieve_season_holder_import(season_key, import_id) @client.get(path(season_key, "/imports/#{encode(import_id)}")) end + # Answers 201 {"offers"} for 100 contracts or fewer. Above 100 it answers 202 + # {"job", "batchId"}: a renewal_run job makes the offers. Follow it with + # +retrieve_season_bulk_job+, then page the offers with +list_season_renewal_offers+. + # Retried with one Idempotency-Key, which answers the same job. def create_season_renewal_offers(season_key, deadline_at:, successor_plan_activation_id: nil, contract_ids: nil, idempotency_key: nil) @@ -168,8 +242,10 @@ def create_season_renewal_offers(season_key, deadline_at:, idempotency_key: idempotency_key, retry_policy: :header_replay) end - def list_season_renewal_offers(season_key) - @client.get(path(season_key, "/renewal-offers")) + # One page of renewal offers, newest first. Pass +cursor+ from the previous + # page's +nextCursor+ (nil on the last page); +limit+ is 1 to 100 (default 100). + def list_season_renewal_offers(season_key, cursor: nil, limit: nil) + @client.get(path(season_key, "/renewal-offers"), compact({ "cursor" => cursor, "limit" => limit })) end def retrieve_season_renewal_offer(season_key, offer_id) @@ -192,8 +268,12 @@ def commit_season_renewal_offer(season_key, offer_id, commit_action_id:, order_r "bookingRef" => booking_ref, "planActivationId" => plan_activation_id }) end - def decline_season_renewal_offer(season_key, offer_id) - @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/decline"), {}) + # 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. + def decline_season_renewal_offer(season_key, offer_id, reason: nil) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/decline"), + compact({ "reason" => reason })) end def release_season_renewal_offer(season_key, offer_id) @@ -204,11 +284,23 @@ def list_season_occurrences(season_key) @client.get(path(season_key, "/occurrences")) end + # Amend one date. +kind+ is "reschedule", "replace", "cancel_exception", + # "postpone" (no new time yet) or "cancel" (the date will not happen). With + # "cancel" only, +refund+ ("none" or "per_date") refunds each SeatLayer order's + # share of the date as a job, and +reason+ is kept on it. A whole-order refund + # is the order refund. A "cancel" also voids every live ticket for the date as a + # date_cancel_void job, +Seasons.date_cancel_void_job_id(amendment_id)+ (the answer + # does not name it); follow it with +retrieve_season_bulk_job+. A cancelled date + # can't be moved ("reschedule"), postponed or replaced: 409 performance_cancelled. + # On a season-seats season, a "cancel" first takes the date off every season seat: + # while that finishes the answer is a 202 (check with +Seasons.date_leave_pending?+); + # send the same request again to read the amendment. def create_season_amendment(season_key, event_key:, kind:, starts_at: nil, name: nil, - idempotency_key: nil) + refund: nil, reason: nil, idempotency_key: nil) @client.post(path(season_key, "/amendments"), compact({ "eventKey" => event_key, "kind" => kind, - "startsAt" => starts_at, "name" => name }), + "startsAt" => starts_at, "name" => name, + "refund" => refund, "reason" => reason }), idempotency_key: idempotency_key, retry_policy: :header_replay) end @@ -249,6 +341,335 @@ def export_season_support_snapshot(season_key) @client.get(path(season_key, "/export")) end + # Dry-run or commit renewal rights derived from a previous Season's holders. + # A commit of more than 100 holders answers 202 {"job"}: a holder_import job + # brings them across, and its +summary+ has the same counts once it completes. + # Retried with one Idempotency-Key, which answers the same job. + def create_season_holder_import_from_season(season_key, source_season_id:, + successor_plan_activation_id:, dry_run: UNSET, + idempotency_key: nil) + body = { "sourceSeasonId" => source_season_id, + "successorPlanActivationId" => successor_plan_activation_id } + body.merge!(supplied({ "dryRun" => dry_run })) + @client.post(path(season_key, "/imports/from-season"), body, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # List the Season's performances and package membership, in every lifecycle state. + def list_season_performances(season_key) + @client.get(path(season_key, "/performances")) + end + + # Create performances inside a draft Season from a repeat rule. Pass +nil+ + # for +venue+ or +duration_minutes+ to send an explicit JSON null. + # rubocop:disable-next Metrics/ParameterLists + def create_season_performances(season_key, title_pattern:, timezone:, repeat:, + first_starts_at: nil, count: nil, dates: nil, venue: UNSET, + duration_minutes: UNSET, region: nil, + attach_to_plan_keys: nil, idempotency_key: nil) + body = compact({ "titlePattern" => title_pattern, "firstStartsAt" => first_starts_at, + "timezone" => timezone, "repeat" => repeat, "count" => count, + "dates" => dates, "region" => region, + "attachToPlanKeys" => attach_to_plan_keys }) + body.merge!(supplied({ "venue" => venue, "durationMinutes" => duration_minutes })) + @client.post(path(season_key, "/performances"), body, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Attach existing events to a draft Season as performances. + def attach_season_performances(season_key, event_keys:, attach_to_plan_keys: nil, + idempotency_key: nil) + body = compact({ "eventKeys" => event_keys, "attachToPlanKeys" => attach_to_plan_keys }) + @client.post(path(season_key, "/performances/attach"), body, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Remove one performance from a draft Season. + def delete_season_performance(season_key, event_key, idempotency_key: nil) + @client.mutation_with_header_replay( + "DELETE", path(season_key, "/performances/#{encode(event_key)}"), + idempotency_key: idempotency_key + ) + end + + # Rename a draft package or change the performances it covers. + def update_season_plan(season_key, plan_key, name: nil, event_keys: nil, idempotency_key: nil) + @client.mutation_with_header_replay( + "PATCH", path(season_key, "/plans/#{encode(plan_key)}"), + compact({ "name" => name, "eventKeys" => event_keys }), idempotency_key: idempotency_key + ) + end + + # Delete a draft package. The API answers 204, so this returns an empty Hash. + def delete_season_plan(season_key, plan_key, idempotency_key: nil) + @client.mutation_with_header_replay( + "DELETE", path(season_key, "/plans/#{encode(plan_key)}"), idempotency_key: idempotency_key + ) + end + + # Publish a draft Season. Sends no body. + def publish_season(season_key, idempotency_key: nil) + @client.post(path(season_key, "/publish"), nil, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Run a test checkout against the Season. + def create_season_checkout_test(season_key, plan_key: nil, quantity: nil, idempotency_key: nil) + @client.post(path(season_key, "/checkout-tests"), + compact({ "planKey" => plan_key, "quantity" => quantity }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # List the Season's test checkouts. + def list_season_checkout_tests(season_key) + @client.get(path(season_key, "/checkout-tests")) + end + + # Replace every price of one package. Each price hash takes "categoryKey" and + # "unitPrice" (minor units), plus an optional "priceTypeKey": "adult" (the + # default), "child", "concession", "senior" or "student". + def replace_season_package_prices(season_key, plan_key, prices:, currency: nil, + idempotency_key: nil) + @client.mutation_with_header_replay( + "PUT", path(season_key, "/plans/#{encode(plan_key)}/prices"), + compact({ "prices" => prices, "currency" => currency }), idempotency_key: idempotency_key + ) + end + + # List the prices of every package in the Season, each with its + # "priceTypeKey" and "priceTypeLabel". + def list_season_prices(season_key) + @client.get(path(season_key, "/prices")) + end + + # Add one performance to a package. + def attach_season_plan_performance(season_key, plan_key, event_key:, idempotency_key: nil) + @client.post(path(season_key, "/plans/#{encode(plan_key)}/performances"), + { "eventKey" => event_key }, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Remove one performance from a package. + def delete_season_plan_performance(season_key, plan_key, event_key, idempotency_key: nil) + @client.mutation_with_header_replay( + "DELETE", path(season_key, "/plans/#{encode(plan_key)}/performances/#{encode(event_key)}"), + idempotency_key: idempotency_key + ) + end + + # Mark a performance past so it stops blocking new Season sales. A cancelled date + # answers 409 performance_cancelled. On a season-seats season the date first leaves + # every season seat: while that finishes the answer is a 202 (check with + # +Seasons.date_leave_pending?+). + def mark_season_performance_past(season_key, event_key, force: nil, idempotency_key: nil) + @client.post(path(season_key, "/performances/#{encode(event_key)}/past"), + compact({ "force" => force }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Return one Season occurrence allocation to ordinary Event inventory. + # Single-attempt: keep +action_id+ stable and retry it yourself if needed. + def return_season_occurrence_inventory(season_key, event_key, action_id:, booking_ref:, + plan_activation_id:, labels:, source:) + @client.post(path(season_key, "/occurrences/#{encode(event_key)}/returns"), + { "actionId" => action_id, "bookingRef" => booking_ref, + "planActivationId" => plan_activation_id, "labels" => labels, + "source" => source }) + end + + # Reclaim returned inventory for a Season occurrence. Single-attempt, like + # +return_season_occurrence_inventory+. + def reclaim_season_occurrence_inventory(season_key, event_key, action_id:, return_action_id:, + booking_ref:, plan_activation_id:, labels:, source:) + @client.post(path(season_key, "/occurrences/#{encode(event_key)}/reclaims"), + { "actionId" => action_id, "returnActionId" => return_action_id, + "bookingRef" => booking_ref, "planActivationId" => plan_activation_id, + "labels" => labels, "source" => source }) + end + + # One page of Season holders. +search+ is sent as the API's +q+ parameter. + def list_season_holders(season_key, search: nil, status: nil, cursor: nil, limit: nil) + @client.get(path(season_key, "/holders"), + compact({ "q" => search, "status" => status, "cursor" => cursor, + "limit" => limit })) + end + + # Refund a Season order, in full or by +amount_minor+. + def refund_season_order(season_key, order_id, amount_minor: nil, idempotency_key: nil) + @client.post(path(season_key, "/orders/#{encode(order_id)}/refund"), + compact({ "amountMinor" => amount_minor }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Cancel a Season order. + def cancel_season_order(season_key, order_id, reason:, note: nil, refund_confirmed: nil, + used_tickets_acknowledged: nil, notify_buyer: nil, idempotency_key: nil) + body = compact({ "reason" => reason, "note" => note, "refundConfirmed" => refund_confirmed, + "usedTicketsAcknowledged" => used_tickets_acknowledged, + "notifyBuyer" => notify_buyer }) + @client.post(path(season_key, "/orders/#{encode(order_id)}/cancel"), body, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Cancel one date of a Season order. + def cancel_season_order_date(season_key, order_id, event_key, action_id: nil, + idempotency_key: nil) + @client.post(path(season_key, "/orders/#{encode(order_id)}/dates/#{encode(event_key)}/cancel"), + compact({ "actionId" => action_id }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Send a Season order's tickets again. Single-attempt: never retried automatically. + def resend_season_order_tickets(season_key, order_id, email: nil) + @client.post(path(season_key, "/orders/#{encode(order_id)}/resend"), + compact({ "email" => email })) + end + + # Read the state of hosted renewals for one page of offers, newest first. Pass + # +cursor+ from the previous page's +nextCursor+ (nil on the last page); +limit+ + # is 1 to 500 (default 500). + def retrieve_season_hosted_renewals(season_key, cursor: nil, limit: nil) + @client.get(path(season_key, "/renewal-hosted"), compact({ "cursor" => cursor, "limit" => limit })) + end + + # Replace a renewal offer's link. The new link is shown once, so this is + # deliberately single-attempt. + def rotate_season_renewal_offer_link(season_key, offer_id) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/link")) + end + + # Set the contact on a renewal offer. Pass +nil+ to send an explicit JSON null. + def update_season_renewal_offer_contact(season_key, offer_id, email: UNSET, name: UNSET) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/contact"), + supplied({ "email" => email, "name" => name })) + end + + # Send renewal notices, to every open offer or only to +offer_ids+ (at most + # 20,000; more is refused with 422 too_many_offer_ids). + # +batch_id+, instead of +offer_ids+: every open offer one renewal offer batch made. + def create_season_renewal_notices(season_key, offer_ids: nil, batch_id: nil) + @client.post(path(season_key, "/renewal-notices"), + compact({ "offerIds" => offer_ids, "batchId" => batch_id })) + end + + # Send one renewal offer again. Single-attempt: never retried automatically. + def resend_season_renewal_offer(season_key, offer_id) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/resend")) + end + + # What renewing an offer costs, priced as the SeatLayer checkout will charge + # it. +problem+ is nil when SeatLayer checkout can take the renewal now. + def retrieve_season_renewal_quote(season_key, offer_id) + @client.get(path(season_key, "/renewal-offers/#{encode(offer_id)}/quote")) + end + + # 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. + def create_season_renewal_intent(season_key, offer_id) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/intent")) + end + + # Start a SeatLayer checkout that renews an offer, with no hosted page. Send + # the buyer to +payment.url+. Retried with one Idempotency-Key, which answers + # the same order. + def create_season_renewal_checkout(season_key, offer_id, return_url:, buyer_email: nil, + buyer_name: nil, provider: nil, idempotency_key: nil) + @client.post(path(season_key, "/renewal-offers/#{encode(offer_id)}/checkout"), + compact({ "returnUrl" => return_url, "buyerEmail" => buyer_email, + "buyerName" => buyer_name, "provider" => provider }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Read the Season's sales report. + def retrieve_season_sales_report(season_key) + @client.get(path(season_key, "/reports/sales")) + end + + # Read the Season's seats report. + def retrieve_season_seats_report(season_key) + @client.get(path(season_key, "/reports/seats")) + end + + # Download the Season's holders as CSV. Returns the raw CSV text. + def export_season_holders_csv(season_key, search: nil, status: nil) + @client.get_text(path(season_key, "/reports/holders.csv"), + compact({ "q" => search, "status" => status })) + end + + # Download every Season order SeatLayer took as CSV. Returns the raw CSV text. + def export_season_orders_csv(season_key) + @client.get_text(path(season_key, "/reports/orders.csv")) + end + + # Read the Season's seat counts. + def retrieve_season_seat_counts(season_key) + @client.get(path(season_key, "/seat-counts")) + end + + # Refund every SeatLayer order holding a cancelled date, as a job. +amount+ is + # "per_date" (the date's share of each order); "full" is refused (422 + # date_refund_per_date_only), so refund a whole order with the order refund. + # Retried with one Idempotency-Key, which replays the same job. + def refund_season_performance(season_key, event_key, amount:, reason: nil, idempotency_key: nil) + @client.post(path(season_key, "/performances/#{encode(event_key)}/refunds"), + compact({ "amount" => amount, "reason" => reason }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # Release the seats of every lapsed renewal offer, as a job; an offer extended + # before its turn is skipped and keeps its seat. Follow it with + # +retrieve_season_bulk_job+. Retried with one Idempotency-Key, which replays the same job. + def release_season_lapsed_offers(season_key, idempotency_key: nil) + @client.post(path(season_key, "/renewal-offers/release-lapsed"), nil, + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # List the Season's bulk jobs, newest first (100 at most). + def list_season_bulk_jobs(season_key) + @client.get(path(season_key, "/jobs")) + end + + # Read one bulk job with its counts and up to 500 failed items. + def retrieve_season_bulk_job(season_key, job_id) + @client.get(path(season_key, "/jobs/#{encode(job_id)}")) + end + + # Set the Season's seats aside on every date: every seat its packages price, minus + # +keep_for_single_tickets+ ({ "sectionIds", "categoryKeys", "labels" }). Answers 202 + # with "job" (a season_block_set_aside job to follow with +retrieve_season_bulk_job+) + # and "block". On an active Season block mode turns on by itself once every date has + # the seats (season.block.ready); on a draft, Publish turns it on. Season seats must be + # on for the Season (409 season_block_not_enabled otherwise). Retried with one + # Idempotency-Key, which replays the same answer. + def set_aside_season_block(season_key, expected_revision:, keep_for_single_tickets: nil, + idempotency_key: nil) + @client.post(path(season_key, "/block"), + compact({ "expectedRevision" => expected_revision, + "keepForSingleTickets" => keep_for_single_tickets }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + + # The Season's seats set aside on every date: "block" with its "mode" ("block" once + # on, else "per_hold"), "state", "counts" and each date's "state". + def retrieve_season_block(season_key) + @client.get(path(season_key, "/block")) + end + + # Release season seats to single sale: every unsold seat (+scope+ { "all" => true }), + # or the sections, categories or seats named. +event_keys+ limits it to those dates + # (left out: every date still to come); +at+ (an ISO time) releases then instead of + # now. Answers 202 with "job" (a season_block_release job to follow with + # +retrieve_season_bulk_job+) and "block". season.block.released is sent once it has + # run on every date. Retried with one Idempotency-Key, which replays the same release. + def release_season_block(season_key, expected_revision:, scope:, event_keys: nil, at: nil, + reason: nil, idempotency_key: nil) + @client.post(path(season_key, "/block/release"), + compact({ "expectedRevision" => expected_revision, "scope" => scope, + "eventKeys" => event_keys, "at" => at, "reason" => reason }), + idempotency_key: idempotency_key, retry_policy: :header_replay) + end + private def path(season_key, suffix = "") diff --git a/lib/seatlayer/version.rb b/lib/seatlayer/version.rb index 1883448..8ef47f5 100644 --- a/lib/seatlayer/version.rb +++ b/lib/seatlayer/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SeatLayer - VERSION = "0.8.0" + VERSION = "0.9.0" end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index bcce55f..7aa38dd 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -378,6 +378,15 @@ expect(transport.calls[0].url).to include("counts=0") end + it "filters events by series" do + client, transport = build_client([{ status: 200, body: '{"events":[]}' }, + { status: 200, body: '{"events":[]}' }]) + client.events.list(limit: 10, series_key: "ser a/b") + client.events.list_all(series_key: "ser_1").to_a + expect(transport.calls[0].url).to end_with("/v1/events?limit=10&seriesKey=ser+a%2Fb") + expect(transport.calls[1].url).to include("seriesKey=ser_1") + end + it "keeps counts on a single explicit page" do client, transport = build_client([{ status: 200, body: '{"events":[]}' }]) client.events.list(limit: 10) @@ -772,6 +781,50 @@ expect(transport.calls[2].url).to end_with("/buyer-access-sessions?limit=25") end + it "accepts the series, order, customer and season ticket webhook events" do + added = %w[ + series.created series.dates_added order.paid order.refunded order.cancelled + customer.created customer.email_changed season.ticket.transfer_sent + season.ticket.transferred season.ticket.transfer_cancelled + ] + client, transport = build_client([{ status: 201 }]) + + client.webhooks.create(url: "https://hooks.example", events: added) + + expect(SeatLayer::Webhooks::EVENT_NAMES.length).to eq(58) + expect(JSON.parse(transport.calls[0].body).fetch("events")).to eq(added) + end + + it "accepts every public webhook event name, including the 2.11 date and bulk job events" do + public_names = %w[ + seat.booked seat.released seat.blocked + hold.expired hold.created hold.extended + event.created event.soldout object.resale.listed + object.resale.unlisted object.resale.sold customer.created + customer.email_changed order.paid order.refunded + order.cancelled season.ticket.transfer_sent season.ticket.transferred + season.ticket.transfer_cancelled season.ticket.issued season.ticket.voided + season.structure.activated season.structure.closed season.structure.archived + season.plan.published season.plan.superseded season.sales.opened + season.sales.paused season.sales.ended season.hold.committed + season.hold.released season.hold.expired season.booking.confirmed + season.booking.cancelled season.booking.emergency_requested season.occurrence.returned + season.occurrence.reclaimed season.renewal.offered season.renewal.intent_received + season.renewal.renewed season.renewal.declined season.renewal.lapsed + season.renewal.released season.operation.partial_terminal season.amendment.applied + season.performance.added season.performance.removed season.performance.past + season.performance.postponed season.performance.cancelled season.performance.rescheduled + season.bulk_action.completed season.block.ready season.block.released + season.order.paid season.order.refunded series.created series.dates_added + ] + client, transport = build_client([{ status: 201 }]) + + client.webhooks.create(url: "https://hooks.example", events: public_names) + + expect(SeatLayer::Webhooks::EVENT_NAMES).to eq(public_names) + expect(JSON.parse(transport.calls[0].body).fetch("events").length).to eq(58) + end + it "rejects unknown webhook events before transport" do client, = build_client([]) expect { client.webhooks.create(url: "https://hooks.example", events: ["booking.created"]) } diff --git a/spec/event_sales_spec.rb b/spec/event_sales_spec.rb new file mode 100644 index 0000000..1470d69 --- /dev/null +++ b/spec/event_sales_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.describe SeatLayer::Events do + it "maps all 14 event sales operations, none of them replay-backed" do + client, transport = build_client(Array.new(14) { { status: 200 } }) + events = client.events + event = "ev/a" + + events.retrieve_event_buyer_checkout(event) + events.replace_event_buyer_checkout(event, buyer_checkout: "seatlayer", expected_revision: 3) + events.retrieve_event_payment_gateway(event, currency: "EUR") + events.replace_event_payment_gateway(event, connection_id: nil) + events.retrieve_event_sales_readiness(event) + events.preflight_event_sales(event, order_id: "ord_1") + events.transition_event_sales(event, to: "live", expected_revision: 4) + events.retrieve_event_booking_fee(event) + events.replace_event_booking_fee(event, booking_fee_minor: nil) + events.list_event_discount_codes(event) + events.create_event_discount_code(event, code: "EARLY", kind: "percent", percent: 10, + max_uses: nil) + events.update_event_discount_code(event, "code/1", state: "paused", ends_at: 1_800_000_000_000) + events.end_event_discount_code(event, "code/1") + events.delete_event_discount_code(event, "code/1") + + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["GET /v1/events/ev%2Fa/buyer-checkout", nil, false], + ["PUT /v1/events/ev%2Fa/buyer-checkout", + { "buyerCheckout" => "seatlayer", "expectedRevision" => 3 }, false], + ["GET /v1/events/ev%2Fa/payment-gateway?currency=EUR", nil, false], + ["PUT /v1/events/ev%2Fa/payment-gateway", { "connectionId" => nil }, false], + ["GET /v1/events/ev%2Fa/sales/readiness", nil, false], + ["POST /v1/events/ev%2Fa/sales/preflight", { "orderId" => "ord_1" }, false], + ["POST /v1/events/ev%2Fa/sales/transition", { "to" => "live", "expectedRevision" => 4 }, false], + ["GET /v1/events/ev%2Fa/booking-fee", nil, false], + ["PUT /v1/events/ev%2Fa/booking-fee", { "bookingFeeMinor" => nil }, false], + ["GET /v1/events/ev%2Fa/discount-codes", nil, false], + ["POST /v1/events/ev%2Fa/discount-codes", + { "code" => "EARLY", "kind" => "percent", "percent" => 10, "maxUses" => nil }, false], + ["PATCH /v1/events/ev%2Fa/discount-codes/code%2F1", + { "state" => "paused", "endsAt" => 1_800_000_000_000 }, false], + ["POST /v1/events/ev%2Fa/discount-codes/code%2F1/end", nil, false], + ["DELETE /v1/events/ev%2Fa/discount-codes/code%2F1", nil, false] + ] + ) + end + + it "returns an empty result when a discount code is deleted (204)" do + client, = build_client([{ status: 204, body: "" }]) + + expect(client.events.delete_event_discount_code("ev_1", "code_1")).to eq({}) + end +end diff --git a/spec/event_series_spec.rb b/spec/event_series_spec.rb new file mode 100644 index 0000000..2495d2b --- /dev/null +++ b/spec/event_series_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.describe SeatLayer::EventSeries do + it "makes a series from an event already set up" do + client, transport = build_client([{ status: 202 }]) + schedule = { "rule" => "daily", "from" => "2026-11-06", "until" => "2026-11-19", "times" => ["14:00"] } + client.event_series.create_event_series(schedule: schedule, from_event_key: "lighthouse-keeper") + expect(request_summary(transport.calls[0])).to eq( + ["POST /v1/event-series", { "fromEventKey" => "lighthouse-keeper", "schedule" => schedule }, true] + ) + expect { client.event_series.create_event_series(schedule: schedule) } + .to raise_error(ArgumentError, /from_event_key/) + end + + it "raises a given-up series create as 409 create_abandoned after one request, with retries on" do + abandoned = { "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." } + client, transport = build_client([{ status: 409, body: abandoned.to_json }], max_retries: 3) + series = client.event_series + schedule = { "rule" => "daily", "from" => "2026-11-06", "until" => "2026-11-08", "times" => ["20:00"] } + + expect do + series.create_event_series(schedule: schedule, from_event_key: "ev_1", idempotency_key: "series-3") + end.to raise_error(SeatLayer::ConflictError) { |error| + expect(error.code).to eq("create_abandoned") + expect(error.message).to include("new Idempotency-Key") + } + expect(transport.calls.length).to eq(1) + expect(transport.calls[0].headers["Idempotency-Key"]).to eq("series-3") + end + + it "maps all 5 event series operations and their replay classes" do + client, transport = build_client(Array.new(6) { { status: 202 } }) + series = client.event_series + schedule = { "rule" => "weekly", "from" => "2027-01-04", "count" => 4, "times" => ["19:30"] } + + series.create_event_series(name: "Hamlet", chart_key: "chart_1", schedule: schedule, + timezone: "Europe/London", dry_run: true) + series.list_event_series(limit: 10, cursor: "c/1") + series.retrieve_event_series("ev/a") + series.add_event_series_dates("ev/a", dates: ["2027-02-01T19:30"], idempotency_key: "dates-1") + series.update_event_series("ev/a", sales: "off") + series.add_event_series_dates("ev/a", retry_failed: true) + + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["POST /v1/event-series", + { "name" => "Hamlet", "chartKey" => "chart_1", "schedule" => schedule, + "timezone" => "Europe/London", "dryRun" => true }, true], + ["GET /v1/event-series?limit=10&cursor=c%2F1", nil, false], + ["GET /v1/event-series/ev%2Fa", nil, false], + ["POST /v1/event-series/ev%2Fa/dates", { "dates" => ["2027-02-01T19:30"] }, true], + ["PATCH /v1/event-series/ev%2Fa", { "sales" => "off" }, true], + # Make again the dates that failed to be made. + ["POST /v1/event-series/ev%2Fa/dates", { "retryFailed" => true }, true] + ] + ) + expect(transport.calls[3].headers["Idempotency-Key"]).to eq("dates-1") + end +end diff --git a/spec/holders_spec.rb b/spec/holders_spec.rb new file mode 100644 index 0000000..6a198aa --- /dev/null +++ b/spec/holders_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +RSpec.describe SeatLayer::Holders do + it "maps all 14 holder and customer operations and their replay classes" do + client, transport = build_client(Array.new(15) { { status: 200 } }) + holders = client.holders + ref = "hol/a" + + holders.list_customers(email: "ann@example.com", holder_ref: "h 1", limit: 10) + holders.retrieve_holder(ref) + holders.update_holder(ref, email: "ann@example.com", phone: nil, idempotency_key: "holder-1") + holders.revoke_holder_sessions(ref, session_id: "ses/1") + holders.list_holder_seasons(ref) + holders.list_holder_tickets(ref, season_key: "sea/a", from: 1, to: 2, cursor: "c/1", limit: 5) + holders.retrieve_holder_ticket(ref, "tkt/1") + holders.create_holder_transfer(ref, ticket_ids: ["tkt/1"], to_email: "bo@example.com") + holders.list_holder_transfers(ref) + holders.cancel_holder_transfer(ref, "xfer/1") + holders.accept_holder_transfer(ref, "xfer/1") + holders.create_holder_return( + ref, ticket_ids: ["tkt_0123456789abcdef01234567"], idempotency_key: "return-1" + ) + holders.create_holder_renewal_checkout( + ref, "offer/1", return_url: "https://tickets.example/renewed", buyer_email: "cara@example.com", + buyer_name: "Cara", provider: "stripe", idempotency_key: "renew-1" + ) + holders.decline_holder_renewal_offer(ref, "offer/1", reason: "Moving away") + holders.decline_holder_renewal_offer(ref, "offer/1") + + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["GET /v1/customers?email=ann%40example.com&holderRef=h+1&limit=10", nil, false], + ["GET /v1/holders/hol%2Fa", nil, false], + ["PATCH /v1/holders/hol%2Fa", { "email" => "ann@example.com", "phone" => nil }, true], + ["POST /v1/holders/hol%2Fa/sessions/revoke", { "sessionId" => "ses/1" }, false], + ["GET /v1/holders/hol%2Fa/seasons", nil, false], + ["GET /v1/holders/hol%2Fa/tickets?seasonKey=sea%2Fa&from=1&to=2&cursor=c%2F1&limit=5", + nil, false], + ["GET /v1/holders/hol%2Fa/tickets/tkt%2F1", nil, false], + ["POST /v1/holders/hol%2Fa/transfers", + { "ticketIds" => ["tkt/1"], "toEmail" => "bo@example.com" }, true], + ["GET /v1/holders/hol%2Fa/transfers", nil, false], + ["POST /v1/holders/hol%2Fa/transfers/xfer%2F1/cancel", nil, false], + ["POST /v1/holders/hol%2Fa/transfers/xfer%2F1/accept", nil, false], + ["POST /v1/holders/hol%2Fa/returns", { "ticketIds" => ["tkt_0123456789abcdef01234567"] }, true], + ["POST /v1/holders/hol%2Fa/renewal-offers/offer%2F1/checkout", + { "returnUrl" => "https://tickets.example/renewed", "buyerEmail" => "cara@example.com", + "buyerName" => "Cara", "provider" => "stripe" }, true], + ["POST /v1/holders/hol%2Fa/renewal-offers/offer%2F1/decline", { "reason" => "Moving away" }, false], + ["POST /v1/holders/hol%2Fa/renewal-offers/offer%2F1/decline", {}, false] + ] + ) + expect(transport.calls[2].headers["Idempotency-Key"]).to eq("holder-1") + expect(transport.calls[11].headers["Idempotency-Key"]).to eq("return-1") + expect(transport.calls[12].headers["Idempotency-Key"]).to eq("renew-1") + end + + it "retries a holder return with one Idempotency-Key, generated or the caller's" do + created = '{"return":{"returnId":"ret_1","state":"returned","seasonKey":"sea_1",' \ + '"tickets":[],"createdAt":1}}' + responses = [ + { status: 429, headers: { "Retry-After" => "0" } }, { status: 201, body: created }, + { status: 429, headers: { "Retry-After" => "0" } }, { status: 200, body: created } + ] + client, transport = build_client(responses) + + first = client.holders.create_holder_return("a/b", ticket_ids: ["tkt_0123456789abcdef01234567"]) + again = client.holders.create_holder_return( + "a/b", ticket_ids: ["tkt_0123456789abcdef01234567"], idempotency_key: "return-1" + ) + + expect([first, again]).to all(eq(JSON.parse(created))) + expect(transport.calls.map { |call| request_summary(call) }).to all( + eq(["POST /v1/holders/a%2Fb/returns", { "ticketIds" => ["tkt_0123456789abcdef01234567"] }, true]) + ) + keys = transport.calls.map { |call| call.headers["Idempotency-Key"] } + expect(keys[0]).to eq(keys[1]) + expect(keys[2..]).to eq(%w[return-1 return-1]) + end + + it "retries a holder renewal checkout with one key, generated or the caller's, and declines once" do + responses = [ + { status: 429, headers: { "Retry-After" => "0" } }, { status: 201 }, + { status: 429, headers: { "Retry-After" => "0" } }, { status: 201 }, + { status: 429, headers: { "Retry-After" => "0" } } + ] + client, transport = build_client(responses) + + client.holders.create_holder_renewal_checkout("a/b", "offer_1", return_url: "https://tickets.example/r") + client.holders.create_holder_renewal_checkout("a/b", "offer_1", return_url: "https://tickets.example/r", + idempotency_key: "renew-1") + expect { client.holders.decline_holder_renewal_offer("a/b", "offer_1", reason: "Moving away") } + .to raise_error(SeatLayer::RateLimitError) + + checkout = ["POST /v1/holders/a%2Fb/renewal-offers/offer_1/checkout", + { "returnUrl" => "https://tickets.example/r" }, true] + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [checkout, checkout, checkout, checkout, + ["POST /v1/holders/a%2Fb/renewal-offers/offer_1/decline", { "reason" => "Moving away" }, false]] + ) + keys = transport.calls.map { |call| call.headers["Idempotency-Key"] } + expect(keys[0]).to eq(keys[1]) + expect(keys[2..3]).to eq(%w[renew-1 renew-1]) + end + + it "sends an explicit null only for the contact fields passed as nil" do + client, transport = build_client([{ status: 200 }]) + + client.holders.update_holder("h_1", name: nil) + + expect(JSON.parse(transport.calls[0].body)).to eq("name" => nil) + end +end diff --git a/spec/orders_spec.rb b/spec/orders_spec.rb new file mode 100644 index 0000000..0773bcf --- /dev/null +++ b/spec/orders_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe SeatLayer::Orders do + it "maps all 9 order operations and their replay classes" do + client, transport = build_client(Array.new(9) { { status: 200 } }) + orders = client.orders + event = "ev/a" + + orders.list_orders(season_key: "sea/a", holder_ref: "ord/1", status: "confirmed", + from: 1, to: 2, limit: 20) + orders.retrieve_order("ord/1") + orders.list_order_tickets("ord/1") + orders.list_event_orders(event, search: "ann lee", paid_by: "card", cursor: "c/1") + orders.retrieve_event_order(event, "ord/1") + orders.export_event_orders_csv(event, status: "refunded", from: 3, to: 4) + orders.refund_event_order(event, "ord/1", amount_minor: 250, idempotency_key: "refund-1") + orders.cancel_event_order( + event, "ord/1", reason: "lost_dispute", refund_confirmed: true, + used_tickets_acknowledged: false + ) + orders.resend_event_order_tickets(event, "ord/1") + + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["GET /v1/orders?seasonKey=sea%2Fa&holderRef=ord%2F1&status=confirmed&from=1&to=2&limit=20", + nil, false], + ["GET /v1/orders/ord%2F1", nil, false], + ["GET /v1/orders/ord%2F1/tickets", nil, false], + ["GET /v1/events/ev%2Fa/orders?q=ann+lee&paidBy=card&cursor=c%2F1", nil, false], + ["GET /v1/events/ev%2Fa/orders/ord%2F1", nil, false], + ["GET /v1/events/ev%2Fa/orders.csv?status=refunded&from=3&to=4", nil, false], + ["POST /v1/events/ev%2Fa/orders/ord%2F1/refund", { "amountMinor" => 250 }, true], + ["POST /v1/events/ev%2Fa/orders/ord%2F1/cancel", + { "reason" => "lost_dispute", "refundConfirmed" => true, + "usedTicketsAcknowledged" => false }, true], + ["POST /v1/events/ev%2Fa/orders/ord%2F1/resend", {}, false] + ] + ) + expect(transport.calls[6].headers["Idempotency-Key"]).to eq("refund-1") + end + + it "returns the event orders CSV as raw text" do + csv = "orderId,email\nord_1,ann@example.com\n" + client, transport = build_client([{ status: 200, body: csv, + headers: { "Content-Type" => "text/csv" } }]) + + expect(client.orders.export_event_orders_csv("ev_1", search: "ann")).to eq(csv) + expect(transport.calls[0].headers["Accept"]).to eq("text/csv") + end + + it "never retries an unsupported ticket resend" do + client, transport = build_client([{ status: 500, body: '{"code":"internal"}' }, + { status: 200 }]) + + expect { client.orders.resend_event_order_tickets("ev_1", "ord_1", email: "ann@example.com") } + .to raise_error(SeatLayer::APIError) + expect(transport.calls.length).to eq(1) + end +end diff --git a/spec/seasons_spec.rb b/spec/seasons_spec.rb index da665c4..be25afc 100644 --- a/spec/seasons_spec.rb +++ b/spec/seasons_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true RSpec.describe SeatLayer::Seasons do - it "maps all 48 Season operations and their exact replay classes" do - client, transport = build_client(Array.new(48) { { status: 200 } }) + it "maps all 90 Season operations and their exact replay classes" do + client, transport = build_client(Array.new(90) { { status: 200 } }) seasons = client.seasons key = "sea/a" @@ -66,6 +66,70 @@ seasons.replay_season_outbox(key, "occurrence/1") seasons.list_season_audit(key) seasons.export_season_support_snapshot(key) + seasons.create_season_holder_import_from_season( + key, source_season_id: "sea_old", successor_plan_activation_id: "pa_2", dry_run: true, + idempotency_key: "from-1" + ) + seasons.list_season_performances(key) + seasons.create_season_performances( + key, title_pattern: "Hamlet", timezone: "Europe/London", repeat: "weekly", count: 3, + venue: nil, idempotency_key: "perf-1" + ) + seasons.attach_season_performances(key, event_keys: ["ev_3"], idempotency_key: "attach-1") + seasons.delete_season_performance(key, "ev/3") + seasons.update_season_plan(key, "plan/1", name: "Gold") + seasons.delete_season_plan(key, "plan/1") + seasons.publish_season(key) + seasons.create_season_checkout_test(key, plan_key: "plan/1", quantity: 2) + seasons.list_season_checkout_tests(key) + seasons.replace_season_package_prices( + key, "plan/1", prices: [{ "categoryKey" => "stalls", "unitPrice" => 9000 }], currency: "GBP" + ) + seasons.list_season_prices(key) + seasons.attach_season_plan_performance(key, "plan/1", event_key: "ev/3") + seasons.delete_season_plan_performance(key, "plan/1", "ev/3") + seasons.mark_season_performance_past(key, "ev/3", force: true) + seasons.return_season_occurrence_inventory( + key, "ev/3", action_id: "ret_1", booking_ref: "order_1", plan_activation_id: "pa_1", + labels: ["A-1"], source: { "bookingActionId" => "book_1" } + ) + seasons.reclaim_season_occurrence_inventory( + key, "ev/3", action_id: "rec_1", return_action_id: "ret_1", booking_ref: "order_1", + plan_activation_id: "pa_1", labels: ["A-1"], source: { "renewalOfferId" => "o_1" } + ) + seasons.list_season_holders(key, search: "ann lee", status: "active", limit: 50) + seasons.refund_season_order(key, "ord/1", amount_minor: 500) + seasons.cancel_season_order(key, "ord/1", reason: "other", note: "Duplicate", notify_buyer: false) + seasons.cancel_season_order_date(key, "ord/1", "ev/3", action_id: "date_1") + seasons.resend_season_order_tickets(key, "ord/1", email: "ann@example.com") + seasons.retrieve_season_hosted_renewals(key) + seasons.rotate_season_renewal_offer_link(key, "offer/1") + seasons.update_season_renewal_offer_contact(key, "offer/1", email: "ann@example.com", name: nil) + seasons.create_season_renewal_notices(key, offer_ids: ["offer/1"]) + seasons.resend_season_renewal_offer(key, "offer/1") + seasons.retrieve_season_sales_report(key) + seasons.retrieve_season_seats_report(key) + seasons.export_season_holders_csv(key, search: "ann", status: "renewal") + seasons.export_season_orders_csv(key) + seasons.retrieve_season_seat_counts(key) + seasons.refund_season_performance(key, "ev/3", amount: "per_date", reason: "Storm", + idempotency_key: "refund-date-1") + seasons.list_season_bulk_jobs(key) + seasons.retrieve_season_bulk_job(key, "job/1") + seasons.retrieve_season_renewal_quote(key, "offer/1") + seasons.create_season_renewal_intent(key, "offer/1") + seasons.create_season_renewal_checkout( + key, "offer/1", return_url: "https://tickets.example/renewed", buyer_email: "ana@example.com", + buyer_name: "Ana Silva", provider: "stripe", idempotency_key: "renew-1" + ) + seasons.release_season_lapsed_offers(key, idempotency_key: "release-lapsed-1") + # Season seats: set aside, read, release (phase 4). + seasons.set_aside_season_block(key, expected_revision: 7, + keep_for_single_tickets: { "labels" => ["A-6"] }, + idempotency_key: "block-1") + seasons.retrieve_season_block(key) + seasons.release_season_block(key, expected_revision: 7, scope: { "all" => true }, + event_keys: ["ev/3"], at: "2026-12-01T10:00:00Z", reason: "Unsold") expected = [ "GET /v1/seasons?workspaceId=ws+1&structureState=draft&limit=20&cursor=c%2F1", @@ -97,7 +161,37 @@ "GET /v1/seasons/sea%2Fa/reports", "GET /v1/seasons/sea%2Fa/operations", "GET /v1/seasons/sea%2Fa/support-lookups?holderRef=holder+a%2Fb", "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" + "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%2F3", "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%2F3/past", + "POST /v1/seasons/sea%2Fa/occurrences/ev%2F3/returns", + "POST /v1/seasons/sea%2Fa/occurrences/ev%2F3/reclaims", + "GET /v1/seasons/sea%2Fa/holders?q=ann+lee&status=active&limit=50", + "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%2F3/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=ann&status=renewal", + "GET /v1/seasons/sea%2Fa/reports/orders.csv", "GET /v1/seasons/sea%2Fa/seat-counts", + "POST /v1/seasons/sea%2Fa/performances/ev%2F3/refunds", "GET /v1/seasons/sea%2Fa/jobs", + "GET /v1/seasons/sea%2Fa/jobs/job%2F1", + "GET /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/quote", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/intent", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/checkout", + "POST /v1/seasons/sea%2Fa/renewal-offers/release-lapsed", + "POST /v1/seasons/sea%2Fa/block", "GET /v1/seasons/sea%2Fa/block", + "POST /v1/seasons/sea%2Fa/block/release" ] actual = transport.calls.map do |call| "#{call.http_method} #{call.url.delete_prefix("https://api.seatlayer.io")}" @@ -105,7 +199,39 @@ expect(actual).to eq(expected) expect(transport.calls[26].body).to be_nil - replay_indexes = [2, 4, 5, 10, 18, 27, 29, 38] + expect(transport.calls.drop(48).map { |call| call.body && JSON.parse(call.body) }).to eq( + [ + { "sourceSeasonId" => "sea_old", "successorPlanActivationId" => "pa_2", "dryRun" => true }, + nil, + { "titlePattern" => "Hamlet", "timezone" => "Europe/London", "repeat" => "weekly", + "count" => 3, "venue" => nil }, + { "eventKeys" => ["ev_3"] }, nil, { "name" => "Gold" }, nil, nil, + { "planKey" => "plan/1", "quantity" => 2 }, nil, + { "prices" => [{ "categoryKey" => "stalls", "unitPrice" => 9000 }], "currency" => "GBP" }, + nil, { "eventKey" => "ev/3" }, nil, { "force" => true }, + { "actionId" => "ret_1", "bookingRef" => "order_1", "planActivationId" => "pa_1", + "labels" => ["A-1"], "source" => { "bookingActionId" => "book_1" } }, + { "actionId" => "rec_1", "returnActionId" => "ret_1", "bookingRef" => "order_1", + "planActivationId" => "pa_1", "labels" => ["A-1"], "source" => { "renewalOfferId" => "o_1" } }, + nil, { "amountMinor" => 500 }, + { "reason" => "other", "note" => "Duplicate", "notifyBuyer" => false }, + { "actionId" => "date_1" }, { "email" => "ann@example.com" }, nil, nil, + { "email" => "ann@example.com", "name" => nil }, { "offerIds" => ["offer/1"] }, + nil, nil, nil, nil, nil, nil, + { "amount" => "per_date", "reason" => "Storm" }, nil, nil, + nil, nil, + { "returnUrl" => "https://tickets.example/renewed", "buyerEmail" => "ana@example.com", + "buyerName" => "Ana Silva", "provider" => "stripe" }, + nil, + { "expectedRevision" => 7, "keepForSingleTickets" => { "labels" => ["A-6"] } }, + nil, + { "expectedRevision" => 7, "scope" => { "all" => true }, "eventKeys" => ["ev/3"], + "at" => "2026-12-01T10:00:00Z", "reason" => "Unsold" } + ] + ) + + replay_indexes = [2, 4, 5, 10, 18, 27, 29, 38, 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, + 66, 67, 68, 80, 85, 86, 87, 89] transport.calls.each_with_index do |call, index| if replay_indexes.include?(index) expect(call.headers).to have_key("Idempotency-Key") @@ -113,5 +239,346 @@ expect(call.headers).not_to have_key("Idempotency-Key") end end + expect(transport.calls[80].headers["Idempotency-Key"]).to eq("refund-date-1") + expect(transport.calls[85].headers["Idempotency-Key"]).to eq("renew-1") + expect(transport.calls[86].headers["Idempotency-Key"]).to eq("release-lapsed-1") + expect(transport.calls[87].headers["Idempotency-Key"]).to eq("block-1") + end + + it "hands the season seats answers back: the 202s, a job of a new kind, and the 409 codes" do + job = { "jobId" => "sbsa_sbp_1", "kind" => "season_block_set_aside", "state" => "running", + "counts" => { "total" => 3, "done" => 1, "skipped" => 0, "failed" => 0 } } + block = { "mode" => "per_hold", "state" => "setting_aside", "revision" => 7 } + pending = { "state" => "finishing", "code" => "block_date_leave_pending", "retryable" => true, + "eventKey" => "ev_1", "shardsLeft" => [1], "shardCount" => 2, "message" => "Still leaving" } + codes = %w[season_block_not_enabled block_exists season_block_set_aside season_inventory_locked + date_leave_in_progress] + responses = [ + { status: 202, body: JSON.generate({ "job" => job, "block" => block }) }, + { status: 200, body: JSON.generate({ "job" => job.merge("kind" => "season_block_release") }) }, + { status: 202, body: JSON.generate(pending), headers: { "Retry-After" => "2" } }, + { status: 202, body: JSON.generate(pending) } + ] + codes.map { |code| { status: 409, body: JSON.generate({ "error" => code, "code" => code }) } } + client, transport = build_client(responses) + seasons = client.seasons + + set_aside = seasons.set_aside_season_block("sea_1", expected_revision: 7) + expect(set_aside).to eq({ "job" => job, "block" => block }) + # Without a caller key one is generated, so a retry replays the same answer. + expect(transport.calls[0].headers).to have_key("Idempotency-Key") + expect(seasons.retrieve_season_bulk_job("sea_1", "sbr_1")["job"]["kind"]).to eq("season_block_release") + cancel = seasons.create_season_amendment("sea_1", event_key: "ev_1", kind: "cancel") + past = seasons.mark_season_performance_past("sea_1", "ev_1") + expect(described_class.date_leave_pending?(cancel)).to be(true) + expect(described_class.date_leave_pending?(past)).to be(true) + expect(described_class.date_leave_pending?({ "amendment" => { "amendmentId" => "am_1" } })).to be(false) + other = { "state" => "finishing", "code" => "season_busy" } + expect(described_class.date_leave_pending?(other)).to be(false) + done = { "state" => "done", "code" => "block_date_leave_pending" } + expect(described_class.date_leave_pending?(done)).to be(false) + expect(described_class.date_leave_pending?(nil)).to be(false) + codes.each do |code| + scope = { "labels" => ["A-1"] } + release = -> { seasons.release_season_block("sea_1", expected_revision: 7, scope: scope) } + expect(release).to raise_error(SeatLayer::ConflictError) { |error| expect(error.code).to eq(code) } + end + end + + it "sends the decline reason, and retries a renewal checkout with one key but not the intent" do + responses = [ + { status: 200 }, { status: 200 }, + { status: 429, headers: { "Retry-After" => "0" } }, { status: 201, body: '{"orderId":"ord_1"}' }, + { status: 429, headers: { "Retry-After" => "0" } } + ] + client, transport = build_client(responses) + seasons = client.seasons + + seasons.decline_season_renewal_offer("sea/a", "offer/1", reason: "Moving away") + seasons.decline_season_renewal_offer("sea_1", "offer_1") + started = seasons.create_season_renewal_checkout("sea_1", "offer_1", + return_url: "https://tickets.example/r") + expect { seasons.create_season_renewal_intent("sea_1", "offer_1") } + .to raise_error(SeatLayer::RateLimitError) + + expect(started).to eq({ "orderId" => "ord_1" }) + checkout = ["POST /v1/seasons/sea_1/renewal-offers/offer_1/checkout", + { "returnUrl" => "https://tickets.example/r" }, true] + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/decline", { "reason" => "Moving away" }, false], + ["POST /v1/seasons/sea_1/renewal-offers/offer_1/decline", {}, false], + checkout, checkout, + ["POST /v1/seasons/sea_1/renewal-offers/offer_1/intent", nil, false] + ] + ) + expect(transport.calls[2].headers["Idempotency-Key"]) + .to eq(transport.calls[3].headers["Idempotency-Key"]) + end + + it "sends price types, sale settings, holder contacts and date cancels on the wire" do + client, transport = build_client(Array.new(6) { { status: 200 } }) + seasons = client.seasons + + seasons.replace_season_package_prices( + "sea_1", "plan_1", prices: [{ "categoryKey" => "stalls", "unitPrice" => 18_000 }, + { "categoryKey" => "stalls", "priceTypeKey" => "child", + "unitPrice" => 9_000 }] + ) + seasons.update_season("sea_1", expected_revision: 3, checkout_mode: "seatlayer", + transfer_cutoff_hours: 24, return_cutoff_hours: nil, audience: "public") + seasons.update_season("sea_1", expected_revision: 4, name: "Renamed") + seasons.create_season_holder_import( + "sea_1", successor_plan_activation_id: "pa_1", + rows: [{ "rowId" => "r1", "holderRef" => "h1", "priorPlanActivationId" => "pa_0", + "priorContractRef" => "c1", "labels" => ["A-1"], + "holderEmail" => "ana@example.com", "holderName" => "Ana Silva" }] + ) + seasons.create_season_amendment("sea_1", event_key: "ev_night_3", kind: "cancel", refund: "per_date", + reason: "Storm", idempotency_key: "night-3-storm") + seasons.create_season_amendment("sea_1", event_key: "ev_1", kind: "postpone") + + expect(transport.calls.map { |call| JSON.parse(call.body) }).to eq( + [ + { "prices" => [{ "categoryKey" => "stalls", "unitPrice" => 18_000 }, + { "categoryKey" => "stalls", "priceTypeKey" => "child", "unitPrice" => 9_000 }] }, + { "expectedRevision" => 3, "checkoutMode" => "seatlayer", "audience" => "public", + "transferCutoffHours" => 24, "returnCutoffHours" => nil }, + { "expectedRevision" => 4, "name" => "Renamed" }, + { "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" } + ] + ) + expect(transport.calls[1].body).to include('"returnCutoffHours":null') + expect(transport.calls[4].headers["Idempotency-Key"]).to eq("night-3-storm") + end + + it "names the job voiding a cancelled date, and sizes a holder import call by its dates" do + expect(SeatLayer::Seasons.date_cancel_void_job_id("amd_1")).to eq("sbj_void_amd_1") + expect([1, 20, 21, 50].map { |dates| SeatLayer::Seasons.holder_import_rows_per_call(dates) }) + .to eq([100, 100, 95, 43]) + + invalid_rows = { "error" => "invalid_rows", "code" => "invalid_rows", + "message" => "At most 43 rows a call for this package.", "maxRows" => 43 } + client, transport = build_client([{ status: 422, body: invalid_rows.to_json }]) + + expect do + client.seasons.create_season_holder_import("sea_1", successor_plan_activation_id: "pa_1", rows: []) + end.to raise_error(SeatLayer::ValidationError) { |error| + expect(error.code).to eq("invalid_rows") + expect(error.body["maxRows"]).to eq(43) + } + expect(transport.calls.length).to eq(1) + end + + it "retries a bulk refund of a date with one key" do + client, transport = build_client([{ status: 429, headers: { "Retry-After" => "0" } }, + { status: 202, body: '{"job":{"jobId":"sbj_1"}}' }]) + + result = client.seasons.refund_season_performance("sea_1", "ev_1", amount: "per_date") + + expect(result).to eq({ "job" => { "jobId" => "sbj_1" } }) + expect(transport.calls.length).to eq(2) + expect(transport.calls.map { |call| JSON.parse(call.body) }).to all(eq({ "amount" => "per_date" })) + expect(transport.calls[0].headers["Idempotency-Key"]) + .to eq(transport.calls[1].headers["Idempotency-Key"]) + end + + it "retries a release of lapsed offers with one key and sends no body" do + client, transport = build_client([{ status: 503, body: '{"code":"job_busy"}' }, + { status: 202, body: '{"job":{"jobId":"sbj_2"}}' }]) + + result = client.seasons.release_season_lapsed_offers("sea/a") + + expect(result).to eq({ "job" => { "jobId" => "sbj_2" } }) + expect(transport.calls.map { |call| request_summary(call) }).to eq( + Array.new(2) { ["POST /v1/seasons/sea%2Fa/renewal-offers/release-lapsed", nil, true] } + ) + expect(transport.calls[0].headers["Idempotency-Key"]) + .to eq(transport.calls[1].headers["Idempotency-Key"]) + end + + it "pages hosted renewals and renewal offers with cursor and limit" do + responses = [ + { status: 200, body: '{"offers":[],"nextCursor":"c/3"}' }, + { status: 200, body: '{"offers":[],"truncated":false,"nextCursor":null}' }, + { status: 200 }, { status: 200 } + ] + client, transport = build_client(responses) + seasons = client.seasons + + hosted = seasons.retrieve_season_hosted_renewals("sea/a", cursor: "c/1", limit: 500) + offers = seasons.list_season_renewal_offers("sea/a", cursor: "c/2", limit: 100) + seasons.retrieve_season_hosted_renewals("sea/a", limit: 1) + seasons.list_season_renewal_offers("sea/a", cursor: "c/4") + + expect(hosted["nextCursor"]).to eq("c/3") + expect(offers["nextCursor"]).to be_nil + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["GET /v1/seasons/sea%2Fa/renewal-hosted?cursor=c%2F1&limit=500", nil, false], + ["GET /v1/seasons/sea%2Fa/renewal-offers?cursor=c%2F2&limit=100", nil, false], + ["GET /v1/seasons/sea%2Fa/renewal-hosted?limit=1", nil, false], + ["GET /v1/seasons/sea%2Fa/renewal-offers?cursor=c%2F4", nil, false] + ] + ) + end + + it "sends single tickets on create and update, an explicit false included" do + updated = '{"season":{"key":"sea_1","singleTickets":true},"singleTicketDates":' \ + '{"direction":"open","opened":["ev_1"],"closed":[],"notOpened":[],"jobId":null}}' + client, transport = build_client([{ status: 201, body: '{"season":{"key":"sea_1"}}' }, + { status: 200, body: updated }]) + seasons = client.seasons + + seasons.create_season(name: "Series", event_keys: %w[ev_1 ev_2], single_tickets: true) + answer = seasons.update_season("sea/a", expected_revision: 5, single_tickets: false) + + expect(answer["singleTicketDates"]["opened"]).to eq(["ev_1"]) + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["POST /v1/seasons", + { "eventKeys" => %w[ev_1 ev_2], "name" => "Series", "singleTickets" => true }, true], + ["PATCH /v1/seasons/sea%2Fa", { "expectedRevision" => 5, "singleTickets" => false }, true] + ] + ) + end + + it "sends inventory_mode on create and update only when given, and reads it back on every season" do + block = '{"season":{"key":"sea_1","revision":5,"inventoryMode":"block"}}' + per_hold = '{"season":{"key":"sea_2","revision":1,"inventoryMode":"per_hold"}}' + list = '{"seasons":[{"key":"sea_1","inventoryMode":"block"},' \ + '{"key":"sea_2","inventoryMode":"per_hold"}],"nextCursor":null}' + client, transport = build_client( + [block, per_hold, block, per_hold, per_hold, block, list].map { |body| { status: 200, body: body } } + ) + seasons = client.seasons + + created = seasons.create_season(name: "Series", event_keys: %w[ev_1 ev_2], inventory_mode: "block") + seasons.create_season(name: "Series", event_keys: %w[ev_1 ev_2]) + turned_on = seasons.update_season("sea_1", expected_revision: 5, inventory_mode: "block") + seasons.update_season("sea_1", expected_revision: 5, inventory_mode: "per_hold") + seasons.update_season("sea_1", expected_revision: 5, name: "Series 2") + retrieved = seasons.retrieve_season("sea_1") + listed = seasons.list_seasons + + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [ + ["POST /v1/seasons", + { "eventKeys" => %w[ev_1 ev_2], "name" => "Series", "inventoryMode" => "block" }, true], + ["POST /v1/seasons", { "eventKeys" => %w[ev_1 ev_2], "name" => "Series" }, true], + ["PATCH /v1/seasons/sea_1", { "expectedRevision" => 5, "inventoryMode" => "block" }, true], + ["PATCH /v1/seasons/sea_1", { "expectedRevision" => 5, "inventoryMode" => "per_hold" }, true], + ["PATCH /v1/seasons/sea_1", { "expectedRevision" => 5, "name" => "Series 2" }, true], + ["GET /v1/seasons/sea_1", nil, false], + ["GET /v1/seasons", nil, false] + ] + ) + expect(created["season"]["inventoryMode"]).to eq("block") + expect(turned_on["season"]["inventoryMode"]).to eq("block") + expect(retrieved["season"]["inventoryMode"]).to eq("block") + expect(listed["seasons"].map { |season| season["inventoryMode"] }).to eq(%w[block per_hold]) + end + + it "hands the inventory_mode refusals and block_not_ready back with their code" do + refusals = [[409, "season_block_packages"], [409, "season_block_on_sale"], + [409, "season_not_draft_or_active"], [409, "season_block_exists"], + [422, "invalid_inventory_mode"]] + responses = refusals.map do |status, code| + { status: status, body: JSON.generate({ "error" => code, "code" => code, "message" => code }) } + end + not_ready = { "error" => "block_not_ready", "code" => "block_not_ready" } + responses << { status: 409, body: JSON.generate(not_ready) } + client, = build_client(responses) + seasons = client.seasons + + refusals.each do |status, code| + update = -> { seasons.update_season("sea_1", expected_revision: 5, inventory_mode: "block") } + expect(update).to raise_error(SeatLayer::APIError) { |error| + expect([error.status, error.code]).to eq([status, code]) + } + end + open_sales = -> { seasons.open_season_sales("sea_1", expected_revision: 5) } + expect(open_sales).to raise_error(SeatLayer::ConflictError) { |error| expect(error.code).to eq("block_not_ready") } + end + + it "sends renewal notices for one offer batch by its batch id" do + client, transport = build_client([{ status: 200, body: '{"scheduled":240,"withoutEmail":3}' }]) + + result = client.seasons.create_season_renewal_notices("sea/a", batch_id: "srb/1") + + expect(result).to eq({ "scheduled" => 240, "withoutEmail" => 3 }) + expect(transport.calls.map { |call| request_summary(call) }).to eq( + [["POST /v1/seasons/sea%2Fa/renewal-notices", { "batchId" => "srb/1" }, false]] + ) + end + + it "returns the job a renewal run or a bring-across of more than 100 starts" do + responses = [ + { status: 202, body: '{"job":{"jobId":"sbj_3","kind":"renewal_run"},"batchId":"srb_1"}' }, + { status: 202, body: '{"job":{"jobId":"sbj_4","kind":"holder_import"}}' }, + { status: 422, body: '{"error":"too_many_offer_ids"}' } + ] + client, transport = build_client(responses) + seasons = client.seasons + + run = seasons.create_season_renewal_offers("sea/a", deadline_at: 123, idempotency_key: "offers-2") + import = seasons.create_season_holder_import_from_season( + "sea/a", source_season_id: "sea_old", successor_plan_activation_id: "pa_2", + idempotency_key: "from-season-2" + ) + expect { seasons.create_season_renewal_notices("sea/a", offer_ids: Array.new(20_001) { |i| "o_#{i}" }) } + .to raise_error(SeatLayer::ValidationError) { |error| expect(error.code).to eq("too_many_offer_ids") } + + expect(run).to eq({ "job" => { "jobId" => "sbj_3", "kind" => "renewal_run" }, "batchId" => "srb_1" }) + expect(import).to eq({ "job" => { "jobId" => "sbj_4", "kind" => "holder_import" } }) + expect(transport.calls.map { |call| call.headers["Idempotency-Key"] }) + .to eq(["offers-2", "from-season-2", nil]) + expect(JSON.parse(transport.calls[2].body)["offerIds"].length).to eq(20_001) + end + + it "returns the Season CSV exports as raw text" do + csv = "holderRef,email\nh_1,ann@example.com\n" + responses = [ + { status: 200, body: csv, headers: { "Content-Type" => "text/csv" } }, + { status: 200, body: "orderId\n", headers: { "Content-Type" => "text/csv" } } + ] + client, transport = build_client(responses) + + expect(client.seasons.export_season_holders_csv("sea/a", search: "ann")).to eq(csv) + expect(client.seasons.export_season_orders_csv("sea/a")).to eq("orderId\n") + expect(transport.calls[0].url).to end_with("/v1/seasons/sea%2Fa/reports/holders.csv?q=ann") + expect(transport.calls.map { |call| call.headers["Accept"] }).to eq(%w[text/csv text/csv]) + end + + it "returns an empty result when a draft package is deleted (204)" do + client, transport = build_client([{ status: 204, body: "" }]) + + expect(client.seasons.delete_season_plan("sea/a", "plan/1")).to eq({}) + expect(transport.calls[0].headers).to have_key("Idempotency-Key") + end + + it "retries header-replay Season writes with one key and never retries unsupported ones" do + responses = [ + { status: 429, headers: { "Retry-After" => "0" } }, { status: 201 }, + { status: 500, body: '{"code":"internal"}' }, { status: 500, body: '{"code":"internal"}' } + ] + client, transport = build_client(responses) + + client.seasons.create_season_performances( + "sea/a", title_pattern: "Hamlet", timezone: "UTC", repeat: "none" + ) + expect(transport.calls.length).to eq(2) + expect(transport.calls[0].headers["Idempotency-Key"]) + .to eq(transport.calls[1].headers["Idempotency-Key"]) + + expect { client.seasons.resend_season_renewal_offer("sea/a", "offer/1") } + .to raise_error(SeatLayer::APIError) + expect { client.seasons.rotate_season_renewal_offer_link("sea/a", "offer/1") } + .to raise_error(SeatLayer::APIError) + expect(transport.calls.length).to eq(4) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 80b635d..547a662 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -46,3 +46,13 @@ def build_client(responses, **options) client = SeatLayer::Client.new("sk_test_abc", transport: transport.to_proc, **options) [client, transport] end + +# One recorded request as [verb and path with query, decoded JSON body or nil, +# whether an Idempotency-Key header was sent], for table-style assertions. +def request_summary(call) + [ + "#{call.http_method} #{call.url.delete_prefix("https://api.seatlayer.io")}", + call.body && JSON.parse(call.body), + call.headers.key?("Idempotency-Key") + ] +end