diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fea0f8..5e66af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,111 @@ # 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": ..., "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. Types: + `SeasonSeatSelection`, `SeasonBlockReleaseScope`. +- `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.create` and `webhooks.update` accept every name in `WebhookEventName`. 0.8.0 refused + every season name with `ValueError`. +- 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: + `SeasonDateLeavePending`, recognised by `is_season_date_leave_pending`. +- New refusals come through `SeatLayerError` 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 `None` 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 with `create_season_amendment(kind="cancel")` voids its tickets as a + `date_cancel_void` job, named by `season_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. `season_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 `SeatLayerValidationError` with code `invalid_rows` and `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. +- A `create_event_series` given up for its key answers `409 create_abandoned` + (`SeatLayerConflictError`, sent once) with a message to send it again with a new idempotency + key. + +- Added 39 Season operations to `seatlayer.seasons` (now 87): performances, package prices, + plan edits and publish, checkout tests, occurrence returns and reclaims, holders, season order + refunds and cancellations, hosted renewals and renewal emails, renewal quotes, intents and + checkouts, sales and seat reports, the holders and orders CSV exports, bulk refunds with + their jobs, and the release of lapsed renewal offers. +- Added `seatlayer.holders` (holders, their tickets and transfers, and `list_customers`), + `seatlayer.orders` (orders across events and seasons, and one event's orders, refunds, + cancellations and CSV export) and `seatlayer.event_series` (create, list, retrieve, add dates, + update). +- Added `holders.create_holder_return`: give back a holder's seat on one date, one per ticket. +- Added an event's sales settings to `seatlayer.events`: checkout choice, payment gateway, sales + readiness, preflight and transitions, booking fee, and discount codes. +- Method names are the snake_case operation ids. 27 of the new mutations use exact header replay + (41 in all); the rest are sent once. CSV exports return text; the two 204 deletes return `None`. +- Added 10 webhook event names: `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`. +- 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`. +- `replace_season_package_prices` documents `priceTypeKey` on each price (`adult`, `child`, + `concession`, `senior`, `student`); `list_season_prices` answers carry `priceTypeKey` and + `priceTypeLabel`. +- `create_season_amendment` takes `kind="postpone"` or `kind="cancel"`, with `refund` and `reason`. +- `update_season` takes `checkout_mode`, `transfer_cutoff_hours`, and `return_cutoff_hours`. +- `create_season_holder_import` documents `holderEmail` and `holderName` on each row. +- `WebhookEventName` lists all 56 public event names, adding `object.resale.*`, + `season.occurrence.*`, `season.performance.*` (including `postponed`, `cancelled`, and + `rescheduled`), `season.bulk_action.completed`, `season.order.*`, `season.ticket.issued`, and + `season.ticket.voided`. +- `events.list` and `events.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`. +- `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: `create_season_amendment(refund=...)` is `"none"` or `"per_date"`, and `refund_season_performance` takes `"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 `buyer.email` can be `None` (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` (last argument): 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. +- `retrieve_season_hosted_renewals` and `list_season_renewal_offers` take `cursor` and `limit` (last arguments; 1-500 and 1-100) and answer one page, newest first, with `nextCursor` (`None` on the last page). A bad cursor or limit answers `400 invalid_cursor` or `400 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 when it completes. Both creates can refuse with `409 job_mismatch` or `503 season_not_configured`; the bring-across also with `503 job_busy`. +- `create_season_renewal_notices` takes up to 20,000 offer ids; more answers `422 too_many_offer_ids`. +- `create_season_renewal_notices` takes `batch_id` (last argument) 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` (last argument; default `False`, Season only; anything else answers `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, the `EventHostingRegion` type, and `default_region` on workspace create and update. Events created without a region run in Western Europe. diff --git a/README.md b/README.md index 61e2fe1..85f2238 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,18 @@ live ones; crossing them returns `403 mode_mismatch`, surfaced as `SeatLayerAuth ## Fixed Renewable Seasons -Version `0.7.0` exposes all 48 trusted organizer operations through -`seatlayer.seasons`. +`seatlayer.seasons` exposes all 87 trusted organizer operations: catalogue and packages, +performances, package prices (per price type: `adult`, `child`, `concession`, `senior`, +`student`), sales, checkout tests, buyer access and booking, holder imports, holders, season +orders, renewals, hosted renewals and renewal checkouts, occurrences (postpone and cancel a date), bulk refunds of a +cancelled date, the release of lapsed renewal offers and their jobs, reports, CSV exports, audit, outbox, and support export. 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 run your own checkout, +your platform owns package price, payment, order, tax, refunds, benefits, and +ticket or pass delivery. ```python checked = seatlayer.seasons.validate_season( @@ -112,7 +116,7 @@ activation = seatlayer.seasons.activate_season(draft["key"], draft["revision"]) Treat `202` as accepted work: retain the returned `lifecycleOperation.operationId` and poll `retrieve_season_lifecycle`. Buyer sessions are show-once and never retried invisibly. Booking, cancellation, and renewal actions require caller-stable -identities. Only header-replay catalogue mutations retry automatically. +identities. Only header-replay mutations retry automatically. ```python seatlayer = SeatLayer(os.environ["SEATLAYER_SECRET_KEY"]) @@ -120,6 +124,102 @@ if os.environ.get("ENV") == "production" and seatlayer.mode != "live": raise RuntimeError("Refusing to boot production against test-mode seating data.") ``` +When SeatLayer takes the season's payments, renew a holder with no hosted page: read the price +with `retrieve_season_renewal_quote`, then start a checkout and send the holder to +`payment["url"]`. For a renewal taken by phone or at the box office, record the holder's intent +with `create_season_renewal_intent` before you commit the offer. A decline can carry the holder's +reason. + +```python +quote = seatlayer.seasons.retrieve_season_renewal_quote(season_key, offer_id) +if quote["problem"] is None: + started = seatlayer.seasons.create_season_renewal_checkout( + season_key, + offer_id, + "https://tickets.yourclub.example/renewed", + idempotency_key=f"renew-{offer_id}", + ) + redirect(started["payment"]["url"]) + +seatlayer.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. + +```python +seatlayer.seasons.update_season(season_key, expected_revision=revision, inventory_mode="block") + +job = seatlayer.seasons.set_aside_season_block( + season_key, + expected_revision=revision, + keep_for_single_tickets={"sectionIds": ["sec_family"]}, +)["job"] +while job["state"] in ("planning", "running"): + time.sleep(2) + job = seatlayer.seasons.retrieve_season_bulk_job(season_key, job["jobId"])["job"] +block = seatlayer.seasons.retrieve_season_block(season_key)["block"] + +# Release unsold season seats to single sale. Leave out `at` to release now. +seatlayer.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.is_season_date_leave_pending(answer)`, then send + the same request again after `Retry-After`. +- Refusals come through `SeatLayerError` with `code`, and nothing changes. For example + `409 season_block_packages` (more than one package), `409 season_block_on_sale` (sales have + opened), or `409 block_not_ready` from `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. + +## Holders, orders, sales settings and event series + +Three more resources sit next to `seasons`: `holders` (season ticket holders, their tickets and +transfers, and `list_customers`), `orders` (orders SeatLayer took through its own checkout, across +events and seasons, and one event's orders), and `event_series` (runs of dated events made from one +schedule). An event's sales settings (checkout choice, payment gateway, sales readiness and +transitions, booking fee, discount codes) are on `events`. Method names are the snake_case +operation ids, as on `seasons`. + +```python +holders = seatlayer.seasons.list_season_holders("sea_2027", status="active", limit=50) +tickets = seatlayer.holders.list_holder_tickets("holder_123", season_key="sea_2027") + +orders = seatlayer.orders.list_orders(season_key="sea_2027", status="confirmed") +seatlayer.orders.refund_event_order("ev_1", "ord_1", idempotency_key="refund-ord_1") +csv_text = seatlayer.orders.export_event_orders_csv("ev_1") # the CSV as a str + +series = seatlayer.event_series.create_event_series( + "Hamlet", + "chart_1", + {"rule": "weekly", "from": "2026-11-03", "count": 6, "times": ["19:30"]}, + timezone="Europe/London", + dry_run=True, # answer the dates, create nothing +) +``` + +The three CSV exports (`seasons.export_season_holders_csv`, `seasons.export_season_orders_csv`, +`orders.export_event_orders_csv`) return the CSV text. `seasons.delete_season_plan` and +`events.delete_event_discount_code` return `None`. If you run your own checkout, your store stays +the order record; these order operations cover sales SeatLayer took. + ## Book reserved seats from Python **Buyer picks seats in the browser.** Your frontend holds them; your backend confirms the price and @@ -192,6 +292,8 @@ exactly when you don't want that, and you can control it explicitly: seatlayer.events.list(limit=50, counts=False) ``` +To list only the dates of one event series, pass `series_key="ser_1"` to `list()` or `list_all()`. + ## Keeping a hold alive When an order takes longer than the checkout window (an invoice, a phone sale), extend rather than @@ -266,7 +368,10 @@ For safe-mode embeds, pass `mode="safe"` with `safe_mode_options`; feature polic Subscription responses use the API envelopes exactly: `list()` returns `{"subs": [...]}`, `create()` returns `{"sub": ..., "secret": ...}` (the secret is shown once), and `update()` returns `{"sub": ...}`. Supported event names are `seat.booked`, `seat.released`, `seat.blocked`, -`hold.expired`, `hold.created`, `hold.extended`, `event.created`, and `event.soldout`. +`hold.expired`, `hold.created`, `hold.extended`, `event.created`, `event.soldout`, +`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`. Verify every delivery against the **raw** body. Re-serialising it changes the bytes and verification will fail. @@ -320,14 +425,24 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Fourteen +**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Forty-one mutations opt into 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`. The SDK generates one `Idempotency-Key` and reuses it for every -attempt. All remaining SDK mutations are sent once; some have a server-side domain idempotency +`performance_groups.create`; on `seasons`, `create_season`, `update_season`, `delete_season`, +`create_season_plan`, `update_season_plan`, `delete_season_plan`, `publish_season`, +`replace_season_package_prices`, `attach_season_plan_performance`, +`delete_season_plan_performance`, `create_season_performances`, `attach_season_performances`, +`delete_season_performance`, `mark_season_performance_past`, `create_season_checkout_test`, +`duplicate_season_to_live`, `create_season_holder_import`, +`create_season_holder_import_from_season`, `create_season_renewal_offers`, +`create_season_amendment`, `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`; and +`event_series.create_event_series`, `event_series.add_event_series_dates`, and +`event_series.update_event_series`. The SDK generates one `Idempotency-Key` (or uses yours) and +reuses it for every attempt. All remaining SDK mutations are sent once; some have a server-side domain idempotency contract, but the SDK does not retry them automatically. **Booking safety.** Direct and box-office bookings have the server's exact-selection plus @@ -338,7 +453,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the 14 exact-header-replay mutations + max_retries=3, # attempts for reads and the 41 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -355,21 +470,24 @@ seatlayer.request("POST", "/v1/events/ev_1/some-new-route", body={...}) ## API surface The client exposes these resources. Performance Groups cover runs, sessions, holds, and bookings; -Seasons cover catalogue, plan, sales, buyer-session, booking, renewal, occurrence, reporting, -outbox, and support operations. +Seasons cover catalogue, plan, performance, price, sales, checkout-test, buyer-session, booking, +holder, order, renewal, occurrence, reporting, export, outbox, and support operations. | Resource | Methods | | --- | --- | | `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` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_hold_ttl` `update_hold_ttl` `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` `list_ticket_releases` `update_ticket_releases` `close_ticket_release` `retrieve_hold_ttl` `update_hold_ttl` `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, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals, hosted renewals and renewal checkouts, occurrence amendments, returns and reclaims, bulk refunds, lapsed offer release and jobs, reports, 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 server API events](https://docs.seatlayer.io/server-api/events/) @@ -377,11 +495,8 @@ Full reference: [SeatLayer server API events](https://docs.seatlayer.io/server-a 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 payment gateways.** Connecting one is a dashboard flow. Once a gateway 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()`. diff --git a/pyproject.toml b/pyproject.toml index 6822cec..5df23a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" [project] name = "seatlayer" -version = "0.8.0" +version = "0.9.0" description = "Official Python server SDK for the SeatLayer reserved-seating API: seating charts, seat maps, events, holds, seat booking and webhooks." readme = "README.md" license = "MIT" diff --git a/src/seatlayer/__init__.py b/src/seatlayer/__init__.py index 8ad2736..aee24ec 100644 --- a/src/seatlayer/__init__.py +++ b/src/seatlayer/__init__.py @@ -13,6 +13,11 @@ SeatLayerRateLimitError, SeatLayerValidationError, ) +from .resources import ( + is_season_date_leave_pending, + season_date_cancel_void_job_id, + season_holder_import_rows_per_call, +) from .types import ( AccessLink, AccessLinkList, @@ -34,6 +39,9 @@ InventoryItem, ManageCapability, ManageSession, + SeasonBlockReleaseScope, + SeasonDateLeavePending, + SeasonSeatSelection, TemplateInstantiateRequest, TicketRelease, TicketReleaseAction, @@ -49,7 +57,7 @@ ) from .webhooks import WebhookVerificationError, verify_webhook -__version__ = "0.8.0" +__version__ = "0.9.0" __all__ = [ "AccessLink", @@ -72,6 +80,9 @@ "InventoryItem", "ManageCapability", "ManageSession", + "SeasonBlockReleaseScope", + "SeasonDateLeavePending", + "SeasonSeatSelection", "SeatLayer", "SeatLayerAuthError", "SeatLayerConflictError", @@ -94,5 +105,8 @@ "WebhookSubscription", "WebhookVerificationError", "__version__", + "is_season_date_leave_pending", + "season_date_cancel_void_job_id", + "season_holder_import_rows_per_call", "verify_webhook", ] diff --git a/src/seatlayer/client.py b/src/seatlayer/client.py index b202fa9..253dd87 100644 --- a/src/seatlayer/client.py +++ b/src/seatlayer/client.py @@ -14,7 +14,10 @@ Channels, Charts, Events, + EventSeries, + Holders, Inventory, + Orders, PerformanceGroups, Seasons, Sessions, @@ -49,6 +52,9 @@ def __init__( self.inventory = Inventory(self._http) self.performance_groups = PerformanceGroups(self._http) self.seasons = Seasons(self._http) + self.holders = Holders(self._http) + self.orders = Orders(self._http) + self.event_series = EventSeries(self._http) self.channels = Channels(self._http) self.sessions = Sessions(self._http) self.webhooks = Webhooks(self._http) diff --git a/src/seatlayer/http.py b/src/seatlayer/http.py index 2a8ca9a..fb3e1d1 100644 --- a/src/seatlayer/http.py +++ b/src/seatlayer/http.py @@ -160,6 +160,29 @@ def mutation_with_header_replay( header_replay=True, ) + def get_text( + self, + path: str, + query: dict[str, Any] | None = None, + accept: str = "text/csv", + ) -> str: + """Internal typed-operation path for reads whose success body is text, such as CSV. + + Errors still arrive as JSON and map to the usual error classes. + """ + text: str = self._request( + "GET", + path, + query=query, + body=None, + raw_body=None, + content_type=None, + idempotency_key=None, + header_replay=False, + text_accept=accept, + ) + return text + def _request( self, method: str, @@ -170,6 +193,7 @@ def _request( content_type: str | None, idempotency_key: str | None, header_replay: bool, + text_accept: str | None = None, ) -> Any: method = method.upper() url = self.base_url + path @@ -180,7 +204,8 @@ def _request( headers = { "Authorization": f"Bearer {self._secret_key}", - "Accept": "application/json", + # A text read still accepts JSON: that is how its errors arrive. + "Accept": f"{text_accept}, application/json" if text_accept else "application/json", "User-Agent": USER_AGENT, } if body is not None and raw_body is not None: @@ -209,6 +234,8 @@ def _request( try: with self._transport(request, self._timeout) as response: raw = response.read() + if text_accept is not None: + return raw.decode("utf-8") if response.status == 204 or not raw: return None return json.loads(raw) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 6403704..d7c2c3d 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -6,8 +6,8 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence -from typing import Any, Literal, cast +from collections.abc import Iterable, Iterator, Sequence +from typing import Any, Literal, cast, get_args from .http import HttpClient, quote from .types import ( @@ -22,6 +22,8 @@ HoldInspection, ManageCapability, ManageSession, + SeasonBlockReleaseScope, + SeasonSeatSelection, TemplateInstantiateRequest, TicketReleaseList, TicketReleaseReplaceInput, @@ -38,6 +40,17 @@ "oceania", "africa", "middle-east", ] +SeasonHolderStatus = Literal["active", "cancelled", "renewal"] +OrderStatus = Literal[ + "pending", "authorized", "inventory_committed", "confirmed", "compensating", + "failed", "expired", "refunded", "partially_refunded", +] +EventOrderStatus = Literal[ + "pending", "authorized", "inventory_committed", "confirmed", "compensating", + "failed", "expired", "refunded", "partially_refunded", "partially_cancelled", +] +OrderCancelReason = Literal["lost_dispute", "refunded_elsewhere", "other"] + class _Unset: """Distinguish an omitted nullable request field from explicit JSON null.""" @@ -46,6 +59,52 @@ class _Unset: _UNSET = _Unset() +def _fields( + fields: Iterable[tuple[str, Any]], + nullable: Iterable[tuple[str, Any]] = (), +) -> dict[str, Any]: + """A request body from optional fields. + + ``fields`` are left out when ``None``. ``nullable`` fields are left out only + when unset, so an explicit ``None`` is sent as JSON null. + """ + body = {key: value for key, value in fields if value is not None} + body.update((key, value) for key, value in nullable if value is not _UNSET) + return body + + +def _renewal_checkout_body( + return_url: str, + buyer_email: str | None, + buyer_name: str | None, + provider: str | None, +) -> dict[str, Any]: + body: dict[str, Any] = {"returnUrl": return_url} + body.update(_fields(( + ("buyerEmail", buyer_email), + ("buyerName", buyer_name), + ("provider", provider), + ))) + return body + + +def _order_cancel_body( + reason: OrderCancelReason, + note: str | None, + refund_confirmed: bool | None, + used_tickets_acknowledged: bool | None, + notify_buyer: bool | None, +) -> dict[str, Any]: + body = _fields(( + ("note", note), + ("refundConfirmed", refund_confirmed), + ("usedTicketsAcknowledged", used_tickets_acknowledged), + ("notifyBuyer", notify_buyer), + )) + body["reason"] = reason + return body + + class Charts: """Seat-map definitions that events are created from. @@ -242,19 +301,21 @@ def list( limit: int | None = None, cursor: str | None = None, counts: bool = True, + series_key: str | None = None, ) -> Any: """One page of events. Live availability ``counts`` cost one round-trip per event server-side. They are on by default because most callers want them; pass ``counts=False`` when paging a whole catalogue, where you almost - certainly do not. + certainly do not. ``series_key`` lists only the dates of one event series. """ query: dict[str, Any] = { "workspaceId": workspace_id, "externalRef": external_ref, "limit": limit, "cursor": cursor, + "seriesKey": series_key, } if not counts: query["counts"] = "0" @@ -266,6 +327,7 @@ def list_all( external_ref: str | None = None, limit: int | None = None, counts: bool = False, + series_key: str | None = None, ) -> Iterator[dict[str, Any]]: """Every event, paging transparently. @@ -274,7 +336,7 @@ def list_all( """ cursor: str | None = None while True: - page = self.list(workspace_id, external_ref, limit, cursor, counts) + page = self.list(workspace_id, external_ref, limit, cursor, counts, series_key) yield from page.get("events", []) cursor = page.get("nextCursor") if not cursor: @@ -399,6 +461,10 @@ def close(self, event_key: str) -> Any: return self._http.post(f"/v1/events/{quote(event_key)}/close") def reopen(self, event_key: str) -> Any: + """Open a paused event's sales again. + + A date its season cancelled never opens again: ``409 season_date_cancelled``. + """ return self._http.post(f"/v1/events/{quote(event_key)}/reopen") def archive(self, event_key: str) -> Any: @@ -457,6 +523,162 @@ def retrieve_log( ), ) + # Sales settings. Method names here keep the full operation id + # (``retrieve_event_buyer_checkout``), as the Season methods do. + + def retrieve_event_buyer_checkout(self, event_key: str) -> Any: + """Retrieve who takes payment for an event, and what blocks a change.""" + return self._http.get(f"/v1/events/{quote(event_key)}/buyer-checkout") + + def replace_event_buyer_checkout( + self, + event_key: str, + buyer_checkout: Literal["seatlayer", "integrator"], + expected_revision: int, + ) -> Any: + """Choose SeatLayer checkout or your own for an event. Sent once.""" + return self._http.put( + f"/v1/events/{quote(event_key)}/buyer-checkout", + body={"buyerCheckout": buyer_checkout, "expectedRevision": expected_revision}, + ) + + def retrieve_event_payment_gateway( + self, event_key: str, currency: str | None = None + ) -> Any: + """Retrieve an event's payment gateway and the ones it could use. + + ``currency`` checks the options against that currency instead of the event's. + """ + return self._http.get( + f"/v1/events/{quote(event_key)}/payment-gateway", + query={"currency": currency}, + ) + + def replace_event_payment_gateway( + self, + event_key: str, + connection_id: str | None | _Unset = _UNSET, + provider: str | None | _Unset = _UNSET, + ) -> Any: + """Assign one of the organization's connected gateways to an event. + + Connecting a gateway stays a dashboard flow. Sent once, never retried + automatically. + """ + body = _fields((), (("connectionId", connection_id), ("provider", provider))) + return self._http.put( + f"/v1/events/{quote(event_key)}/payment-gateway", body=body or None + ) + + def retrieve_event_sales_readiness(self, event_key: str) -> Any: + """Retrieve what an event still needs before its next sales step.""" + return self._http.get(f"/v1/events/{quote(event_key)}/sales/readiness") + + def preflight_event_sales(self, event_key: str, order_id: str) -> Any: + """Record a test order as the event's sales preflight. Sent once.""" + return self._http.post( + f"/v1/events/{quote(event_key)}/sales/preflight", body={"orderId": order_id} + ) + + def transition_event_sales( + self, + event_key: str, + to: Literal["setup", "testable", "live", "paused", "ended"], + expected_revision: int, + reason: str | None = None, + ) -> Any: + """Move an event's sales to another state, such as live or paused. Sent once. + + A date its season cancelled never opens again: moving it to ``testable`` + or ``live`` answers ``409 season_date_cancelled``. + """ + body: dict[str, Any] = {"to": to, "expectedRevision": expected_revision} + if reason is not None: + body["reason"] = reason + return self._http.post( + f"/v1/events/{quote(event_key)}/sales/transition", body=body + ) + + def retrieve_event_booking_fee(self, event_key: str) -> Any: + """Retrieve an event's booking fee.""" + return self._http.get(f"/v1/events/{quote(event_key)}/booking-fee") + + def replace_event_booking_fee(self, event_key: str, booking_fee_minor: int | None) -> Any: + """Set an event's booking fee in minor units, or pass ``None`` to clear it.""" + return self._http.put( + f"/v1/events/{quote(event_key)}/booking-fee", + body={"bookingFeeMinor": booking_fee_minor}, + ) + + def list_event_discount_codes(self, event_key: str) -> Any: + """List an event's discount codes.""" + return self._http.get(f"/v1/events/{quote(event_key)}/discount-codes") + + def create_event_discount_code( + self, + event_key: str, + code: str, + kind: Literal["percent", "amount"], + percent: int | None = None, + amount_minor: int | None = None, + starts_at: int | None | _Unset = _UNSET, + ends_at: int | None | _Unset = _UNSET, + max_uses: int | None | _Unset = _UNSET, + active: bool | None = None, + ) -> Any: + """Create a discount code for an event. + + Give ``percent`` for a ``"percent"`` code and ``amount_minor`` for an + ``"amount"`` code. Times are epoch milliseconds. Sent once, never retried + automatically. + """ + body = _fields( + (("percent", percent), ("amountMinor", amount_minor), ("active", active)), + (("startsAt", starts_at), ("endsAt", ends_at), ("maxUses", max_uses)), + ) + body.update({"code": code, "kind": kind}) + return self._http.post(f"/v1/events/{quote(event_key)}/discount-codes", body=body) + + def update_event_discount_code( + self, + event_key: str, + code_id: str, + code: str | None = None, + kind: Literal["percent", "amount"] | None = None, + percent: int | None = None, + amount_minor: int | None = None, + starts_at: int | None | _Unset = _UNSET, + ends_at: int | None | _Unset = _UNSET, + max_uses: int | None | _Unset = _UNSET, + active: bool | None = None, + state: Literal["active", "paused"] | None = None, + ) -> Any: + """Change or pause a discount code. Sent once, never retried automatically.""" + body = _fields( + ( + ("code", code), + ("kind", kind), + ("percent", percent), + ("amountMinor", amount_minor), + ("active", active), + ("state", state), + ), + (("startsAt", starts_at), ("endsAt", ends_at), ("maxUses", max_uses)), + ) + return self._http.patch( + f"/v1/events/{quote(event_key)}/discount-codes/{quote(code_id)}", body=body + ) + + def end_event_discount_code(self, event_key: str, code_id: str) -> Any: + """End a discount code for good.""" + return self._http.post( + f"/v1/events/{quote(event_key)}/discount-codes/{quote(code_id)}/end" + ) + + def delete_event_discount_code(self, event_key: str, code_id: str) -> None: + """Delete a discount code nobody has used.""" + self._http.delete(f"/v1/events/{quote(event_key)}/discount-codes/{quote(code_id)}") + class Inventory: """Holds, booking, blocking, availability. @@ -1083,16 +1305,9 @@ def revoke_designer_session(self, session_id: str) -> Any: class Webhooks: - _EVENT_NAMES = frozenset({ - "seat.booked", - "seat.released", - "seat.blocked", - "hold.expired", - "hold.created", - "hold.extended", - "event.created", - "event.soldout", - }) + # Every name the API delivers (`WebhookEventName`), so a new name is added in one place. + # 0.9.0 listed 18 by hand and refused every season name but the 3 transfer ones. + _EVENT_NAMES = frozenset(get_args(WebhookEventName)) def __init__(self, http: HttpClient) -> None: self._http = http @@ -1347,6 +1562,41 @@ def retrieve_booking(self, performance_group_key: str, action_id: str) -> Any: ) +def season_date_cancel_void_job_id(amendment_id: str) -> str: + """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 + :meth:`Seasons.retrieve_season_bulk_job`. Each season ticket it voids is told by + ``season.ticket.voided`` with reason ``date_cancelled``. + """ + return f"sbj_void_{amendment_id}" + + +def season_holder_import_rows_per_call(dates: int) -> int: + """The most rows one ``create_season_holder_import`` call takes for a package of ``dates``. + + Each row is fenced on every date: 100 up to 20 dates, then ``2300 // (dates + 3)``, 43 at + 50. A call with more answers ``422 invalid_rows`` with ``maxRows``. + """ + return max(1, min(100, 2300 // (max(1, dates) + 3))) + + +def is_season_date_leave_pending(value: object) -> bool: + """True for the ``202`` a season-seats season's date cancel or past date answers while it finishes. + + :meth:`Seasons.create_season_amendment` (``cancel``) and + :meth:`Seasons.mark_season_performance_past` answer it (a + :class:`~seatlayer.types.SeasonDateLeavePending`) while the date is still leaving + every season seat. It finishes by itself: send the same request again after + ``Retry-After`` to read how it ended. + """ + return ( + isinstance(value, dict) + and value.get("state") == "finishing" + and value.get("code") == "block_date_leave_pending" + ) + + class Seasons: """Fixed Renewable Season organizer operations for trusted backends. @@ -1406,11 +1656,28 @@ def create_season( source_performance_group_keys: Sequence[str] | None = None, edition: str | None | _Unset = _UNSET, idempotency_key: str | None = None, + single_tickets: bool | None = None, + inventory_mode: Literal["block", "per_hold"] | None = None, ) -> Any: + """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``. Anything else answers ``422 invalid_inventory_mode``. + Every Season answer carries ``inventoryMode``: how the season's seats are + decided, ``block`` once season seats are turned on for it + (``inventoryMode: "block"`` on create or update) or it sells from its block, + else ``per_hold``. + """ body = self._selection(event_keys, source_performance_group_keys) body["name"] = name if edition is not _UNSET: body["edition"] = edition + if single_tickets is not None: + body["singleTickets"] = single_tickets + if inventory_mode is not None: + body["inventoryMode"] = inventory_mode return self._http.post_with_header_replay( "/v1/seasons", body=body, idempotency_key=idempotency_key ) @@ -1425,12 +1692,53 @@ def update_season( name: str | None = None, edition: str | None | _Unset = _UNSET, idempotency_key: str | None = None, + checkout_mode: Literal["seatlayer", "server"] | None = None, + transfer_cutoff_hours: int | None | _Unset = _UNSET, + return_cutoff_hours: int | None | _Unset = _UNSET, + audience: Literal["embed_only", "public"] | None = None, + single_tickets: bool | None = None, + inventory_mode: Literal["block", "per_hold"] | None = None, ) -> Any: + """Rename a draft, or set how the Season sells. + + ``checkout_mode`` says who takes the money; it cannot go back to + undecided and is fixed once a live booking exists. The cutoffs are hours + before a date starts (0 to 720) after which its tickets can no longer be + sent to others, or a seat given back; ``None`` means the club default + (Settings › Ticket site), or until it starts when the club has none. + ``audience="public"`` lists the Season on your SeatLayer website; an + ended Season cannot become public (``409 sales_ended``). + ``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; + its ``jobRefused``, when present, says why the ``season_singles`` job could not + start. The change still stands, and sending ``single_tickets=True`` again opens + any date still not open. An exact 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``. + """ body: dict[str, Any] = {"expectedRevision": expected_revision} if name is not None: body["name"] = name if edition is not _UNSET: body["edition"] = edition + body.update(_fields( + ( + ("checkoutMode", checkout_mode), + ("audience", audience), + ("singleTickets", single_tickets), + ("inventoryMode", inventory_mode), + ), + ( + ("transferCutoffHours", transfer_cutoff_hours), + ("returnCutoffHours", return_cutoff_hours), + ), + )) return self._http.mutation_with_header_replay( "PATCH", self._path(season_key), body=body, idempotency_key=idempotency_key ) @@ -1500,6 +1808,15 @@ def _sales(self, season_key: str, action: str, expected_revision: int) -> Any: ) def open_season_sales(self, season_key: str, expected_revision: int) -> Any: + """Open sales. Answers ``{"season": ..., "warnings": [...]}``. + + ``warnings`` says what is advised but not required, such as + ``checkout_test_missing`` until a checkout test or rehearsal has passed. + Refused with ``409 plan_not_published`` or ``409 checkout_mode_not_chosen`` + until a package is published and a checkout mode is chosen, and with + ``409 block_not_ready`` when season seats are turned on + (``inventory_mode="block"``) but the seats are not set aside yet. + """ return self._sales(season_key, "open", expected_revision) def pause_season_sales(self, season_key: str, expected_revision: int) -> Any: @@ -1615,6 +1932,15 @@ def create_season_holder_import( dry_run: bool | None = None, idempotency_key: str | None = None, ) -> Any: + """Import incumbent holders. + + Each row is ``{"rowId", "holderRef", "priorPlanActivationId", + "priorContractRef", "labels"}`` plus optional ``existingBookingRef``, + ``holderEmail`` and ``holderName``. A call takes at most + :func:`season_holder_import_rows_per_call` rows for the package's dates (100 + up to 20 dates); more raises :class:`SeatLayerValidationError` with code + ``invalid_rows`` and ``maxRows`` in its ``body``. + """ body: dict[str, Any] = { "successorPlanActivationId": successor_plan_activation_id, "rows": list(rows), @@ -1638,6 +1964,15 @@ def create_season_renewal_offers( contract_ids: Sequence[str] | None = None, idempotency_key: str | None = None, ) -> Any: + """Offer each eligible prior Contract its renewal. + + For 100 contracts or fewer this answers 201 ``{"offers": [...]}``. Above 100 + it answers 202 ``{"job": ..., "batchId": ...}``: a ``renewal_run`` job makes + the offers. Follow it with :meth:`retrieve_season_bulk_job`, then page the + offers with :meth:`list_season_renewal_offers`. Retried safely with one + Idempotency-Key, which answers the same job. Can refuse with + ``409 job_mismatch`` or ``503 season_not_configured``. + """ body: dict[str, Any] = {"deadlineAt": deadline_at} if successor_plan_activation_id is not None: body["successorPlanActivationId"] = successor_plan_activation_id @@ -1649,8 +1984,19 @@ def create_season_renewal_offers( idempotency_key=idempotency_key, ) - def list_season_renewal_offers(self, season_key: str) -> Any: - return self._http.get(self._path(season_key, "/renewal-offers")) + def list_season_renewal_offers( + self, season_key: str, cursor: str | None = None, limit: int | None = None + ) -> Any: + """One page of renewal offers, newest first. + + Pass ``cursor`` from the previous page's ``nextCursor`` (``None`` on the last + page). ``limit`` is 1-100 and defaults to 100. A bad cursor or limit answers + ``400 invalid_cursor`` or ``400 invalid_limit``. + """ + return self._http.get( + self._path(season_key, "/renewal-offers"), + query={"cursor": cursor, "limit": limit}, + ) def retrieve_season_renewal_offer(self, season_key: str, offer_id: str) -> Any: return self._http.get(self._path(season_key, f"/renewal-offers/{quote(offer_id)}")) @@ -1687,9 +2033,17 @@ def commit_season_renewal_offer( }, ) - def decline_season_renewal_offer(self, season_key: str, offer_id: str) -> Any: + def decline_season_renewal_offer( + self, season_key: str, offer_id: str, reason: str | None = None + ) -> Any: + """Decline an 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. + """ return self._http.post( - self._path(season_key, f"/renewal-offers/{quote(offer_id)}/decline"), body={} + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/decline"), + body=_fields((("reason", reason),)), ) def release_season_renewal_offer(self, season_key: str, offer_id: str) -> Any: @@ -1704,16 +2058,38 @@ def create_season_amendment( self, season_key: str, event_key: str, - kind: Literal["reschedule", "replace", "cancel_exception"], + kind: Literal["reschedule", "replace", "cancel_exception", "postpone", "cancel"], starts_at: int | None = None, name: str | None = None, idempotency_key: str | None = None, + refund: Literal["none", "per_date"] | None = None, + reason: str | None = None, ) -> Any: + """Amend one date of a published Season. + + ``postpone`` keeps the date with no new time yet; ``cancel`` takes it + off sale for good. With ``cancel`` only, ``refund="per_date"`` refunds + each order's share of the date as a job (the answer's ``refundJob``), + and ``reason`` is kept on the job. A whole-order refund is the order + refund. A ``cancel`` also voids every live ticket for the date as a + ``date_cancel_void`` job, which the answer does not name: + ``season_date_cancel_void_job_id(answer["amendment"]["amendmentId"])``. + Follow it with :meth:`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`` + :class:`~seatlayer.types.SeasonDateLeavePending` (check with + :func:`~seatlayer.is_season_date_leave_pending`); send the same request + again to read the amendment. + """ body: dict[str, Any] = {"eventKey": event_key, "kind": kind} if starts_at is not None: body["startsAt"] = starts_at if name is not None: body["name"] = name + body.update(_fields((("refund", refund), ("reason", reason)))) return self._http.post_with_header_replay( self._path(season_key, "/amendments"), body=body, @@ -1758,3 +2134,1065 @@ def list_season_audit(self, season_key: str) -> Any: def export_season_support_snapshot(self, season_key: str) -> Any: return self._http.get(self._path(season_key, "/export")) + + def create_season_holder_import_from_season( + self, + season_key: str, + source_season_id: str, + successor_plan_activation_id: str, + dry_run: bool | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Dry-run or commit renewal rights taken from a previous season's holders. + + A dry run answers 200 and a commit of 100 holders or fewer answers 201, + both as ``{"import": ...}``. A commit of more than 100 holders answers 202 + ``{"job": ...}``: a ``holder_import`` job brings them across. Follow it with + :meth:`retrieve_season_bulk_job`; its ``summary`` has the same counts when it + completes. Retried safely with one Idempotency-Key, which answers the same + job. Can refuse with ``409 job_mismatch``, ``503 job_busy`` or + ``503 season_not_configured``. + """ + body = _fields((("dryRun", dry_run),)) + body["sourceSeasonId"] = source_season_id + body["successorPlanActivationId"] = successor_plan_activation_id + return self._http.post_with_header_replay( + self._path(season_key, "/imports/from-season"), + body=body, + idempotency_key=idempotency_key, + ) + + def list_season_performances(self, season_key: str) -> Any: + """List the Season's performances and the packages each one is in.""" + return self._http.get(self._path(season_key, "/performances")) + + def create_season_performances( + self, + season_key: str, + title_pattern: str, + timezone: str, + repeat: Literal["none", "daily", "weekly", "biweekly", "dates"], + first_starts_at: float | None = None, + count: int | None = None, + dates: Sequence[float] | None = None, + venue: str | None | _Unset = _UNSET, + duration_minutes: int | None | _Unset = _UNSET, + region: EventHostingRegion | None = None, + attach_to_plan_keys: Sequence[str] | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Create performances inside a draft Season from a repeat rule. + + ``first_starts_at`` and ``dates`` are epoch milliseconds. ``repeat="dates"`` + takes the moments in ``dates``; the other rules step from ``first_starts_at``. + """ + body = _fields( + ( + ("firstStartsAt", first_starts_at), + ("count", count), + ("dates", None if dates is None else list(dates)), + ("region", region), + ( + "attachToPlanKeys", + None if attach_to_plan_keys is None else list(attach_to_plan_keys), + ), + ), + (("venue", venue), ("durationMinutes", duration_minutes)), + ) + body.update({"titlePattern": title_pattern, "timezone": timezone, "repeat": repeat}) + return self._http.post_with_header_replay( + self._path(season_key, "/performances"), + body=body, + idempotency_key=idempotency_key, + ) + + def attach_season_performances( + self, + season_key: str, + event_keys: Sequence[str], + attach_to_plan_keys: Sequence[str] | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Attach existing Events to a draft Season and, optionally, to its packages.""" + body: dict[str, Any] = {"eventKeys": list(event_keys)} + if attach_to_plan_keys is not None: + body["attachToPlanKeys"] = list(attach_to_plan_keys) + return self._http.post_with_header_replay( + self._path(season_key, "/performances/attach"), + body=body, + idempotency_key=idempotency_key, + ) + + def delete_season_performance( + self, season_key: str, event_key: str, idempotency_key: str | None = None + ) -> Any: + """Remove a performance from a draft Season and from every package.""" + return self._http.mutation_with_header_replay( + "DELETE", + self._path(season_key, f"/performances/{quote(event_key)}"), + idempotency_key=idempotency_key, + ) + + def update_season_plan( + self, + season_key: str, + plan_key: str, + name: str | None = None, + event_keys: Sequence[str] | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Rename a draft package or change the performances it covers.""" + body = _fields(( + ("name", name), + ("eventKeys", None if event_keys is None else list(event_keys)), + )) + return self._http.mutation_with_header_replay( + "PATCH", + self._path(season_key, f"/plans/{quote(plan_key)}"), + body=body, + idempotency_key=idempotency_key, + ) + + def delete_season_plan( + self, season_key: str, plan_key: str, idempotency_key: str | None = None + ) -> None: + """Delete a draft package. The last package of a Season cannot be deleted.""" + self._http.mutation_with_header_replay( + "DELETE", + self._path(season_key, f"/plans/{quote(plan_key)}"), + idempotency_key=idempotency_key, + ) + + def publish_season(self, season_key: str, idempotency_key: str | None = None) -> Any: + """Activate the Season and publish every draft package in one retryable call.""" + return self._http.post_with_header_replay( + self._path(season_key, "/publish"), idempotency_key=idempotency_key + ) + + def create_season_checkout_test( + self, + season_key: str, + plan_key: str | None = None, + quantity: int | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Hold and release seats server-side to prove the Season buyer path works.""" + body = _fields((("planKey", plan_key), ("quantity", quantity))) + return self._http.post_with_header_replay( + self._path(season_key, "/checkout-tests"), + body=body or None, + idempotency_key=idempotency_key, + ) + + def list_season_checkout_tests(self, season_key: str) -> Any: + """List recent Season checkout tests, newest first.""" + return self._http.get(self._path(season_key, "/checkout-tests")) + + def replace_season_package_prices( + self, + season_key: str, + plan_key: str, + prices: Sequence[dict[str, Any]], + currency: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Replace a package's price table. + + Each price is ``{"categoryKey": ..., "unitPrice": ...}`` with the unit + price in minor units, plus an optional ``"priceTypeKey"``: ``adult`` + (the default), ``child``, ``concession``, ``senior`` or ``student``. + Each category takes each type once. + """ + body: dict[str, Any] = {"prices": list(prices)} + if currency is not None: + body["currency"] = currency + return self._http.mutation_with_header_replay( + "PUT", + self._path(season_key, f"/plans/{quote(plan_key)}/prices"), + body=body, + idempotency_key=idempotency_key, + ) + + def list_season_prices(self, season_key: str) -> Any: + """List every package price table of the Season. + + Each price carries its ``priceTypeKey`` and ``priceTypeLabel``. + """ + return self._http.get(self._path(season_key, "/prices")) + + def attach_season_plan_performance( + self, + season_key: str, + plan_key: str, + event_key: str, + idempotency_key: str | None = None, + ) -> Any: + """Add a performance to a published package and extend its allocations.""" + return self._http.post_with_header_replay( + self._path(season_key, f"/plans/{quote(plan_key)}/performances"), + body={"eventKey": event_key}, + idempotency_key=idempotency_key, + ) + + def delete_season_plan_performance( + self, + season_key: str, + plan_key: str, + event_key: str, + idempotency_key: str | None = None, + ) -> Any: + """Remove a performance from a published package and release its allocations.""" + return self._http.mutation_with_header_replay( + "DELETE", + self._path( + season_key, f"/plans/{quote(plan_key)}/performances/{quote(event_key)}" + ), + idempotency_key=idempotency_key, + ) + + def mark_season_performance_past( + self, + season_key: str, + event_key: str, + force: bool | None = None, + idempotency_key: str | None = None, + ) -> Any: + """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`` + :class:`~seatlayer.types.SeasonDateLeavePending` (check with + :func:`~seatlayer.is_season_date_leave_pending`). + """ + return self._http.post_with_header_replay( + self._path(season_key, f"/performances/{quote(event_key)}/past"), + body=_fields((("force", force),)), + idempotency_key=idempotency_key, + ) + + def return_season_occurrence_inventory( + self, + season_key: str, + event_key: str, + action_id: str, + booking_ref: str, + plan_activation_id: str, + labels: Sequence[str], + source: dict[str, str], + ) -> Any: + """Return one occurrence allocation to the Event's ordinary inventory. + + ``source`` is ``{"bookingActionId": ...}`` or ``{"renewalOfferId": ...}``. + Sent once; reuse the same ``action_id`` if you send it again. + """ + return self._http.post( + self._path(season_key, f"/occurrences/{quote(event_key)}/returns"), + body={ + "actionId": action_id, + "bookingRef": booking_ref, + "planActivationId": plan_activation_id, + "labels": list(labels), + "source": source, + }, + ) + + def reclaim_season_occurrence_inventory( + self, + season_key: str, + event_key: str, + action_id: str, + return_action_id: str, + booking_ref: str, + plan_activation_id: str, + labels: Sequence[str], + source: dict[str, str], + ) -> Any: + """Take returned occurrence inventory back while it is still free. + + ``source`` is ``{"bookingActionId": ...}`` or ``{"renewalOfferId": ...}``. + Sent once; reuse the same ``action_id`` if you send it again. + """ + return self._http.post( + self._path(season_key, f"/occurrences/{quote(event_key)}/reclaims"), + body={ + "actionId": action_id, + "returnActionId": return_action_id, + "bookingRef": booking_ref, + "planActivationId": plan_activation_id, + "labels": list(labels), + "source": source, + }, + ) + + def list_season_holders( + self, + season_key: str, + q: str | None = None, + status: SeasonHolderStatus | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> Any: + """One page of season ticket holders. + + ``q`` searches holder name, email, reference, order number or seat. + """ + return self._http.get( + self._path(season_key, "/holders"), + query={"q": q, "status": status, "cursor": cursor, "limit": limit}, + ) + + def refund_season_order( + self, + season_key: str, + order_id: str, + amount_minor: int | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Refund a season order through its payment provider. + + Leave out ``amount_minor`` to refund what is left. + """ + body = _fields((("amountMinor", amount_minor),)) + return self._http.post_with_header_replay( + self._path(season_key, f"/orders/{quote(order_id)}/refund"), + body=body or None, + idempotency_key=idempotency_key, + ) + + def cancel_season_order( + self, + season_key: str, + order_id: str, + reason: OrderCancelReason, + note: str | None = None, + refund_confirmed: bool | None = None, + used_tickets_acknowledged: bool | None = None, + notify_buyer: bool | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Cancel a season order without moving money and release every date.""" + return self._http.post_with_header_replay( + self._path(season_key, f"/orders/{quote(order_id)}/cancel"), + body=_order_cancel_body( + reason, note, refund_confirmed, used_tickets_acknowledged, notify_buyer + ), + idempotency_key=idempotency_key, + ) + + def cancel_season_order_date( + self, + season_key: str, + order_id: str, + event_key: str, + action_id: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Take back one date of a season order and put its seats back on sale.""" + body = _fields((("actionId", action_id),)) + return self._http.post_with_header_replay( + self._path( + season_key, f"/orders/{quote(order_id)}/dates/{quote(event_key)}/cancel" + ), + body=body or None, + idempotency_key=idempotency_key, + ) + + def resend_season_order_tickets( + self, season_key: str, order_id: str, email: str | None = None + ) -> Any: + """Email a season order's tickets again. Sent once, never retried automatically.""" + body = _fields((("email", email),)) + return self._http.post( + self._path(season_key, f"/orders/{quote(order_id)}/resend"), body=body or None + ) + + def retrieve_season_hosted_renewals( + self, season_key: str, cursor: str | None = None, limit: int | None = None + ) -> Any: + """Retrieve renewal links, payments and emails for one page of offers, newest first. + + Pass ``cursor`` from the previous page's ``nextCursor`` (``None`` on the last + page). ``limit`` is 1-500 and defaults to 500. A bad cursor or limit answers + ``400 invalid_cursor`` or ``400 invalid_limit``. + """ + return self._http.get( + self._path(season_key, "/renewal-hosted"), + query={"cursor": cursor, "limit": limit}, + ) + + def rotate_season_renewal_offer_link(self, season_key: str, offer_id: str) -> Any: + """Make a new renewal link for one holder; the old link stops working. + + The new link is shown once. Never retry this call automatically. + """ + return self._http.post( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/link") + ) + + def update_season_renewal_offer_contact( + self, + season_key: str, + offer_id: str, + email: str | None | _Unset = _UNSET, + name: str | None | _Unset = _UNSET, + ) -> Any: + """Set the email and name a renewal offer is sent to. Pass ``None`` to clear one.""" + return self._http.post( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/contact"), + body=_fields((), (("email", email), ("name", name))), + ) + + def create_season_renewal_notices( + self, + season_key: str, + offer_ids: Sequence[str] | None = None, + batch_id: str | None = None, + ) -> Any: + """Email renewal invitations now and reminders before the deadline. + + Leave ``offer_ids`` out to email every open offer. At most 20,000 ids; + more answers ``422 too_many_offer_ids``. ``batch_id`` instead of + ``offer_ids``: every open offer one renewal offer batch made (its ``batchId``). + """ + body = _fields( + ( + ("offerIds", None if offer_ids is None else list(offer_ids)), + ("batchId", batch_id), + ) + ) + return self._http.post( + self._path(season_key, "/renewal-notices"), body=body or None + ) + + def resend_season_renewal_offer(self, season_key: str, offer_id: str) -> Any: + """Send one holder a new renewal invitation. Sent once, never retried automatically.""" + return self._http.post( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/resend") + ) + + def retrieve_season_renewal_quote(self, season_key: str, offer_id: str) -> Any: + """What renewing an offer costs, and whether SeatLayer checkout can take it now. + + ``problem`` is ``None`` when it can; otherwise it says why not. + """ + return self._http.get( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/quote") + ) + + def create_season_renewal_intent(self, season_key: str, offer_id: str) -> Any: + """Record that the holder means to renew, for a renewal taken by phone or at the box office. + + Your own checkout can then commit the offer. A repeat answers the same offer. + """ + return self._http.post( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/intent") + ) + + def create_season_renewal_checkout( + self, + season_key: str, + offer_id: str, + return_url: str, + buyer_email: str | None = None, + buyer_name: str | None = None, + provider: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Start a SeatLayer checkout that renews an offer, with no hosted page. + + Send the buyer to ``payment["url"]``; paying renews the offer. ``return_url`` must be + on one of the organization's allowed domains. A repeat with the same key answers + with the same order. + """ + return self._http.post_with_header_replay( + self._path(season_key, f"/renewal-offers/{quote(offer_id)}/checkout"), + body=_renewal_checkout_body(return_url, buyer_email, buyer_name, provider), + idempotency_key=idempotency_key, + ) + + def retrieve_season_sales_report(self, season_key: str) -> Any: + """Retrieve season money, seats, holders and renewals as totals.""" + return self._http.get(self._path(season_key, "/reports/sales")) + + def retrieve_season_seats_report(self, season_key: str) -> Any: + """Retrieve the seats still free per package and per date.""" + return self._http.get(self._path(season_key, "/reports/seats")) + + def export_season_holders_csv( + self, + season_key: str, + q: str | None = None, + status: SeasonHolderStatus | None = None, + ) -> str: + """Download every season ticket holder as CSV. Returns the CSV text.""" + return self._http.get_text( + self._path(season_key, "/reports/holders.csv"), + query={"q": q, "status": status}, + ) + + def export_season_orders_csv(self, season_key: str) -> str: + """Download every season order SeatLayer took as CSV. Returns the CSV text.""" + return self._http.get_text(self._path(season_key, "/reports/orders.csv")) + + def retrieve_season_seat_counts(self, season_key: str) -> Any: + """Retrieve the season seats sold per package and per date.""" + return self._http.get(self._path(season_key, "/seat-counts")) + + def refund_season_performance( + self, + season_key: str, + event_key: str, + amount: Literal["per_date"], + reason: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Refund every SeatLayer order holding a cancelled date, as a job. + + ``per_date`` refunds the date's share of each order (rounded down). + ``full`` is refused (``422 date_refund_per_date_only``): refund a whole + order with the order refund. Answers ``{"job": ...}``; + follow it with :meth:`retrieve_season_bulk_job`. Retried safely with one + Idempotency-Key, which replays the same job. + """ + return self._http.post_with_header_replay( + self._path(season_key, f"/performances/{quote(event_key)}/refunds"), + body=_fields((("amount", amount), ("reason", reason))), + idempotency_key=idempotency_key, + ) + + def release_season_lapsed_offers( + self, season_key: str, idempotency_key: str | None = None + ) -> Any: + """Release the seats of every lapsed renewal offer, as a job. + + Answers ``{"job": ...}``; follow it with :meth:`retrieve_season_bulk_job`. + An offer extended before its turn is skipped and keeps its seat. Retried + safely with one Idempotency-Key, which replays the same job. + """ + return self._http.post_with_header_replay( + self._path(season_key, "/renewal-offers/release-lapsed"), + idempotency_key=idempotency_key, + ) + + def list_season_bulk_jobs(self, season_key: str) -> Any: + """List the Season's bulk jobs, newest first (100 at most).""" + return self._http.get(self._path(season_key, "/jobs")) + + def retrieve_season_bulk_job(self, season_key: str, job_id: str) -> Any: + """Retrieve one bulk job with its counts and up to 500 failed items.""" + return self._http.get(self._path(season_key, f"/jobs/{quote(job_id)}")) + + def set_aside_season_block( + self, + season_key: str, + expected_revision: int, + keep_for_single_tickets: SeasonSeatSelection | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Set the season's seats aside on every date: every seat its packages price, minus ``keep_for_single_tickets``. + + Answers ``202`` ``{"job": ..., "block": ...}``: a ``season_block_set_aside`` + job to follow with :meth:`retrieve_season_bulk_job`. On an active season + block mode turns on by itself once every date has the seats + (``season.block.ready``); on a draft, Publish turns it on. Season seats + must be on for the season (``409 season_block_not_enabled`` otherwise). + Retried safely with one Idempotency-Key, which replays the same answer. + """ + return self._http.post_with_header_replay( + self._path(season_key, "/block"), + body=_fields( + ( + ("expectedRevision", expected_revision), + ("keepForSingleTickets", keep_for_single_tickets), + ) + ), + idempotency_key=idempotency_key, + ) + + def retrieve_season_block(self, season_key: str) -> Any: + """The season's seats set aside on every date: ``{"block": ...}``. + + ``block.mode`` is ``block`` once its seats are set aside and on, else + ``per_hold``; ``block.state`` is ``none``, ``setting_aside``, ``ready`` + or ``failed``. Each date says whether it is ``up_to_date``. + """ + return self._http.get(self._path(season_key, "/block")) + + def release_season_block( + self, + season_key: str, + expected_revision: int, + scope: SeasonBlockReleaseScope, + event_keys: Sequence[str] | None = None, + at: str | None = None, + reason: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Release season seats to single sale: every unsold seat, or the sections, categories or seats in ``scope``. + + ``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`` + ``{"job": ..., "block": ...}``: a ``season_block_release`` job to follow + with :meth:`retrieve_season_bulk_job`. ``season.block.released`` is sent + once it has run on every date. Retried safely with one Idempotency-Key, + which replays the same release. + """ + return self._http.post_with_header_replay( + self._path(season_key, "/block/release"), + body=_fields( + ( + ("expectedRevision", expected_revision), + ("scope", scope), + ("eventKeys", None if event_keys is None else list(event_keys)), + ("at", at), + ("reason", reason), + ) + ), + idempotency_key=idempotency_key, + ) + + +class Holders: + """Season ticket holders, their tickets, transfers, returns and renewals, and their customers. + + Method names are the snake_case form of the public operation ids. + """ + + def __init__(self, http: HttpClient) -> None: + self._http = http + + @staticmethod + def _path(holder_ref: str, suffix: str = "") -> str: + return f"/v1/holders/{quote(holder_ref)}{suffix}" + + def list_customers( + self, + email: str | None = None, + holder_ref: str | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> Any: + """One page of customers, or the one with this email or holder reference.""" + return self._http.get("/v1/customers", query={ + "email": email, + "holderRef": holder_ref, + "cursor": cursor, + "limit": limit, + }) + + def retrieve_holder(self, holder_ref: str) -> Any: + """Retrieve the customer behind a holder reference.""" + return self._http.get(self._path(holder_ref)) + + def update_holder( + self, + holder_ref: str, + email: str | None = None, + name: str | None | _Unset = _UNSET, + phone: str | None | _Unset = _UNSET, + idempotency_key: str | None = None, + ) -> Any: + """Set a holder's email, name or phone. Pass ``None`` to clear name or phone.""" + body = _fields((("email", email),), (("name", name), ("phone", phone))) + return self._http.mutation_with_header_replay( + "PATCH", self._path(holder_ref), body=body, idempotency_key=idempotency_key + ) + + def revoke_holder_sessions(self, holder_ref: str, session_id: str | None = None) -> Any: + """Sign a holder's customer out of the ticket site. + + Pass ``session_id`` to end one session only. Sent once. + """ + body = _fields((("sessionId", session_id),)) + return self._http.post( + self._path(holder_ref, "/sessions/revoke"), body=body or None + ) + + def list_holder_seasons(self, holder_ref: str) -> Any: + """List the seasons a holder holds seats in.""" + return self._http.get(self._path(holder_ref, "/seasons")) + + def list_holder_tickets( + self, + holder_ref: str, + season_key: str | None = None, + from_: int | None = None, + to: int | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> Any: + """One page of a holder's tickets, date by date. + + ``from_`` and ``to`` bound the date's start, in epoch milliseconds; they are + sent as ``from`` and ``to``. + """ + return self._http.get(self._path(holder_ref, "/tickets"), query={ + "seasonKey": season_key, + "from": from_, + "to": to, + "cursor": cursor, + "limit": limit, + }) + + def retrieve_holder_ticket(self, holder_ref: str, ticket_id: str) -> Any: + """Retrieve one of a holder's tickets with its QR.""" + return self._http.get(self._path(holder_ref, f"/tickets/{quote(ticket_id)}")) + + def create_holder_transfer( + self, + holder_ref: str, + ticket_ids: Sequence[str], + to_email: str, + idempotency_key: str | None = None, + ) -> Any: + """Send some of a holder's season tickets to an email address. + + Turning customer sends off in Settings › Ticket site doesn't stop your + server; cut-offs still apply. + """ + return self._http.post_with_header_replay( + self._path(holder_ref, "/transfers"), + body={"ticketIds": list(ticket_ids), "toEmail": to_email}, + idempotency_key=idempotency_key, + ) + + def list_holder_transfers(self, holder_ref: str) -> Any: + """List the transfers a holder sent or was sent.""" + return self._http.get(self._path(holder_ref, "/transfers")) + + def cancel_holder_transfer(self, holder_ref: str, transfer_id: str) -> Any: + """Cancel a transfer that has not been accepted. Sent once.""" + return self._http.post( + self._path(holder_ref, f"/transfers/{quote(transfer_id)}/cancel") + ) + + def accept_holder_transfer(self, holder_ref: str, transfer_id: str) -> Any: + """Accept a transfer for the recipient holder. Sent once.""" + return self._http.post( + self._path(holder_ref, f"/transfers/{quote(transfer_id)}/accept") + ) + + def create_holder_return( + self, + holder_ref: str, + ticket_ids: Sequence[str], + idempotency_key: str | None = None, + ) -> Any: + """Give back a holder's seat on one date, one per ticket. + + ``ticket_ids`` takes 1 to 20 tickets, all of one season. Returns ``{"return": ...}``; + 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. + """ + return self._http.post_with_header_replay( + self._path(holder_ref, "/returns"), + body={"ticketIds": list(ticket_ids)}, + idempotency_key=idempotency_key, + ) + + def create_holder_renewal_checkout( + self, + holder_ref: str, + offer_id: str, + return_url: str, + buyer_email: str | None = None, + buyer_name: str | None = None, + provider: str | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Start a SeatLayer checkout that renews one of a holder's offers. + + The offer is the ``renewalOffer`` on ``list_holder_seasons``. Send the buyer to + ``payment["url"]``. A repeat with the same key answers with the same order. + """ + return self._http.post_with_header_replay( + self._path(holder_ref, f"/renewal-offers/{quote(offer_id)}/checkout"), + body=_renewal_checkout_body(return_url, buyer_email, buyer_name, provider), + idempotency_key=idempotency_key, + ) + + def decline_holder_renewal_offer( + self, holder_ref: str, offer_id: str, reason: str | None = None + ) -> Any: + """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. + """ + body = _fields((("reason", reason),)) + return self._http.post( + self._path(holder_ref, f"/renewal-offers/{quote(offer_id)}/decline"), + body=body or None, + ) + + +class Orders: + """Orders SeatLayer took, across events and seasons, and one event's orders. + + Method names are the snake_case form of the public operation ids. Season + order refunds and cancellations live on ``seasons``. + """ + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_orders( + self, + event_key: str | None = None, + season_key: str | None = None, + status: OrderStatus | None = None, + from_: int | None = None, + to: int | None = None, + cursor: str | None = None, + limit: int | None = None, + holder_ref: str | None = None, + ) -> Any: + """One page of orders across events and seasons. + + ``from_`` and ``to`` bound the creation time, in epoch milliseconds; they are + sent as ``from`` and ``to``. ``holder_ref`` lists one holder's orders: the + season order with that id and renewals offered to that reference. Each + order has ``holderRef`` and ``customerId``; ``buyer.email`` is ``None`` + for a box office sale taken without one. + """ + return self._http.get("/v1/orders", query={ + "eventKey": event_key, + "seasonKey": season_key, + "holderRef": holder_ref, + "status": status, + "from": from_, + "to": to, + "cursor": cursor, + "limit": limit, + }) + + def retrieve_order(self, order_id: str) -> Any: + """Retrieve an order and its lines.""" + return self._http.get(f"/v1/orders/{quote(order_id)}") + + def list_order_tickets(self, order_id: str) -> Any: + """List an order's tickets with their scan links.""" + return self._http.get(f"/v1/orders/{quote(order_id)}/tickets") + + def list_event_orders( + self, + event_key: str, + q: str | None = None, + status: EventOrderStatus | None = None, + paid_by: str | None = None, + from_: int | None = None, + to: int | None = None, + cursor: str | None = None, + limit: int | None = None, + ) -> Any: + """One page of one event's orders. + + ``q`` searches buyer name, email, order number or seat. ``from_`` and ``to`` + bound the payment time, in epoch milliseconds; they are sent as ``from`` + and ``to``. + """ + return self._http.get(f"/v1/events/{quote(event_key)}/orders", query={ + "q": q, + "status": status, + "paidBy": paid_by, + "from": from_, + "to": to, + "cursor": cursor, + "limit": limit, + }) + + def retrieve_event_order(self, event_key: str, order_id: str) -> Any: + """Retrieve one event order with its guests and tickets.""" + return self._http.get(f"/v1/events/{quote(event_key)}/orders/{quote(order_id)}") + + def export_event_orders_csv( + self, + event_key: str, + q: str | None = None, + status: EventOrderStatus | None = None, + paid_by: str | None = None, + from_: int | None = None, + to: int | None = None, + ) -> str: + """Download one event's orders as CSV. Returns the CSV text.""" + return self._http.get_text(f"/v1/events/{quote(event_key)}/orders.csv", query={ + "q": q, + "status": status, + "paidBy": paid_by, + "from": from_, + "to": to, + }) + + def refund_event_order( + self, + event_key: str, + order_id: str, + amount_minor: int | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Refund an event order through its payment provider. + + Leave out ``amount_minor`` to refund what is left. + """ + body = _fields((("amountMinor", amount_minor),)) + return self._http.post_with_header_replay( + f"/v1/events/{quote(event_key)}/orders/{quote(order_id)}/refund", + body=body or None, + idempotency_key=idempotency_key, + ) + + def cancel_event_order( + self, + event_key: str, + order_id: str, + reason: OrderCancelReason, + note: str | None = None, + refund_confirmed: bool | None = None, + used_tickets_acknowledged: bool | None = None, + notify_buyer: bool | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Cancel an event order without moving money.""" + return self._http.post_with_header_replay( + f"/v1/events/{quote(event_key)}/orders/{quote(order_id)}/cancel", + body=_order_cancel_body( + reason, note, refund_confirmed, used_tickets_acknowledged, notify_buyer + ), + idempotency_key=idempotency_key, + ) + + def resend_event_order_tickets( + self, event_key: str, order_id: str, email: str | None = None + ) -> Any: + """Email an event order's tickets again. Sent once, never retried automatically.""" + body = _fields((("email", email),)) + return self._http.post( + f"/v1/events/{quote(event_key)}/orders/{quote(order_id)}/resend", + body=body or None, + ) + + +class EventSeries: + """Runs of dated events made from one schedule and one floor plan. + + Method names are the snake_case form of the public operation ids. A series + is addressed by an event key, as in the API path. + """ + + def __init__(self, http: HttpClient) -> None: + self._http = http + + @staticmethod + def _path(event_key: str, suffix: str = "") -> str: + return f"/v1/event-series/{quote(event_key)}{suffix}" + + def create_event_series( + self, + name: str | None = None, + chart_key: str | None = None, + schedule: dict[str, Any] | None = None, + timezone: str | None = None, + venue: str | None = None, + duration_minutes: int | None = None, + dry_run: bool | None = None, + idempotency_key: str | None = None, + from_event_key: str | None = None, + ) -> Any: + """Plan an event series from a schedule, or make it as a job. + + ``schedule`` takes ``rule`` (``"none"``, ``"daily"``, ``"weekly"``, + ``"biweekly"`` or ``"dates"``) and ``times`` (``"HH:MM"``), plus ``from``, + ``until`` or ``count``, ``skip`` and ``dates`` as the rule needs. + ``dry_run=True`` answers the dates and creates nothing. 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 then takes + the event's name, zone and floor plan; ``chart_key``, ``venue`` and + ``duration_minutes`` are refused with it. + + Retried safely with one Idempotency-Key. A series from an event that never + started and was given up answers ``409 create_abandoned`` for that key every + time (:class:`SeatLayerConflictError`, not retried): send the request again + with a new key. + """ + if schedule is None: + raise ValueError("schedule is required") + if from_event_key is None and (name is None or chart_key is None): + raise ValueError("give name and chart_key, or from_event_key") + body = _fields(( + ("fromEventKey", from_event_key), + ("name", name), + ("chartKey", chart_key), + ("timezone", timezone), + ("venue", venue), + ("durationMinutes", duration_minutes), + ("dryRun", dry_run), + )) + body["schedule"] = schedule + return self._http.post_with_header_replay( + "/v1/event-series", body=body, idempotency_key=idempotency_key + ) + + def list_event_series(self, limit: int | None = None, cursor: str | None = None) -> Any: + """One page of event series.""" + return self._http.get("/v1/event-series", query={"limit": limit, "cursor": cursor}) + + def retrieve_event_series(self, event_key: str) -> Any: + """Retrieve an event series, its dates and its job.""" + return self._http.get(self._path(event_key)) + + def add_event_series_dates( + self, + event_key: str, + schedule: dict[str, Any] | None = None, + dates: Sequence[str] | None = None, + idempotency_key: str | None = None, + retry_failed: bool = False, + ) -> Any: + """Add dates to an event series, copied from its first date. + + Give exactly one of ``schedule`` (as for ``create_event_series``), + ``dates`` (exact moments as ``"YYYY-MM-DDTHH:MM"`` in the series' zone), + or ``retry_failed=True`` to make again every date that failed to be made + (up to 100, as in ``job.failedDates``; ``422 nothing_to_retry`` when + none did, ``409 series_busy`` while another change runs). + """ + # The API takes one shape; failing here names the mistake instead of a + # 400 after the round trip. + if (schedule is not None) + (dates is not None) + bool(retry_failed) != 1: + raise ValueError("give exactly one of schedule, dates or retry_failed") + body: dict[str, Any] = ( + {"schedule": schedule} if schedule is not None + else {"dates": list(dates)} if dates is not None + else {"retryFailed": True} + ) + return self._http.post_with_header_replay( + self._path(event_key, "/dates"), body=body, idempotency_key=idempotency_key + ) + + def update_event_series( + self, + event_key: str, + name: str | None = None, + prices: dict[str, Any] | None = None, + sales: Literal["on", "off"] | None = None, + idempotency_key: str | None = None, + ) -> Any: + """Change the name, prices or sales of every upcoming date in a series. + + ``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"`` or ``"off"`` opens or + pauses sales on every upcoming date, each with its own go-live checks. + ``"on"`` never opens a date its season cancelled: that date is a failed + item of the job (``season_date_cancelled``), counted in ``job["failed"]`` + but not in ``failedDates``, which are the dates that could not be made. + """ + body = _fields((("name", name), ("prices", prices), ("sales", sales))) + return self._http.mutation_with_header_replay( + "PATCH", self._path(event_key), body=body, idempotency_key=idempotency_key + ) diff --git a/src/seatlayer/types.py b/src/seatlayer/types.py index c88a4e4..755cb57 100644 --- a/src/seatlayer/types.py +++ b/src/seatlayer/types.py @@ -13,6 +13,56 @@ "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.order.paid", + "season.order.refunded", + "season.block.ready", + "season.block.released", + "series.created", + "series.dates_added", ] ManageCapability = Literal[ @@ -300,3 +350,36 @@ class EventLogEntry(TypedDict): class EventLogPage(TypedDict): entries: list[EventLogEntry] nextBefore: int | None + + +class SeasonSeatSelection(TypedDict, total=False): + """Seats kept for single tickets, or released: sections, categories or seats.""" + + sectionIds: list[str] + categoryKeys: list[str] + labels: list[str] + + +class SeasonBlockReleaseScope(TypedDict, total=False): + """What a release covers: exactly one of ``all=True``, ``sectionIds``, ``categoryKeys`` or ``labels``.""" + + all: Literal[True] + sectionIds: list[str] + categoryKeys: list[str] + labels: list[str] + + +class SeasonDateLeavePending(TypedDict): + """The ``202`` a season-seats season's date cancel or past date answers while the date leaves every season seat. + + It finishes by itself: send the same request again after ``Retry-After`` to read how it ended. + Check for it with :func:`seatlayer.is_season_date_leave_pending`. + """ + + state: Literal["finishing"] + code: Literal["block_date_leave_pending"] + retryable: Literal[True] + eventKey: str + shardsLeft: list[int] + shardCount: int + message: str diff --git a/tests/test_client.py b/tests/test_client.py index dcfa70a..ef356cb 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,7 +5,7 @@ import io import json import urllib.error -from typing import Any +from typing import Any, get_args import pytest @@ -19,6 +19,7 @@ SeatLayerError, SeatLayerRateLimitError, SeatLayerValidationError, + WebhookEventName, ) @@ -371,6 +372,31 @@ def test_retries_429_and_reuses_idempotency_key(self) -> None: # Same key on the retry, or the server would create two events. assert calls[0].get_header("Idempotency-key") == calls[1].get_header("Idempotency-key") + def test_bulk_refund_of_a_date_retries_with_one_key(self) -> None: + sdk, calls = make_client([ + {"status": 503, "body": {"error": "unavailable"}}, + {"status": 202, "body": {"job": {"jobId": "sbj_1"}}}, + ]) + result = sdk.seasons.refund_season_performance("sea_1", "ev_1", "per_date") + + assert result == {"job": {"jobId": "sbj_1"}} + assert len(calls) == 2 + assert calls[0].get_header("Idempotency-key") == calls[1].get_header("Idempotency-key") + + def test_release_of_lapsed_offers_retries_with_one_key(self) -> None: + sdk, calls = make_client([ + {"status": 503, "body": {"error": "job_busy"}}, + {"status": 202, "body": {"job": {"jobId": "sbj_1", "kind": "lapsed_release"}}}, + ]) + result = sdk.seasons.release_season_lapsed_offers("sea_1") + + assert result == {"job": {"jobId": "sbj_1", "kind": "lapsed_release"}} + assert len(calls) == 2 + assert calls[0].full_url.endswith("/v1/seasons/sea_1/renewal-offers/release-lapsed") + assert calls[0].data is None + first = calls[0].get_header("Idempotency-key") + assert first and first == calls[1].get_header("Idempotency-key") + def test_template_instantiate_retries_but_release_mutations_do_not(self) -> None: template, template_calls = make_client([ {"status": 503, "body": {"error": "unavailable"}}, @@ -524,6 +550,14 @@ def test_list_all_events_skips_per_event_counts(self) -> None: list(sdk.events.list_all()) assert "counts=0" in calls[0].full_url + def test_lists_one_event_series(self) -> None: + sdk, calls = make_client([{"status": 200, "body": {"events": []}}]) + sdk.events.list(series_key="ser a/b", limit=10) + assert "seriesKey=ser+a%2Fb" in calls[0].full_url + sdk2, calls2 = make_client([{"status": 200, "body": {"events": []}}]) + list(sdk2.events.list_all(series_key="ser_1")) + assert "seriesKey=ser_1" in calls2[0].full_url + def test_single_page_keeps_counts(self) -> None: sdk, calls = make_client([{"status": 200, "body": {"events": []}}]) sdk.events.list(limit=10) @@ -829,6 +863,14 @@ def test_webhook_envelopes_update_and_delivery_filters(self) -> None: "/v1/webhooks/wh_1/deliveries?limit=10&status=failed&before=200" ) + def test_webhooks_accept_every_event_name_the_sdk_types(self) -> None: + names = list(get_args(WebhookEventName)) + assert "season.block.ready" in names and "season.block.released" in names + assert "season.plan.published" in names + sdk, calls = make_client([{"status": 201, "body": {"sub": {}, "secret": "whsec_once"}}]) + sdk.webhooks.create("https://hooks.example/seatlayer", names) + assert json.loads(calls[0].data)["events"] == names + def test_webhooks_reject_unknown_enums_before_transport(self) -> None: sdk, calls = make_client([]) with pytest.raises(ValueError, match="supported SeatLayer webhook event names"): diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py new file mode 100644 index 0000000..5d82c89 --- /dev/null +++ b/tests/test_holders_orders_series.py @@ -0,0 +1,670 @@ +"""Holders, orders, event sales settings and event series: public-operation conformance.""" + +from __future__ import annotations + +import io +import json +import urllib.error +from collections.abc import Callable +from typing import Any + +import pytest +from typing_extensions import Self + +from seatlayer import SeatLayer, SeatLayerConflictError, SeatLayerError + +BASE = "https://api.seatlayer.io" +CSV = "order,buyer\r\nSL-1,A. Fan\r\n" + + +class FakeResponse(io.BytesIO): + def __init__(self, status: int = 200, raw: bytes = b"{}") -> None: + super().__init__(raw) + self.status = status + + def __enter__(self) -> Self: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + +def recorder() -> tuple[SeatLayer, list[Any]]: + """A client whose transport records requests and answers like the API would.""" + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + if ".csv" in request.full_url: + return FakeResponse(raw=CSV.encode()) + if request.method == "DELETE": + return FakeResponse(204, raw=b"") + return FakeResponse() + + return SeatLayer("sk_test_abc", transport=transport), calls + + +Call = Callable[[SeatLayer], Any] + +# (operation id, call, verb, path and query, JSON body or None, sends Idempotency-Key) +CASES: list[tuple[str, Call, str, str, Any, bool]] = [ + # holders + ( + "listCustomers", + lambda sdk: sdk.holders.list_customers( + email="fan+1@example.com", holder_ref="h/1", cursor="c/1", limit=10 + ), + "GET", + "/v1/customers?email=fan%2B1%40example.com&holderRef=h%2F1&cursor=c%2F1&limit=10", + None, + False, + ), + ( + "retrieveHolder", + lambda sdk: sdk.holders.retrieve_holder("h/1"), + "GET", + "/v1/holders/h%2F1", + None, + False, + ), + ( + "updateHolder", + lambda sdk: sdk.holders.update_holder( + "h/1", email="fan@example.com", phone=None, idempotency_key="holder-1" + ), + "PATCH", + "/v1/holders/h%2F1", + {"email": "fan@example.com", "phone": None}, + True, + ), + ( + "revokeHolderSessions", + lambda sdk: sdk.holders.revoke_holder_sessions( + "h/1", session_id="csn_0123456789abcdef01234567" + ), + "POST", + "/v1/holders/h%2F1/sessions/revoke", + {"sessionId": "csn_0123456789abcdef01234567"}, + False, + ), + ( + "listHolderSeasons", + lambda sdk: sdk.holders.list_holder_seasons("h/1"), + "GET", + "/v1/holders/h%2F1/seasons", + None, + False, + ), + ( + "listHolderTickets", + lambda sdk: sdk.holders.list_holder_tickets( + "h/1", season_key="sea/1", from_=100, to=200, cursor="c/1", limit=5 + ), + "GET", + "/v1/holders/h%2F1/tickets?seasonKey=sea%2F1&from=100&to=200&cursor=c%2F1&limit=5", + None, + False, + ), + ( + "retrieveHolderTicket", + lambda sdk: sdk.holders.retrieve_holder_ticket("h/1", "tk/1"), + "GET", + "/v1/holders/h%2F1/tickets/tk%2F1", + None, + False, + ), + ( + "createHolderTransfer", + lambda sdk: sdk.holders.create_holder_transfer( + "h/1", ["tk_1", "tk_2"], "friend@example.com" + ), + "POST", + "/v1/holders/h%2F1/transfers", + {"ticketIds": ["tk_1", "tk_2"], "toEmail": "friend@example.com"}, + True, + ), + ( + "listHolderTransfers", + lambda sdk: sdk.holders.list_holder_transfers("h/1"), + "GET", + "/v1/holders/h%2F1/transfers", + None, + False, + ), + ( + "cancelHolderTransfer", + lambda sdk: sdk.holders.cancel_holder_transfer("h/1", "tr/1"), + "POST", + "/v1/holders/h%2F1/transfers/tr%2F1/cancel", + None, + False, + ), + ( + "acceptHolderTransfer", + lambda sdk: sdk.holders.accept_holder_transfer("h/1", "tr/1"), + "POST", + "/v1/holders/h%2F1/transfers/tr%2F1/accept", + None, + False, + ), + ( + "createHolderReturn", + lambda sdk: sdk.holders.create_holder_return( + "a/b", ["tkt_0123456789abcdef01234567"] + ), + "POST", + "/v1/holders/a%2Fb/returns", + {"ticketIds": ["tkt_0123456789abcdef01234567"]}, + True, + ), + ( + "createHolderRenewalCheckout", + lambda sdk: sdk.holders.create_holder_renewal_checkout( + "h/1", + "offer/1", + "https://tickets.example/renewed", + buyer_email="cara@example.com", + buyer_name="Cara", + provider="stripe", + ), + "POST", + "/v1/holders/h%2F1/renewal-offers/offer%2F1/checkout", + { + "returnUrl": "https://tickets.example/renewed", + "buyerEmail": "cara@example.com", + "buyerName": "Cara", + "provider": "stripe", + }, + True, + ), + ( + "declineHolderRenewalOffer", + lambda sdk: sdk.holders.decline_holder_renewal_offer( + "h/1", "offer/1", reason="Moving away" + ), + "POST", + "/v1/holders/h%2F1/renewal-offers/offer%2F1/decline", + {"reason": "Moving away"}, + False, + ), + # orders + ( + "listOrders", + lambda sdk: sdk.orders.list_orders( + season_key="sea/1", status="confirmed", from_=1, to=2, cursor="c/1", limit=50, + holder_ref="ord/1", + ), + "GET", + "/v1/orders?seasonKey=sea%2F1&holderRef=ord%2F1&status=confirmed&from=1&to=2&cursor=c%2F1&limit=50", + None, + False, + ), + ( + "retrieveOrder", + lambda sdk: sdk.orders.retrieve_order("ord/1"), + "GET", + "/v1/orders/ord%2F1", + None, + False, + ), + ( + "listOrderTickets", + lambda sdk: sdk.orders.list_order_tickets("ord/1"), + "GET", + "/v1/orders/ord%2F1/tickets", + None, + False, + ), + ( + "listEventOrders", + lambda sdk: sdk.orders.list_event_orders( + "ev/1", q="fan a", status="partially_cancelled", paid_by="card", limit=25 + ), + "GET", + "/v1/events/ev%2F1/orders?q=fan+a&status=partially_cancelled&paidBy=card&limit=25", + None, + False, + ), + ( + "retrieveEventOrder", + lambda sdk: sdk.orders.retrieve_event_order("ev/1", "ord/1"), + "GET", + "/v1/events/ev%2F1/orders/ord%2F1", + None, + False, + ), + ( + "exportEventOrdersCsv", + lambda sdk: sdk.orders.export_event_orders_csv("ev/1", status="refunded", from_=5), + "GET", + "/v1/events/ev%2F1/orders.csv?status=refunded&from=5", + None, + False, + ), + ( + "refundEventOrder", + lambda sdk: sdk.orders.refund_event_order( + "ev/1", "ord/1", amount_minor=500, idempotency_key="refund-1" + ), + "POST", + "/v1/events/ev%2F1/orders/ord%2F1/refund", + {"amountMinor": 500}, + True, + ), + ( + "cancelEventOrder", + lambda sdk: sdk.orders.cancel_event_order( + "ev/1", "ord/1", "lost_dispute", refund_confirmed=True, + used_tickets_acknowledged=True, + ), + "POST", + "/v1/events/ev%2F1/orders/ord%2F1/cancel", + {"reason": "lost_dispute", "refundConfirmed": True, "usedTicketsAcknowledged": True}, + True, + ), + ( + "resendEventOrderTickets", + lambda sdk: sdk.orders.resend_event_order_tickets("ev/1", "ord/1"), + "POST", + "/v1/events/ev%2F1/orders/ord%2F1/resend", + None, + False, + ), + # event sales settings, on events + ( + "retrieveEventBuyerCheckout", + lambda sdk: sdk.events.retrieve_event_buyer_checkout("ev/1"), + "GET", + "/v1/events/ev%2F1/buyer-checkout", + None, + False, + ), + ( + "replaceEventBuyerCheckout", + lambda sdk: sdk.events.replace_event_buyer_checkout("ev/1", "integrator", 4), + "PUT", + "/v1/events/ev%2F1/buyer-checkout", + {"buyerCheckout": "integrator", "expectedRevision": 4}, + False, + ), + ( + "retrieveEventPaymentGateway", + lambda sdk: sdk.events.retrieve_event_payment_gateway("ev/1", currency="EUR"), + "GET", + "/v1/events/ev%2F1/payment-gateway?currency=EUR", + None, + False, + ), + ( + "replaceEventPaymentGateway", + lambda sdk: sdk.events.replace_event_payment_gateway("ev/1", connection_id=None), + "PUT", + "/v1/events/ev%2F1/payment-gateway", + {"connectionId": None}, + False, + ), + ( + "retrieveEventSalesReadiness", + lambda sdk: sdk.events.retrieve_event_sales_readiness("ev/1"), + "GET", + "/v1/events/ev%2F1/sales/readiness", + None, + False, + ), + ( + "preflightEventSales", + lambda sdk: sdk.events.preflight_event_sales("ev/1", "ord_test_1"), + "POST", + "/v1/events/ev%2F1/sales/preflight", + {"orderId": "ord_test_1"}, + False, + ), + ( + "transitionEventSales", + lambda sdk: sdk.events.transition_event_sales("ev/1", "live", 7, reason="Go"), + "POST", + "/v1/events/ev%2F1/sales/transition", + {"to": "live", "expectedRevision": 7, "reason": "Go"}, + False, + ), + ( + "retrieveEventBookingFee", + lambda sdk: sdk.events.retrieve_event_booking_fee("ev/1"), + "GET", + "/v1/events/ev%2F1/booking-fee", + None, + False, + ), + ( + "replaceEventBookingFee", + lambda sdk: sdk.events.replace_event_booking_fee("ev/1", None), + "PUT", + "/v1/events/ev%2F1/booking-fee", + {"bookingFeeMinor": None}, + False, + ), + ( + "listEventDiscountCodes", + lambda sdk: sdk.events.list_event_discount_codes("ev/1"), + "GET", + "/v1/events/ev%2F1/discount-codes", + None, + False, + ), + ( + "createEventDiscountCode", + lambda sdk: sdk.events.create_event_discount_code( + "ev/1", "EARLY10", "percent", percent=10, ends_at=None, max_uses=100 + ), + "POST", + "/v1/events/ev%2F1/discount-codes", + {"code": "EARLY10", "kind": "percent", "percent": 10, "endsAt": None, "maxUses": 100}, + False, + ), + ( + "updateEventDiscountCode", + lambda sdk: sdk.events.update_event_discount_code("ev/1", "dc/1", state="paused"), + "PATCH", + "/v1/events/ev%2F1/discount-codes/dc%2F1", + {"state": "paused"}, + False, + ), + ( + "endEventDiscountCode", + lambda sdk: sdk.events.end_event_discount_code("ev/1", "dc/1"), + "POST", + "/v1/events/ev%2F1/discount-codes/dc%2F1/end", + None, + False, + ), + ( + "deleteEventDiscountCode", + lambda sdk: sdk.events.delete_event_discount_code("ev/1", "dc/1"), + "DELETE", + "/v1/events/ev%2F1/discount-codes/dc%2F1", + None, + False, + ), + # event series + ( + "createEventSeries", + lambda sdk: sdk.event_series.create_event_series( + "Hamlet", + "chart_1", + {"rule": "weekly", "from": "2026-11-03", "count": 4, "times": ["19:30"]}, + timezone="Europe/London", + duration_minutes=150, + dry_run=True, + idempotency_key="series-1", + ), + "POST", + "/v1/event-series", + { + "name": "Hamlet", + "chartKey": "chart_1", + "schedule": {"rule": "weekly", "from": "2026-11-03", "count": 4, "times": ["19:30"]}, + "timezone": "Europe/London", + "durationMinutes": 150, + "dryRun": True, + }, + True, + ), + ( + "listEventSeries", + lambda sdk: sdk.event_series.list_event_series(limit=20, cursor="c/1"), + "GET", + "/v1/event-series?limit=20&cursor=c%2F1", + None, + False, + ), + ( + "retrieveEventSeries", + lambda sdk: sdk.event_series.retrieve_event_series("ev/1"), + "GET", + "/v1/event-series/ev%2F1", + None, + False, + ), + ( + "addEventSeriesDates", + lambda sdk: sdk.event_series.add_event_series_dates( + "ev/1", dates=["2026-12-01T19:30"] + ), + "POST", + "/v1/event-series/ev%2F1/dates", + {"dates": ["2026-12-01T19:30"]}, + True, + ), + ( + "updateEventSeries", + lambda sdk: sdk.event_series.update_event_series( + "ev/1", name="Hamlet (revival)", sales="off" + ), + "PATCH", + "/v1/event-series/ev%2F1", + {"name": "Hamlet (revival)", "sales": "off"}, + True, + ), +] + + +def test_covers_every_new_holder_order_sales_and_series_operation() -> None: + operation_ids = [case[0] for case in CASES] + assert len(operation_ids) == len(set(operation_ids)) == 42 # 14 + 9 + 14 + 5 + + +@pytest.mark.parametrize( + ("operation_id", "call", "verb", "path", "body", "replay"), + CASES, + ids=[case[0] for case in CASES], +) +def test_wire_contract( + operation_id: str, call: Call, verb: str, path: str, body: Any, replay: bool +) -> None: + sdk, calls = recorder() + call(sdk) + + assert len(calls) == 1 + request = calls[0] + assert request.method == verb + assert request.full_url == BASE + path + if body is None: + assert request.data is None + else: + assert json.loads(request.data) == body + if replay: + assert request.get_header("Idempotency-key") + else: + assert request.get_header("Idempotency-key") is None + + +def test_caller_keys_are_sent_as_given() -> None: + sdk, calls = recorder() + sdk.holders.update_holder("h_1", name="A. Fan", idempotency_key="holder-1") + sdk.orders.refund_event_order("ev_1", "ord_1", idempotency_key="refund-1") + sdk.event_series.create_event_series( + "Hamlet", "chart_1", {"rule": "none", "times": ["19:30"]}, idempotency_key="series-1" + ) + assert [call.get_header("Idempotency-key") for call in calls] == [ + "holder-1", "refund-1", "series-1", + ] + # Optional bodies are left out entirely when nothing is set. + assert calls[1].data is None + + +def test_csv_export_returns_text_and_204_returns_none() -> None: + sdk, calls = recorder() + assert sdk.orders.export_event_orders_csv("ev_1") == CSV + assert calls[0].get_header("Accept") == "text/csv, application/json" + assert sdk.events.delete_event_discount_code("ev_1", "dc_1") is None + + +def test_optional_bodies_are_omitted_when_empty() -> None: + sdk, calls = recorder() + sdk.holders.revoke_holder_sessions("h_1") + sdk.orders.resend_event_order_tickets("ev_1", "ord_1") + sdk.events.replace_event_payment_gateway("ev_1") + sdk.holders.decline_holder_renewal_offer("h_1", "offer_1") + sdk.event_series.update_event_series("ev_1") + assert [call.data for call in calls[:4]] == [None, None, None, None] + # updateEventSeries requires a body, even an empty one. + assert json.loads(calls[4].data) == {} + + +def test_create_event_series_from_an_event() -> None: + sdk, calls = recorder() + schedule = {"rule": "daily", "from": "2026-11-06", "until": "2026-11-19", "times": ["14:00", "17:30", "20:15"]} + sdk.event_series.create_event_series( + schedule=schedule, from_event_key="lighthouse-keeper", idempotency_key="series-2" + ) + assert json.loads(calls[0].data) == {"fromEventKey": "lighthouse-keeper", "schedule": schedule} + with pytest.raises(ValueError, match="name and chart_key, or from_event_key"): + sdk.event_series.create_event_series(schedule=schedule) + with pytest.raises(ValueError, match="schedule is required"): + sdk.event_series.create_event_series(from_event_key="lighthouse-keeper") + assert len(calls) == 1 + + +def test_a_given_up_series_create_answers_409_create_abandoned_once() -> None: + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + raise urllib.error.HTTPError( + request.full_url, + 409, + "error", + {}, # type: ignore[arg-type] + io.BytesIO(json.dumps({ + "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.", + }).encode()), + ) + + sdk = SeatLayer("sk_test_abc", max_retries=3, transport=transport) + schedule = {"rule": "daily", "from": "2026-11-06", "until": "2026-11-08", "times": ["20:00"]} + with pytest.raises(SeatLayerConflictError) as raised: + sdk.event_series.create_event_series( + schedule=schedule, from_event_key="ev_1", idempotency_key="series-3" + ) + assert raised.value.code == "create_abandoned" + assert "new Idempotency-Key" in str(raised.value) + assert len(calls) == 1 + + +def test_add_event_series_dates_takes_exactly_one_shape() -> None: + sdk, calls = recorder() + with pytest.raises(ValueError, match="exactly one"): + sdk.event_series.add_event_series_dates("ev_1") + with pytest.raises(ValueError, match="exactly one"): + sdk.event_series.add_event_series_dates( + "ev_1", schedule={"rule": "none", "times": ["19:30"]}, dates=["2026-12-01T19:30"] + ) + sdk.event_series.add_event_series_dates( + "ev_1", schedule={"rule": "daily", "from": "2026-12-01", "count": 2, "times": ["20:00"]} + ) + with pytest.raises(ValueError, match="exactly one"): + sdk.event_series.add_event_series_dates("ev_1", dates=["2026-12-01T19:30"], retry_failed=True) + sdk.event_series.add_event_series_dates("ev_1", retry_failed=True) + assert len(calls) == 2 + assert json.loads(calls[0].data) == { + "schedule": {"rule": "daily", "from": "2026-12-01", "count": 2, "times": ["20:00"]} + } + # Make again the dates that failed to be made. + assert json.loads(calls[1].data) == {"retryFailed": True} + + +def _flaky(status: int, calls: list[Any]) -> Callable[[Any, float], FakeResponse]: + """Fail the first attempt with ``status``, then succeed.""" + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + if len(calls) == 1: + raise urllib.error.HTTPError( + request.full_url, + status, + "error", + {"Retry-After": "0"}, # type: ignore[arg-type] + io.BytesIO(b'{"error": "rate_limited"}'), + ) + return FakeResponse() + + return transport + + +def test_header_replay_mutations_retry_with_one_key() -> None: + calls: list[Any] = [] + sdk = SeatLayer("sk_test_abc", transport=_flaky(429, calls)) + sdk.holders.create_holder_transfer("h_1", ["tk_1"], "friend@example.com") + assert len(calls) == 2 + first = calls[0].get_header("Idempotency-key") + assert first and first == calls[1].get_header("Idempotency-key") + + +def test_holder_return_retries_with_one_key_generated_or_given() -> None: + calls: list[Any] = [] + sdk = SeatLayer("sk_test_abc", transport=_flaky(429, calls)) + sdk.holders.create_holder_return("a/b", ["tkt_0123456789abcdef01234567"]) + assert len(calls) == 2 + first = calls[0].get_header("Idempotency-key") + assert first and first == calls[1].get_header("Idempotency-key") + assert [call.full_url for call in calls] == [BASE + "/v1/holders/a%2Fb/returns"] * 2 + assert [json.loads(call.data) for call in calls] == [ + {"ticketIds": ["tkt_0123456789abcdef01234567"]} + ] * 2 + + calls.clear() + sdk = SeatLayer("sk_test_abc", transport=_flaky(503, calls)) + sdk.holders.create_holder_return( + "a/b", ["tkt_0123456789abcdef01234567"], idempotency_key="return-1" + ) + assert [call.get_header("Idempotency-key") for call in calls] == ["return-1", "return-1"] + + +def test_holder_renewal_checkout_retries_with_one_key_generated_or_given() -> None: + calls: list[Any] = [] + sdk = SeatLayer("sk_test_abc", transport=_flaky(429, calls)) + sdk.holders.create_holder_renewal_checkout("a/b", "offer_1", "https://tickets.example/r") + assert len(calls) == 2 + first = calls[0].get_header("Idempotency-key") + assert first and first == calls[1].get_header("Idempotency-key") + assert [call.full_url for call in calls] == [ + BASE + "/v1/holders/a%2Fb/renewal-offers/offer_1/checkout" + ] * 2 + + calls.clear() + sdk = SeatLayer("sk_test_abc", transport=_flaky(503, calls)) + sdk.seasons.create_season_renewal_checkout( + "sea_1", "offer_1", "https://tickets.example/r", idempotency_key="renew-1" + ) + assert [call.get_header("Idempotency-key") for call in calls] == ["renew-1", "renew-1"] + assert [json.loads(call.data) for call in calls] == [ + {"returnUrl": "https://tickets.example/r"} + ] * 2 + + +@pytest.mark.parametrize( + "call", + [ + lambda sdk: sdk.orders.resend_event_order_tickets("ev_1", "ord_1"), + lambda sdk: sdk.seasons.resend_season_order_tickets("sea_1", "ord_1"), + lambda sdk: sdk.seasons.rotate_season_renewal_offer_link("sea_1", "offer_1"), + lambda sdk: sdk.events.create_event_discount_code("ev_1", "X", "amount", amount_minor=5), + lambda sdk: sdk.holders.accept_holder_transfer("h_1", "tr_1"), + lambda sdk: sdk.seasons.create_season_renewal_intent("sea_1", "offer_1"), + lambda sdk: sdk.holders.decline_holder_renewal_offer("h_1", "offer_1", reason="Moving"), + ], + ids=[ + "resend-event", "resend-season", "rotate-link", "discount-code", "accept-transfer", + "renewal-intent", "holder-decline", + ], +) +def test_unsupported_and_domain_exact_mutations_are_single_attempt(call: Call) -> None: + calls: list[Any] = [] + sdk = SeatLayer("sk_test_abc", transport=_flaky(429, calls)) + with pytest.raises(SeatLayerError): + call(sdk) + assert len(calls) == 1 + assert calls[0].get_header("Idempotency-key") is None diff --git a/tests/test_seasons.py b/tests/test_seasons.py index bfe6c70..1065878 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -4,16 +4,26 @@ import io import json +import urllib.error from typing import Any +import pytest from typing_extensions import Self -from seatlayer import SeatLayer +from seatlayer import ( + SeatLayer, + SeatLayerConflictError, + SeatLayerError, + SeatLayerValidationError, + is_season_date_leave_pending, + season_date_cancel_void_job_id, + season_holder_import_rows_per_call, +) class FakeResponse(io.BytesIO): - def __init__(self, status: int = 200, body: Any = None) -> None: - super().__init__(json.dumps(body or {}).encode()) + def __init__(self, status: int = 200, body: Any = None, raw: bytes | None = None) -> None: + super().__init__(raw if raw is not None else json.dumps(body or {}).encode()) self.status = status def __enter__(self) -> Self: @@ -23,11 +33,18 @@ def __exit__(self, *args: object) -> None: self.close() -def test_maps_all_48_season_operations_and_replay_classes() -> None: +CSV = "holder,email\r\nA. Fan,fan@example.com\r\n" + + +def test_maps_all_90_season_operations_and_replay_classes() -> None: calls: list[Any] = [] def transport(request: Any, _timeout: float) -> FakeResponse: calls.append(request) + if ".csv" in request.full_url: + return FakeResponse(raw=CSV.encode()) + if request.method == "DELETE" and request.full_url.endswith("/plans/plan%2F1"): + return FakeResponse(204, raw=b"") return FakeResponse() seasons = SeatLayer("sk_test_abc", transport=transport).seasons @@ -80,8 +97,91 @@ def transport(request: Any, _timeout: float) -> FakeResponse: seasons.replay_season_outbox(key, "occurrence/1") seasons.list_season_audit(key) seasons.export_season_support_snapshot(key) + # The 32 operations added for season sales, holders, orders and reports. + seasons.create_season_holder_import_from_season( + key, "sea/prev", "pa_1", dry_run=True, idempotency_key="from-season-1" + ) + seasons.list_season_performances(key) + seasons.create_season_performances( + key, + "Match {n}", + "Europe/London", + "weekly", + first_starts_at=1767225600000, + count=3, + venue=None, + region="western-europe", + attach_to_plan_keys=["plan/1"], + idempotency_key="perf-1", + ) + seasons.attach_season_performances( + key, ["ev_1", "ev_2"], attach_to_plan_keys=["plan/1"], idempotency_key="attach-1" + ) + seasons.delete_season_performance(key, "ev/1", idempotency_key="perf-del-1") + seasons.update_season_plan( + key, "plan/1", name="Full season", event_keys=["ev_1"], idempotency_key="plan-upd-1" + ) + deleted_plan = 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", [{"categoryKey": "A", "unitPrice": 5000}], currency="GBP" + ) + seasons.list_season_prices(key) + seasons.attach_season_plan_performance(key, "plan/1", "ev/1") + seasons.delete_season_plan_performance(key, "plan/1", "ev/1") + seasons.mark_season_performance_past(key, "ev/1", force=True) + seasons.return_season_occurrence_inventory( + key, "ev/1", "ret_1", "order_1", "pa_1", ["A-1"], {"bookingActionId": "book_1"} + ) + seasons.reclaim_season_occurrence_inventory( + key, "ev/1", "rec_1", "ret_1", "order_1", "pa_1", ["A-1"], {"renewalOfferId": "offer_1"} + ) + seasons.list_season_holders(key, q="smith a/b", status="active", cursor="c/2", limit=25) + seasons.refund_season_order(key, "order/1", amount_minor=1500) + seasons.cancel_season_order(key, "order/1", "other", note="Duplicate", notify_buyer=False) + seasons.cancel_season_order_date(key, "order/1", "ev/1", action_id="date-cancel-1") + seasons.resend_season_order_tickets(key, "order/1", email="fan@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="fan@example.com", name=None) + 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) + holders_csv = seasons.export_season_holders_csv(key, q="smith", status="active") + orders_csv = seasons.export_season_orders_csv(key) + seasons.retrieve_season_seat_counts(key) + # The bulk refund of a cancelled date and the jobs that run it. + seasons.refund_season_performance( + key, "ev/1", "per_date", reason="Storm", idempotency_key="refund-date-1" + ) + seasons.release_season_lapsed_offers(key) + seasons.list_season_bulk_jobs(key) + seasons.retrieve_season_bulk_job(key, "job/1") + # Renewal by API: the quote, the phone or box office intent, and the checkout. + seasons.retrieve_season_renewal_quote(key, "offer/1") + seasons.create_season_renewal_intent(key, "offer/1") + seasons.create_season_renewal_checkout( + key, + "offer/1", + "https://tickets.example/renewed", + buyer_email="ana@example.com", + buyer_name="Ana Silva", + provider="stripe", + idempotency_key="renew-1", + ) + # Season seats: set aside, read, release (phase 4). + seasons.set_aside_season_block( + key, 7, keep_for_single_tickets={"labels": ["A-6"]}, idempotency_key="block-1" + ) + seasons.retrieve_season_block(key) + seasons.release_season_block( + key, 7, {"all": True}, event_keys=["ev/1"], at="2026-12-01T10:00:00Z", reason="Unsold" + ) - assert len(calls) == 48 + assert len(calls) == 90 actual = [(call.method, call.full_url.removeprefix("https://api.seatlayer.io")) for call in calls] assert actual == [ ("GET", "/v1/seasons?workspaceId=ws+1&structureState=draft&limit=20&cursor=c%2F1"), @@ -132,13 +232,529 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ("POST", "/v1/seasons/sea%2Fa/outbox/occurrence%2F1/replay"), ("GET", "/v1/seasons/sea%2Fa/audit"), ("GET", "/v1/seasons/sea%2Fa/export"), + ("POST", "/v1/seasons/sea%2Fa/imports/from-season"), + ("GET", "/v1/seasons/sea%2Fa/performances"), + ("POST", "/v1/seasons/sea%2Fa/performances"), + ("POST", "/v1/seasons/sea%2Fa/performances/attach"), + ("DELETE", "/v1/seasons/sea%2Fa/performances/ev%2F1"), + ("PATCH", "/v1/seasons/sea%2Fa/plans/plan%2F1"), + ("DELETE", "/v1/seasons/sea%2Fa/plans/plan%2F1"), + ("POST", "/v1/seasons/sea%2Fa/publish"), + ("POST", "/v1/seasons/sea%2Fa/checkout-tests"), + ("GET", "/v1/seasons/sea%2Fa/checkout-tests"), + ("PUT", "/v1/seasons/sea%2Fa/plans/plan%2F1/prices"), + ("GET", "/v1/seasons/sea%2Fa/prices"), + ("POST", "/v1/seasons/sea%2Fa/plans/plan%2F1/performances"), + ("DELETE", "/v1/seasons/sea%2Fa/plans/plan%2F1/performances/ev%2F1"), + ("POST", "/v1/seasons/sea%2Fa/performances/ev%2F1/past"), + ("POST", "/v1/seasons/sea%2Fa/occurrences/ev%2F1/returns"), + ("POST", "/v1/seasons/sea%2Fa/occurrences/ev%2F1/reclaims"), + ("GET", "/v1/seasons/sea%2Fa/holders?q=smith+a%2Fb&status=active&cursor=c%2F2&limit=25"), + ("POST", "/v1/seasons/sea%2Fa/orders/order%2F1/refund"), + ("POST", "/v1/seasons/sea%2Fa/orders/order%2F1/cancel"), + ("POST", "/v1/seasons/sea%2Fa/orders/order%2F1/dates/ev%2F1/cancel"), + ("POST", "/v1/seasons/sea%2Fa/orders/order%2F1/resend"), + ("GET", "/v1/seasons/sea%2Fa/renewal-hosted"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/link"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/contact"), + ("POST", "/v1/seasons/sea%2Fa/renewal-notices"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/resend"), + ("GET", "/v1/seasons/sea%2Fa/reports/sales"), + ("GET", "/v1/seasons/sea%2Fa/reports/seats"), + ("GET", "/v1/seasons/sea%2Fa/reports/holders.csv?q=smith&status=active"), + ("GET", "/v1/seasons/sea%2Fa/reports/orders.csv"), + ("GET", "/v1/seasons/sea%2Fa/seat-counts"), + ("POST", "/v1/seasons/sea%2Fa/performances/ev%2F1/refunds"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/release-lapsed"), + ("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/block"), + ("GET", "/v1/seasons/sea%2Fa/block"), + ("POST", "/v1/seasons/sea%2Fa/block/release"), ] assert calls[26].data is None - replay_indexes = {2, 4, 5, 10, 18, 27, 29, 38} + bodies = [None if call.data is None else json.loads(call.data) for call in calls[48:]] + assert bodies == [ + {"sourceSeasonId": "sea/prev", "successorPlanActivationId": "pa_1", "dryRun": True}, + None, + { + "titlePattern": "Match {n}", + "timezone": "Europe/London", + "repeat": "weekly", + "firstStartsAt": 1767225600000, + "count": 3, + "venue": None, + "region": "western-europe", + "attachToPlanKeys": ["plan/1"], + }, + {"eventKeys": ["ev_1", "ev_2"], "attachToPlanKeys": ["plan/1"]}, + None, + {"name": "Full season", "eventKeys": ["ev_1"]}, + None, + None, + {"planKey": "plan/1", "quantity": 2}, + None, + {"prices": [{"categoryKey": "A", "unitPrice": 5000}], "currency": "GBP"}, + None, + {"eventKey": "ev/1"}, + None, + {"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": "offer_1"}, + }, + None, + {"amountMinor": 1500}, + {"reason": "other", "note": "Duplicate", "notifyBuyer": False}, + {"actionId": "date-cancel-1"}, + {"email": "fan@example.com"}, + None, + None, + {"email": "fan@example.com", "name": None}, + {"offerIds": ["offer_1"]}, + None, + None, + None, + None, + None, + None, + {"amount": "per_date", "reason": "Storm"}, + None, + None, + None, + None, + None, + { + "returnUrl": "https://tickets.example/renewed", + "buyerEmail": "ana@example.com", + "buyerName": "Ana Silva", + "provider": "stripe", + }, + {"expectedRevision": 7, "keepForSingleTickets": {"labels": ["A-6"]}}, + None, + { + "expectedRevision": 7, + "scope": {"all": True}, + "eventKeys": ["ev/1"], + "at": "2026-12-01T10:00:00Z", + "reason": "Unsold", + }, + ] + + # CSV exports hand back the body as text; the 204 delete hands back nothing. + assert holders_csv == CSV + assert orders_csv == CSV + assert deleted_plan is None + assert calls[77].get_header("Accept") == "text/csv, application/json" + + caller_keys = { + 48: "from-season-1", 50: "perf-1", 51: "attach-1", 52: "perf-del-1", 53: "plan-upd-1", + 80: "refund-date-1", 86: "renew-1", 87: "block-1", + } + for index, expected_key in caller_keys.items(): + assert calls[index].get_header("Idempotency-key") == expected_key + + 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, 81, 86, 87, 89, + } for index, request in enumerate(calls): headers = {name.lower(): value for name, value in request.header_items()} if index in replay_indexes: assert "idempotency-key" in headers else: assert "idempotency-key" not in headers + + +def test_season_optional_bodies_are_sent_only_when_set() -> None: + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse() + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + seasons.create_season_checkout_test("sea_1") + seasons.refund_season_order("sea_1", "ord_1") + seasons.cancel_season_order_date("sea_1", "ord_1", "ev_1") + seasons.resend_season_order_tickets("sea_1", "ord_1") + seasons.create_season_renewal_notices("sea_1") + # Required bodies whose fields are all optional still send an object. + seasons.mark_season_performance_past("sea_1", "ev_1") + seasons.update_season_plan("sea_1", "plan_1") + seasons.update_season_renewal_offer_contact("sea_1", "offer_1") + + assert [call.data for call in calls[:5]] == [None] * 5 + assert [json.loads(call.data) for call in calls[5:]] == [{}, {}, {}] + + +def test_price_types_sale_settings_holder_contacts_and_date_cancels_reach_the_wire() -> None: + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse() + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + seasons.replace_season_package_prices( + "sea_1", + "plan_1", + [ + {"categoryKey": "stalls", "unitPrice": 18000}, + {"categoryKey": "stalls", "priceTypeKey": "child", "unitPrice": 9000}, + ], + ) + seasons.update_season( + "sea_1", 3, checkout_mode="seatlayer", transfer_cutoff_hours=24, return_cutoff_hours=None, + audience="public", + ) + seasons.update_season("sea_1", 4, name="Renamed") + seasons.create_season_holder_import( + "sea_1", + "pa_1", + [{ + "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", "ev_night_3", "cancel", refund="per_date", reason="Storm", + idempotency_key="night-3-storm", + ) + seasons.create_season_amendment("sea_1", "ev_1", "postpone") + seasons.refund_season_performance("sea_1", "ev_1", "per_date") + + assert [json.loads(call.data) for call in calls] == [ + {"prices": [ + {"categoryKey": "stalls", "unitPrice": 18000}, + {"categoryKey": "stalls", "priceTypeKey": "child", "unitPrice": 9000}, + ]}, + { + "expectedRevision": 3, "checkoutMode": "seatlayer", "audience": "public", + "transferCutoffHours": 24, "returnCutoffHours": None, + }, + {"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"}, + {"amount": "per_date"}, + ] + assert calls[4].get_header("Idempotency-key") == "night-3-storm" + # A refund without a caller key still gets one, so a retry replays the same job. + assert calls[6].get_header("Idempotency-key") + + + +def test_renewal_by_api_decline_reason_and_checkout_body_reach_the_wire() -> None: + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse() + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + seasons.decline_season_renewal_offer("sea/a", "offer/1", reason="Moving away") + seasons.decline_season_renewal_offer("sea_1", "offer_1") + seasons.create_season_renewal_checkout("sea_1", "offer_1", "https://tickets.example/renewed") + + assert [call.full_url.removeprefix("https://api.seatlayer.io") for call in calls] == [ + "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/decline", + "/v1/seasons/sea_1/renewal-offers/offer_1/decline", + "/v1/seasons/sea_1/renewal-offers/offer_1/checkout", + ] + assert [json.loads(call.data) for call in calls] == [ + {"reason": "Moving away"}, + {}, + {"returnUrl": "https://tickets.example/renewed"}, + ] + assert calls[0].get_header("Idempotency-key") is None + assert calls[1].get_header("Idempotency-key") is None + # A checkout without a caller key still gets one, so a retry answers the same order. + assert calls[2].get_header("Idempotency-key") + + +def test_renewal_offer_lists_page_and_bulk_answers_come_back_as_sent() -> None: + calls: list[Any] = [] + answers = [ + FakeResponse(body={"offers": [], "holdersWithoutEmail": 0, "nextCursor": "c/2"}), + FakeResponse(body={"offers": [], "truncated": False, "nextCursor": None}), + FakeResponse(202, {"job": {"jobId": "sbj_1", "kind": "renewal_run"}, "batchId": "srb_1"}), + FakeResponse(202, {"job": {"jobId": "sbj_2", "kind": "holder_import"}}), + FakeResponse(202, {"job": {"jobId": "sbj_3", "kind": "lapsed_release"}}), + FakeResponse(), + FakeResponse(), + ] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return answers.pop(0) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + hosted = seasons.retrieve_season_hosted_renewals("sea/a", cursor="c/1", limit=500) + offers = seasons.list_season_renewal_offers("sea/a", cursor="c/1", limit=100) + run = seasons.create_season_renewal_offers("sea/a", 123, idempotency_key="offers-1") + brought = seasons.create_season_holder_import_from_season("sea/a", "sea/prev", "pa_1") + released = seasons.release_season_lapsed_offers("sea/a", idempotency_key="release-1") + + assert [(call.method, call.full_url.removeprefix("https://api.seatlayer.io")) for call in calls] == [ + ("GET", "/v1/seasons/sea%2Fa/renewal-hosted?cursor=c%2F1&limit=500"), + ("GET", "/v1/seasons/sea%2Fa/renewal-offers?cursor=c%2F1&limit=100"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers"), + ("POST", "/v1/seasons/sea%2Fa/imports/from-season"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/release-lapsed"), + ] + assert hosted["nextCursor"] == "c/2" + assert offers["nextCursor"] is None + assert run == {"job": {"jobId": "sbj_1", "kind": "renewal_run"}, "batchId": "srb_1"} + assert brought == {"job": {"jobId": "sbj_2", "kind": "holder_import"}} + assert released == {"job": {"jobId": "sbj_3", "kind": "lapsed_release"}} + assert calls[4].data is None + assert calls[4].get_header("Idempotency-key") == "release-1" + # Without paging arguments the lists send no query. + seasons.retrieve_season_hosted_renewals("sea_1") + seasons.list_season_renewal_offers("sea_1") + assert [call.full_url for call in calls[5:]] == [ + "https://api.seatlayer.io/v1/seasons/sea_1/renewal-hosted", + "https://api.seatlayer.io/v1/seasons/sea_1/renewal-offers", + ] + + +def test_renewal_notices_for_one_offer_batch_send_its_batch_id() -> None: + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse(body={"scheduled": 240, "withoutEmail": 3}) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + sent = seasons.create_season_renewal_notices("sea/a", batch_id="srb/1") + + assert sent == {"scheduled": 240, "withoutEmail": 3} + assert calls[0].method == "POST" + assert calls[0].full_url == "https://api.seatlayer.io/v1/seasons/sea%2Fa/renewal-notices" + assert json.loads(calls[0].data) == {"batchId": "srb/1"} + assert calls[0].get_header("Idempotency-key") is None + + +def test_single_tickets_reach_the_wire_on_create_and_update() -> None: + calls: list[Any] = [] + updated = { + "season": {"key": "sea_1", "singleTickets": True}, + "singleTicketDates": { + "direction": "open", + "opened": ["ev_1"], + "closed": [], + "notOpened": [], + "jobId": None, + }, + } + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse(body=updated) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + seasons.create_season("Series", event_keys=["ev_1", "ev_2"], single_tickets=True) + answer = seasons.update_season("sea/a", 5, single_tickets=False) + seasons.update_season("sea/a", 6, name="Series 2") + + assert [(call.method, call.full_url.removeprefix("https://api.seatlayer.io")) for call in calls] == [ + ("POST", "/v1/seasons"), + ("PATCH", "/v1/seasons/sea%2Fa"), + ("PATCH", "/v1/seasons/sea%2Fa"), + ] + assert json.loads(calls[0].data) == { + "eventKeys": ["ev_1", "ev_2"], + "name": "Series", + "singleTickets": True, + } + assert json.loads(calls[1].data) == {"expectedRevision": 5, "singleTickets": False} + assert json.loads(calls[2].data) == {"expectedRevision": 6, "name": "Series 2"} + assert answer["singleTicketDates"]["opened"] == ["ev_1"] + + +def test_inventory_mode_reaches_the_wire_only_when_given_and_reads_back() -> None: + calls: list[Any] = [] + block_season = {"key": "sea_1", "revision": 5, "inventoryMode": "block"} + per_hold_season = {"key": "sea_2", "revision": 1, "inventoryMode": "per_hold"} + answers: list[Any] = [ + {"season": block_season}, + {"season": per_hold_season}, + {"season": block_season}, + {"season": per_hold_season}, + {"season": per_hold_season}, + {"season": block_season}, + {"seasons": [block_season, per_hold_season], "nextCursor": None}, + ] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + return FakeResponse(body=answers.pop(0)) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + created = seasons.create_season("Series", event_keys=["ev_1", "ev_2"], inventory_mode="block") + seasons.create_season("Series", event_keys=["ev_1", "ev_2"]) + turned_on = seasons.update_season("sea_1", 5, inventory_mode="block") + seasons.update_season("sea_1", 5, inventory_mode="per_hold") + seasons.update_season("sea_1", 5, name="Series 2") + retrieved = seasons.retrieve_season("sea_1") + listed = seasons.list_seasons() + + assert [(call.method, call.full_url.removeprefix("https://api.seatlayer.io")) for call in calls] == [ + ("POST", "/v1/seasons"), + ("POST", "/v1/seasons"), + ("PATCH", "/v1/seasons/sea_1"), + ("PATCH", "/v1/seasons/sea_1"), + ("PATCH", "/v1/seasons/sea_1"), + ("GET", "/v1/seasons/sea_1"), + ("GET", "/v1/seasons"), + ] + assert json.loads(calls[0].data) == { + "eventKeys": ["ev_1", "ev_2"], + "name": "Series", + "inventoryMode": "block", + } + assert json.loads(calls[1].data) == {"eventKeys": ["ev_1", "ev_2"], "name": "Series"} + assert json.loads(calls[2].data) == {"expectedRevision": 5, "inventoryMode": "block"} + assert json.loads(calls[3].data) == {"expectedRevision": 5, "inventoryMode": "per_hold"} + assert json.loads(calls[4].data) == {"expectedRevision": 5, "name": "Series 2"} + assert created["season"]["inventoryMode"] == "block" + assert turned_on["season"]["inventoryMode"] == "block" + assert retrieved["season"]["inventoryMode"] == "block" + assert [season["inventoryMode"] for season in listed["seasons"]] == ["block", "per_hold"] + assert answers == [] + + +def test_inventory_mode_refusals_reach_the_caller_with_their_code() -> None: + refusals = [ + (409, "season_block_packages"), + (409, "season_block_on_sale"), + (409, "season_not_draft_or_active"), + (409, "season_block_exists"), + (422, "invalid_inventory_mode"), + ] + answers: list[Any] = [ + (status, {"error": code, "code": code, "message": code}) for status, code in refusals + ] + answers.append((409, {"error": "block_not_ready", "code": "block_not_ready"})) + + def transport(request: Any, _timeout: float) -> FakeResponse: + status, body = answers.pop(0) + raise _error(request, status, body) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + for status, code in refusals: + with pytest.raises(SeatLayerError) as raised: + seasons.update_season("sea_1", 5, inventory_mode="block") + assert raised.value.status == status + assert raised.value.code == code + with pytest.raises(SeatLayerConflictError) as not_ready: + seasons.open_season_sales("sea_1", 5) + assert not_ready.value.code == "block_not_ready" + assert answers == [] + + +def test_names_the_cancelled_date_void_job_and_sizes_a_holder_import_call() -> None: + assert season_date_cancel_void_job_id("amd_1") == "sbj_void_amd_1" + assert [season_holder_import_rows_per_call(dates) for dates in (1, 20, 21, 50)] == [ + 100, 100, 95, 43, + ] + + calls: list[Any] = [] + + def transport(request: Any, _timeout: float) -> FakeResponse: + calls.append(request) + raise urllib.error.HTTPError( + request.full_url, + 422, + "error", + {}, # type: ignore[arg-type] + io.BytesIO(json.dumps({ + "error": "invalid_rows", "code": "invalid_rows", + "message": "At most 43 rows a call for this package.", "maxRows": 43, + }).encode()), + ) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + with pytest.raises(SeatLayerValidationError) as raised: + seasons.create_season_holder_import("sea_1", "spa_1", []) + assert raised.value.status == 422 + assert raised.value.code == "invalid_rows" + assert raised.value.body["maxRows"] == 43 + # A 422 is the request itself, so even this header-replay call is sent once. + assert len(calls) == 1 + + +def _error(request: Any, status: int, body: dict[str, Any]) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + request.full_url, status, "error", {}, # type: ignore[arg-type] + io.BytesIO(json.dumps(body).encode()), + ) + + +def test_season_seats_answers_reach_the_caller() -> None: + """The phase 4 answers, shaped as the API sends them (the app repo's contract §2, §6 and §8).""" + job = { + "jobId": "sbsa_sbp_pkg_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, "counts": {"setAside": 5}} + pending = { + "state": "finishing", "code": "block_date_leave_pending", "retryable": True, + "eventKey": "ev_1", "shardsLeft": [1], "shardCount": 2, "message": "Still leaving one shard", + } + answers: list[Any] = [ + (202, {"job": job, "block": block}), + (200, {"job": {**job, "kind": "season_block_release", "jobId": "sbr_1"}}), + (202, pending), + (202, pending), + ] + refusals = [ + "season_block_not_enabled", "block_exists", "season_block_set_aside", + "season_inventory_locked", "date_leave_in_progress", + ] + answers += [(409, {"error": code, "code": code, "message": code}) for code in refusals] + + def transport(request: Any, _timeout: float) -> FakeResponse: + status, body = answers.pop(0) + if status >= 400: + raise _error(request, status, body) + return FakeResponse(status, body) + + seasons = SeatLayer("sk_test_abc", transport=transport).seasons + set_aside = seasons.set_aside_season_block("sea_1", 7) + assert set_aside == {"job": job, "block": block} + # A job of a kind this SDK did not know before reads as any other. + assert seasons.retrieve_season_bulk_job("sea_1", "sbr_1")["job"]["kind"] == "season_block_release" + cancel = seasons.create_season_amendment("sea_1", "ev_1", "cancel") + past = seasons.mark_season_performance_past("sea_1", "ev_1") + assert is_season_date_leave_pending(cancel) and is_season_date_leave_pending(past) + assert not is_season_date_leave_pending({"amendment": {"amendmentId": "am_1"}}) + assert not is_season_date_leave_pending(None) + assert not is_season_date_leave_pending({"state": "finishing", "code": "season_busy"}) + assert not is_season_date_leave_pending({"state": "done", "code": "block_date_leave_pending"}) + for code in refusals: + with pytest.raises(SeatLayerConflictError) as raised: + seasons.release_season_block("sea_1", 7, {"labels": ["A-1"]}) + assert raised.value.code == code + assert answers == []