From 7db65df729fae6d0f7e58bf305b69d65fa9431dc Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:55:07 +0530 Subject: [PATCH 01/14] Season 2a operations: 32 more season methods, holders, orders, event sales and event series --- src/seatlayer/client.py | 6 + src/seatlayer/http.py | 29 +- src/seatlayer/resources.py | 977 +++++++++++++++++++++++++++- src/seatlayer/types.py | 10 + tests/test_holders_orders_series.py | 533 +++++++++++++++ tests/test_seasons.py | 200 +++++- 6 files changed, 1748 insertions(+), 7 deletions(-) create mode 100644 tests/test_holders_orders_series.py 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..0ae9502 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -6,7 +6,7 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence +from collections.abc import Iterable, Iterator, Sequence from typing import Any, Literal, cast from .http import HttpClient, quote @@ -38,6 +38,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 +57,37 @@ 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 _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. @@ -457,6 +499,158 @@ 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.""" + 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. @@ -1092,6 +1286,16 @@ class Webhooks: "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", + "season.ticket.transfer_cancelled", }) def __init__(self, http: HttpClient) -> None: @@ -1758,3 +1962,774 @@ 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.""" + 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. + """ + 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.""" + 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.""" + 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) -> Any: + """Retrieve renewal links, payments and emails for each offer.""" + return self._http.get(self._path(season_key, "/renewal-hosted")) + + 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 + ) -> Any: + """Email renewal invitations now and reminders before the deadline.""" + body = _fields((("offerIds", None if offer_ids is None else list(offer_ids)),)) + 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_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")) + + +class Holders: + """Season ticket holders, their tickets and transfers, and the customers behind them. + + 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.""" + 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") + ) + + +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, + ) -> 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``. + """ + return self._http.get("/v1/orders", query={ + "eventKey": event_key, + "seasonKey": season_key, + "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, + chart_key: str, + schedule: dict[str, Any], + timezone: str | None = None, + venue: str | None = None, + duration_minutes: int | None = None, + dry_run: bool | None = None, + idempotency_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. + """ + body = _fields(( + ("timezone", timezone), + ("venue", venue), + ("durationMinutes", duration_minutes), + ("dryRun", dry_run), + )) + body.update({"name": name, "chartKey": chart_key, "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, + ) -> Any: + """Add dates to an event series, copied from its first date. + + Give exactly one of ``schedule`` (as for ``create_event_series``) or + ``dates``, exact moments as ``"YYYY-MM-DDTHH:MM"`` in the series' zone. + """ + # The API takes one shape or the other; failing here names the mistake + # instead of a 400 after the round trip. + if (schedule is None) == (dates is None): + raise ValueError("give exactly one of schedule or dates") + body: dict[str, Any] = ( + {"schedule": schedule} if schedule is not None else {"dates": list(dates or ())} + ) + 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. + + ``sales="on"`` or ``"off"`` opens or pauses sales on every upcoming date, + each with its own go-live checks. + """ + 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..e33cc69 100644 --- a/src/seatlayer/types.py +++ b/src/seatlayer/types.py @@ -13,6 +13,16 @@ "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", + "season.ticket.transfer_cancelled", ] ManageCapability = Literal[ diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py new file mode 100644 index 0000000..ef38dc8 --- /dev/null +++ b/tests/test_holders_orders_series.py @@ -0,0 +1,533 @@ +"""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, 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, + ), + # orders + ( + "listOrders", + lambda sdk: sdk.orders.list_orders( + season_key="sea/1", status="confirmed", from_=1, to=2, cursor="c/1", limit=50 + ), + "GET", + "/v1/orders?seasonKey=sea%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)) == 39 # 11 + 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.event_series.update_event_series("ev_1") + assert [call.data for call in calls[:3]] == [None, None, None] + # updateEventSeries requires a body, even an empty one. + assert json.loads(calls[3].data) == {} + + +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"]} + ) + assert len(calls) == 1 + assert json.loads(calls[0].data) == { + "schedule": {"rule": "daily", "from": "2026-12-01", "count": 2, "times": ["20:00"]} + } + + +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") + + +@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"), + ], + ids=["resend-event", "resend-season", "rotate-link", "discount-code", "accept-transfer"], +) +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..445c0a1 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -12,8 +12,8 @@ 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 +23,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_80_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 +87,64 @@ 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) - assert len(calls) == 48 + assert len(calls) == 80 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 +195,140 @@ 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"), ] 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, + ] + + # 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", + } + 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, + } 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:]] == [{}, {}, {}] From 3b9b315b344e7156657714799b75d24bd14edfb7 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:55:07 +0530 Subject: [PATCH 02/14] Docs: README and changelog for the 71 new operations --- CHANGELOG.md | 18 +++++++++++ README.md | 89 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fea0f8..d1d2777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased + +- Added 32 Season operations to `seatlayer.seasons` (now 80): performances, package prices, + plan edits and publish, checkout tests, occurrence returns and reclaims, holders, season order + refunds and cancellations, hosted renewals and renewal emails, sales and seat reports, and the + holders and orders CSV exports. +- 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 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. 22 of the new mutations use exact header replay + (36 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`. + ## 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..265292b 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,17 @@ 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 80 trusted organizer operations: catalogue and packages, +performances, package prices, sales, checkout tests, buyer access and booking, holder imports, +holders, season orders, renewals and hosted renewals, occurrences, 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 +115,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 +123,37 @@ if os.environ.get("ENV") == "production" and seatlayer.mode != "live": raise RuntimeError("Refusing to boot production against test-mode seating data.") ``` +## 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 @@ -266,7 +300,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 +357,22 @@ 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. Thirty-six 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`, and +`cancel_season_order_date`; `holders.update_holder` and `holders.create_holder_transfer`; +`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 +383,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 36 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -355,21 +400,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` | 80 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals and hosted renewals, occurrence amendments, returns and reclaims, 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` | +| `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 +425,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()`. From bdb53c3bc357ba6dbbf466d1bc0069f21913d122 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 20:43:34 +0530 Subject: [PATCH 03/14] Holders: createHolderReturn (give back one seat on one date) --- CHANGELOG.md | 5 +++-- README.md | 10 ++++----- src/seatlayer/resources.py | 19 ++++++++++++++++- tests/test_holders_orders_series.py | 32 ++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d2777..e458567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,11 @@ `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. 22 of the new mutations use exact header replay - (36 in all); the rest are sent once. CSV exports return text; the two 204 deletes return `None`. +- Method names are the snake_case operation ids. 23 of the new mutations use exact header replay + (37 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`. diff --git a/README.md b/README.md index 265292b..f515a16 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Thirty-six +**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Thirty-seven mutations opt into exact header replay: `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, `workspaces.create`, `performance_groups.create`; on `seasons`, `create_season`, `update_season`, `delete_season`, @@ -368,8 +368,8 @@ mutations opt into exact header replay: `charts.create`, `charts.copy`, `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`, and -`cancel_season_order_date`; `holders.update_holder` and `holders.create_holder_transfer`; -`orders.refund_event_order` and `orders.cancel_event_order`; and +`cancel_season_order_date`; `holders.update_holder`, `holders.create_holder_transfer`, and +`holders.create_holder_return`; `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 @@ -383,7 +383,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the 36 exact-header-replay mutations + max_retries=3, # attempts for reads and the 37 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -415,7 +415,7 @@ holder, order, renewal, occurrence, reporting, export, outbox, and support opera | `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` | 80 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals and hosted renewals, occurrence amendments, returns and reclaims, 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` | +| `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` | | `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` | diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 0ae9502..8f2e495 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -2385,7 +2385,7 @@ def retrieve_season_seat_counts(self, season_key: str) -> Any: class Holders: - """Season ticket holders, their tickets and transfers, and the customers behind them. + """Season ticket holders, their tickets, transfers and returns, and the customers behind them. Method names are the snake_case form of the public operation ids. """ @@ -2500,6 +2500,23 @@ def accept_holder_transfer(self, holder_ref: str, transfer_id: str) -> Any: 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. + """ + return self._http.post_with_header_replay( + self._path(holder_ref, "/returns"), + body={"ticketIds": list(ticket_ids)}, + idempotency_key=idempotency_key, + ) + class Orders: """Orders SeatLayer took, across events and seasons, and one event's orders. diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py index ef38dc8..62dd44c 100644 --- a/tests/test_holders_orders_series.py +++ b/tests/test_holders_orders_series.py @@ -147,6 +147,16 @@ def transport(request: Any, _timeout: float) -> FakeResponse: None, False, ), + ( + "createHolderReturn", + lambda sdk: sdk.holders.create_holder_return( + "a/b", ["tkt_0123456789abcdef01234567"] + ), + "POST", + "/v1/holders/a%2Fb/returns", + {"ticketIds": ["tkt_0123456789abcdef01234567"]}, + True, + ), # orders ( "listOrders", @@ -409,7 +419,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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)) == 39 # 11 + 9 + 14 + 5 + assert len(operation_ids) == len(set(operation_ids)) == 40 # 12 + 9 + 14 + 5 @pytest.mark.parametrize( @@ -513,6 +523,26 @@ def test_header_replay_mutations_retry_with_one_key() -> None: 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"] + + @pytest.mark.parametrize( "call", [ From 172791a7b3287d856c7eca87211359b31f69f53f Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:04:19 +0530 Subject: [PATCH 04/14] Seasons: bulk refund and jobs, price types, postpone and cancel, sale settings, all 56 webhook names, events series_key --- CHANGELOG.md | 23 ++++++++--- README.md | 21 +++++----- src/seatlayer/resources.py | 80 +++++++++++++++++++++++++++++++++++--- src/seatlayer/types.py | 46 ++++++++++++++++++++-- tests/test_client.py | 19 +++++++++ tests/test_seasons.py | 80 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 243 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e458567..6d8fedb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Added 32 Season operations to `seatlayer.seasons` (now 80): performances, package prices, +- Added 35 Season operations to `seatlayer.seasons` (now 83): performances, package prices, plan edits and publish, checkout tests, occurrence returns and reclaims, holders, season order - refunds and cancellations, hosted renewals and renewal emails, sales and seat reports, and the - holders and orders CSV exports. + refunds and cancellations, hosted renewals and renewal emails, sales and seat reports, the + holders and orders CSV exports, and bulk refunds with their jobs. - 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, @@ -13,11 +13,24 @@ - 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. 23 of the new mutations use exact header replay - (37 in all); the rest are sent once. CSV exports return text; the two 204 deletes return `None`. +- Method names are the snake_case operation ids. 24 of the new mutations use exact header replay + (38 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. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index f515a16..de4d752 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,11 @@ live ones; crossing them returns `403 mode_mismatch`, surfaced as `SeatLayerAuth ## Fixed Renewable Seasons -`seatlayer.seasons` exposes all 80 trusted organizer operations: catalogue and packages, -performances, package prices, sales, checkout tests, buyer access and booking, holder imports, -holders, season orders, renewals and hosted renewals, occurrences, reports, CSV exports, audit, -outbox, and support export. +`seatlayer.seasons` exposes all 83 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 and hosted renewals, occurrences (postpone and cancel a date), bulk refunds of a +cancelled date 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 @@ -226,6 +227,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 @@ -357,7 +360,7 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Thirty-seven +**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Thirty-eight mutations opt into exact header replay: `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, `workspaces.create`, `performance_groups.create`; on `seasons`, `create_season`, `update_season`, `delete_season`, @@ -367,8 +370,8 @@ mutations opt into exact header replay: `charts.create`, `charts.copy`, `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`, and -`cancel_season_order_date`; `holders.update_holder`, `holders.create_holder_transfer`, and +`create_season_amendment`, `refund_season_order`, `cancel_season_order`, +`cancel_season_order_date`, and `refund_season_performance`; `holders.update_holder`, `holders.create_holder_transfer`, and `holders.create_holder_return`; `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 @@ -383,7 +386,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the 37 exact-header-replay mutations + max_retries=3, # attempts for reads and the 38 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -414,7 +417,7 @@ holder, order, renewal, occurrence, reporting, export, outbox, and support opera | `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` | 80 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals and hosted renewals, occurrence amendments, returns and reclaims, reports, CSV exports, audit, outbox, and support export | +| `seasons` | 83 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals and hosted renewals, occurrence amendments, returns and reclaims, bulk refunds 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` | | `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` | diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 8f2e495..ba4c587 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -284,19 +284,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" @@ -308,6 +310,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. @@ -316,7 +319,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: @@ -1629,12 +1632,29 @@ 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, ) -> 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 until it starts. + """ 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),), + ( + ("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 ) @@ -1819,6 +1839,12 @@ 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``. + """ body: dict[str, Any] = { "successorPlanActivationId": successor_plan_activation_id, "rows": list(rows), @@ -1908,16 +1934,26 @@ 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", "full"] | 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`` refunds the date's + SeatLayer orders as a job (the answer's ``refundJob``), and ``reason`` + is kept on the job. + """ 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, @@ -2118,7 +2154,9 @@ def replace_season_package_prices( """Replace a package's price table. Each price is ``{"categoryKey": ..., "unitPrice": ...}`` with the unit - price in minor units. + 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: @@ -2131,7 +2169,10 @@ def replace_season_package_prices( ) def list_season_prices(self, season_key: str) -> Any: - """List every package price table of the Season.""" + """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( @@ -2383,6 +2424,35 @@ 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", "full"], + 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`` refunds what is left of each order. 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 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)}")) + class Holders: """Season ticket holders, their tickets, transfers and returns, and the customers behind them. diff --git a/src/seatlayer/types.py b/src/seatlayer/types.py index e33cc69..e8339de 100644 --- a/src/seatlayer/types.py +++ b/src/seatlayer/types.py @@ -13,16 +13,54 @@ "hold.extended", "event.created", "event.soldout", - "series.created", - "series.dates_added", + "object.resale.listed", + "object.resale.unlisted", + "object.resale.sold", + "customer.created", + "customer.email_changed", "order.paid", "order.refunded", "order.cancelled", - "customer.created", - "customer.email_changed", "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", + "series.created", + "series.dates_added", ] ManageCapability = Literal[ diff --git a/tests/test_client.py b/tests/test_client.py index dcfa70a..d044e32 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -371,6 +371,17 @@ 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", "full") + + 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_template_instantiate_retries_but_release_mutations_do_not(self) -> None: template, template_calls = make_client([ {"status": 503, "body": {"error": "unavailable"}}, @@ -524,6 +535,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) diff --git a/tests/test_seasons.py b/tests/test_seasons.py index 445c0a1..a0d44ea 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -26,7 +26,7 @@ def __exit__(self, *args: object) -> None: CSV = "holder,email\r\nA. Fan,fan@example.com\r\n" -def test_maps_all_80_season_operations_and_replay_classes() -> None: +def test_maps_all_83_season_operations_and_replay_classes() -> None: calls: list[Any] = [] def transport(request: Any, _timeout: float) -> FakeResponse: @@ -143,8 +143,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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.list_season_bulk_jobs(key) + seasons.retrieve_season_bulk_job(key, "job/1") - assert len(calls) == 80 + assert len(calls) == 83 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"), @@ -227,6 +233,9 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ("GET", "/v1/seasons/sea%2Fa/reports/holders.csv?q=smith&status=active"), ("GET", "/v1/seasons/sea%2Fa/reports/orders.csv"), ("GET", "/v1/seasons/sea%2Fa/seat-counts"), + ("POST", "/v1/seasons/sea%2Fa/performances/ev%2F1/refunds"), + ("GET", "/v1/seasons/sea%2Fa/jobs"), + ("GET", "/v1/seasons/sea%2Fa/jobs/job%2F1"), ] assert calls[26].data is None @@ -286,6 +295,9 @@ def transport(request: Any, _timeout: float) -> FakeResponse: None, None, None, + {"amount": "per_date", "reason": "Storm"}, + None, + None, ] # CSV exports hand back the body as text; the 204 delete hands back nothing. @@ -296,13 +308,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: caller_keys = { 48: "from-season-1", 50: "perf-1", 51: "attach-1", 52: "perf-del-1", 53: "plan-upd-1", + 80: "refund-date-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, + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, } for index, request in enumerate(calls): headers = {name.lower(): value for name, value in request.header_items()} @@ -332,3 +345,64 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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 + ) + 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", "full") + + assert [json.loads(call.data) for call in calls] == [ + {"prices": [ + {"categoryKey": "stalls", "unitPrice": 18000}, + {"categoryKey": "stalls", "priceTypeKey": "child", "unitPrice": 9000}, + ]}, + { + "expectedRevision": 3, "checkoutMode": "seatlayer", + "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": "full"}, + ] + 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") + From 21e53d47538e5ef9a7904df7a682338fc0686c86 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:27:46 +0530 Subject: [PATCH 05/14] Seasons and holders: renewal by API (2.20) --- CHANGELOG.md | 17 +++-- README.md | 37 +++++++--- src/seatlayer/resources.py | 104 +++++++++++++++++++++++++++- tests/test_holders_orders_series.py | 66 ++++++++++++++++-- tests/test_seasons.py | 59 ++++++++++++++-- 5 files changed, 259 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8fedb..6b4ee07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ ## Unreleased -- Added 35 Season operations to `seatlayer.seasons` (now 83): performances, package prices, +- Added 38 Season operations to `seatlayer.seasons` (now 86): performances, package prices, plan edits and publish, checkout tests, occurrence returns and reclaims, holders, season order - refunds and cancellations, hosted renewals and renewal emails, sales and seat reports, the - holders and orders CSV exports, and bulk refunds with their jobs. + refunds and cancellations, hosted renewals and renewal emails, renewal quotes, intents and + checkouts, sales and seat reports, the holders and orders CSV exports, and bulk refunds with + their jobs. - 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, @@ -13,8 +14,8 @@ - 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. 24 of the new mutations use exact header replay - (38 in all); the rest are sent once. CSV exports return text; the two 204 deletes return `None`. +- Method names are the snake_case operation ids. 26 of the new mutations use exact header replay + (40 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`. @@ -31,6 +32,12 @@ `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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index de4d752..409bc55 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,10 @@ live ones; crossing them returns `403 mode_mismatch`, surfaced as `SeatLayerAuth ## Fixed Renewable Seasons -`seatlayer.seasons` exposes all 83 trusted organizer operations: catalogue and packages, +`seatlayer.seasons` exposes all 86 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 and hosted renewals, occurrences (postpone and cancel a date), bulk refunds of a +orders, renewals, hosted renewals and renewal checkouts, occurrences (postpone and cancel a date), bulk refunds of a cancelled date and their jobs, reports, CSV exports, audit, outbox, and support export. After the test hold/book/cancel journey and matching webhook deliveries, @@ -124,6 +124,26 @@ 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") +``` + ## Holders, orders, sales settings and event series Three more resources sit next to `seasons`: `holders` (season ticket holders, their tickets and @@ -360,7 +380,7 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Thirty-eight +**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Forty mutations opt into exact header replay: `charts.create`, `charts.copy`, `templates.instantiate_template`, `events.create`, `workspaces.create`, `performance_groups.create`; on `seasons`, `create_season`, `update_season`, `delete_season`, @@ -371,8 +391,9 @@ mutations opt into exact header replay: `charts.create`, `charts.copy`, `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`, and `refund_season_performance`; `holders.update_holder`, `holders.create_holder_transfer`, and -`holders.create_holder_return`; `orders.refund_event_order` and `orders.cancel_event_order`; and +`cancel_season_order_date`, `refund_season_performance`, 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 @@ -386,7 +407,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the 38 exact-header-replay mutations + max_retries=3, # attempts for reads and the 40 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -417,8 +438,8 @@ holder, order, renewal, occurrence, reporting, export, outbox, and support opera | `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` | 83 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holder imports, holders, season orders, renewals and hosted renewals, occurrence amendments, returns and reclaims, bulk refunds 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` | +| `seasons` | 86 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 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` | diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index ba4c587..059e360 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -71,6 +71,21 @@ def _fields( 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, @@ -1917,9 +1932,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: @@ -2396,6 +2419,46 @@ def resend_season_renewal_offer(self, season_key: str, offer_id: str) -> Any: 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")) @@ -2455,7 +2518,7 @@ def retrieve_season_bulk_job(self, season_key: str, job_id: str) -> Any: class Holders: - """Season ticket holders, their tickets, transfers and returns, and the customers behind them. + """Season ticket holders, their tickets, transfers, returns and renewals, and their customers. Method names are the snake_case form of the public operation ids. """ @@ -2587,6 +2650,41 @@ def create_holder_return( 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. diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py index 62dd44c..0bbbaf3 100644 --- a/tests/test_holders_orders_series.py +++ b/tests/test_holders_orders_series.py @@ -157,6 +157,36 @@ def transport(request: Any, _timeout: float) -> FakeResponse: {"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", @@ -419,7 +449,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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)) == 40 # 12 + 9 + 14 + 5 + assert len(operation_ids) == len(set(operation_ids)) == 42 # 14 + 9 + 14 + 5 @pytest.mark.parametrize( @@ -473,10 +503,11 @@ def test_optional_bodies_are_omitted_when_empty() -> None: 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[:3]] == [None, None, None] + assert [call.data for call in calls[:4]] == [None, None, None, None] # updateEventSeries requires a body, even an empty one. - assert json.loads(calls[3].data) == {} + assert json.loads(calls[4].data) == {} def test_add_event_series_dates_takes_exactly_one_shape() -> None: @@ -543,6 +574,28 @@ def test_holder_return_retries_with_one_key_generated_or_given() -> None: 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", [ @@ -551,8 +604,13 @@ def test_holder_return_retries_with_one_key_generated_or_given() -> None: 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", ], - ids=["resend-event", "resend-season", "rotate-link", "discount-code", "accept-transfer"], ) def test_unsupported_and_domain_exact_mutations_are_single_attempt(call: Call) -> None: calls: list[Any] = [] diff --git a/tests/test_seasons.py b/tests/test_seasons.py index a0d44ea..e4ecd96 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -26,7 +26,7 @@ def __exit__(self, *args: object) -> None: CSV = "holder,email\r\nA. Fan,fan@example.com\r\n" -def test_maps_all_83_season_operations_and_replay_classes() -> None: +def test_maps_all_86_season_operations_and_replay_classes() -> None: calls: list[Any] = [] def transport(request: Any, _timeout: float) -> FakeResponse: @@ -149,8 +149,20 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ) 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", + ) - assert len(calls) == 83 + assert len(calls) == 86 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"), @@ -236,6 +248,9 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ("POST", "/v1/seasons/sea%2Fa/performances/ev%2F1/refunds"), ("GET", "/v1/seasons/sea%2Fa/jobs"), ("GET", "/v1/seasons/sea%2Fa/jobs/job%2F1"), + ("GET", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/quote"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/intent"), + ("POST", "/v1/seasons/sea%2Fa/renewal-offers/offer%2F1/checkout"), ] assert calls[26].data is None @@ -298,6 +313,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: {"amount": "per_date", "reason": "Storm"}, None, None, + None, + None, + { + "returnUrl": "https://tickets.example/renewed", + "buyerEmail": "ana@example.com", + "buyerName": "Ana Silva", + "provider": "stripe", + }, ] # CSV exports hand back the body as text; the 204 delete hands back nothing. @@ -308,14 +331,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: caller_keys = { 48: "from-season-1", 50: "perf-1", 51: "attach-1", 52: "perf-del-1", 53: "plan-upd-1", - 80: "refund-date-1", + 80: "refund-date-1", 85: "renew-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, + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, 85, } for index, request in enumerate(calls): headers = {name.lower(): value for name, value in request.header_items()} @@ -406,3 +429,31 @@ def transport(request: Any, _timeout: float) -> FakeResponse: # 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") From 52da4f0c4767d0f9a2005206f3ae88f90d06ee5d Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 01:19:22 +0530 Subject: [PATCH 06/14] Seasons, series and orders: contract changes from the 2.23 gaps and the p2a-api review --- CHANGELOG.md | 6 ++++ src/seatlayer/resources.py | 55 ++++++++++++++++++++--------- tests/test_client.py | 2 +- tests/test_holders_orders_series.py | 12 +++++-- tests/test_seasons.py | 9 ++--- 5 files changed, 60 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4ee07..13cb16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ - 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`. ## 0.8.0 (2026-09-10) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 059e360..fcd1333 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -1650,6 +1650,7 @@ def update_season( 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, ) -> Any: """Rename a draft, or set how the Season sells. @@ -1657,6 +1658,8 @@ def update_season( 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 until it starts. + ``audience="public"`` lists the Season on your SeatLayer website; an + ended Season cannot become public (``409 sales_ended``). """ body: dict[str, Any] = {"expectedRevision": expected_revision} if name is not None: @@ -1664,7 +1667,7 @@ def update_season( if edition is not _UNSET: body["edition"] = edition body.update(_fields( - (("checkoutMode", checkout_mode),), + (("checkoutMode", checkout_mode), ("audience", audience)), ( ("transferCutoffHours", transfer_cutoff_hours), ("returnCutoffHours", return_cutoff_hours), @@ -1739,6 +1742,13 @@ 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. + """ return self._sales(season_key, "open", expected_revision) def pause_season_sales(self, season_key: str, expected_revision: int) -> Any: @@ -1961,15 +1971,16 @@ def create_season_amendment( starts_at: int | None = None, name: str | None = None, idempotency_key: str | None = None, - refund: Literal["none", "per_date", "full"] | 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`` refunds the date's - SeatLayer orders as a job (the answer's ``refundJob``), and ``reason`` - is kept on the job. + 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. """ body: dict[str, Any] = {"eventKey": event_key, "kind": kind} if starts_at is not None: @@ -2491,14 +2502,15 @@ def refund_season_performance( self, season_key: str, event_key: str, - amount: Literal["per_date", "full"], + 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`` refunds what is left of each order. Answers ``{"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. """ @@ -2705,15 +2717,20 @@ def list_orders( 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``. + 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, @@ -2884,18 +2901,24 @@ def add_event_series_dates( 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``) or - ``dates``, exact moments as ``"YYYY-MM-DDTHH:MM"`` in the series' zone. + 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 or the other; failing here names the mistake - # instead of a 400 after the round trip. - if (schedule is None) == (dates is None): - raise ValueError("give exactly one of schedule or dates") + # 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 or ())} + {"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 diff --git a/tests/test_client.py b/tests/test_client.py index d044e32..02b624f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -376,7 +376,7 @@ def test_bulk_refund_of_a_date_retries_with_one_key(self) -> None: {"status": 503, "body": {"error": "unavailable"}}, {"status": 202, "body": {"job": {"jobId": "sbj_1"}}}, ]) - result = sdk.seasons.refund_season_performance("sea_1", "ev_1", "full") + result = sdk.seasons.refund_season_performance("sea_1", "ev_1", "per_date") assert result == {"job": {"jobId": "sbj_1"}} assert len(calls) == 2 diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py index 0bbbaf3..27700a8 100644 --- a/tests/test_holders_orders_series.py +++ b/tests/test_holders_orders_series.py @@ -191,10 +191,11 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ( "listOrders", lambda sdk: sdk.orders.list_orders( - season_key="sea/1", status="confirmed", from_=1, to=2, cursor="c/1", limit=50 + 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&status=confirmed&from=1&to=2&cursor=c%2F1&limit=50", + "/v1/orders?seasonKey=sea%2F1&holderRef=ord%2F1&status=confirmed&from=1&to=2&cursor=c%2F1&limit=50", None, False, ), @@ -521,10 +522,15 @@ def test_add_event_series_dates_takes_exactly_one_shape() -> None: sdk.event_series.add_event_series_dates( "ev_1", schedule={"rule": "daily", "from": "2026-12-01", "count": 2, "times": ["20:00"]} ) - assert len(calls) == 1 + 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]: diff --git a/tests/test_seasons.py b/tests/test_seasons.py index e4ecd96..91a586d 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -387,7 +387,8 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ], ) seasons.update_season( - "sea_1", 3, checkout_mode="seatlayer", transfer_cutoff_hours=24, return_cutoff_hours=None + "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( @@ -404,7 +405,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: idempotency_key="night-3-storm", ) seasons.create_season_amendment("sea_1", "ev_1", "postpone") - seasons.refund_season_performance("sea_1", "ev_1", "full") + seasons.refund_season_performance("sea_1", "ev_1", "per_date") assert [json.loads(call.data) for call in calls] == [ {"prices": [ @@ -412,7 +413,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: {"categoryKey": "stalls", "priceTypeKey": "child", "unitPrice": 9000}, ]}, { - "expectedRevision": 3, "checkoutMode": "seatlayer", + "expectedRevision": 3, "checkoutMode": "seatlayer", "audience": "public", "transferCutoffHours": 24, "returnCutoffHours": None, }, {"expectedRevision": 4, "name": "Renamed"}, @@ -423,7 +424,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: }]}, {"eventKey": "ev_night_3", "kind": "cancel", "refund": "per_date", "reason": "Storm"}, {"eventKey": "ev_1", "kind": "postpone"}, - {"amount": "full"}, + {"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. From 96ae9b213e09880da6095ecd96111f5dde05e5ce Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 04:04:21 +0530 Subject: [PATCH 07/14] Event series: create from an event, and every date keeps the plain title (2.16c) createEventSeries takes fromEventKey: an event already set up becomes the first date under its own key and every other date is a copy of it; name and chartKey are then left out. Every date of a series carries the series' plain name. Matches seatmap slice 2.16c. --- CHANGELOG.md | 2 ++ src/seatlayer/resources.py | 26 +++++++++++++++++++++----- tests/test_holders_orders_series.py | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cb16a..459ec3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ - `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}`. ## 0.8.0 (2026-09-10) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index fcd1333..b12236b 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -2860,29 +2860,45 @@ def _path(event_key: str, suffix: str = "") -> str: def create_event_series( self, - name: str, - chart_key: str, - schedule: dict[str, Any], + 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. + ``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. """ + 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.update({"name": name, "chartKey": chart_key, "schedule": schedule}) + body["schedule"] = schedule return self._http.post_with_header_replay( "/v1/event-series", body=body, idempotency_key=idempotency_key ) diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py index 27700a8..3f0630c 100644 --- a/tests/test_holders_orders_series.py +++ b/tests/test_holders_orders_series.py @@ -511,6 +511,20 @@ def test_optional_bodies_are_omitted_when_empty() -> None: 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_add_event_series_dates_takes_exactly_one_shape() -> None: sdk, calls = recorder() with pytest.raises(ValueError, match="exactly one"): From 236ca9218f21a6fa917b5b345e076da29db592a1 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:02:32 +0530 Subject: [PATCH 08/14] Seasons: renewal at scale contract changes (2.8) Adds releaseSeasonLapsedOffers (POST /v1/seasons/{key}/renewal-offers/release-lapsed, no body, header replay, 202 with the job). retrieveSeasonHostedRenewals pages with cursor and limit (1-500) and answers nextCursor, and listSeasonRenewalOffers pages the same way (1-100). createSeasonRenewalOffers answers 201 {offers} for 100 contracts or fewer and 202 {job, batchId} above that. The bring-across (createSeasonHolderImportFromSeason) answers 202 with a holder_import job above 100 holders. Renewal emails take up to 20,000 offer ids; above that, 422 too_many_offer_ids. --- CHANGELOG.md | 15 +++++--- README.md | 13 +++---- src/seatlayer/resources.py | 71 ++++++++++++++++++++++++++++++++++---- tests/test_client.py | 14 ++++++++ tests/test_seasons.py | 57 +++++++++++++++++++++++++++--- 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459ec3f..756bde4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## Unreleased -- Added 38 Season operations to `seatlayer.seasons` (now 86): performances, package prices, +- 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, and bulk refunds with - their jobs. + 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, @@ -14,8 +14,8 @@ - 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. 26 of the new mutations use exact header replay - (40 in all); the rest are sent once. CSV exports return text; the two 204 deletes return `None`. +- 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`. @@ -46,6 +46,11 @@ - Season holds, give-backs and date cancels can refuse with `performance_cancelled`, `refund_not_started` (503), `missing_manage_capability` (403) or `invalid_refund`; a holder's status can be `reserved`; hosted renewal readiness carries `missingPrices`. - `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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 409bc55..fdea3b0 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,11 @@ live ones; crossing them returns `403 mode_mismatch`, surfaced as `SeatLayerAuth ## Fixed Renewable Seasons -`seatlayer.seasons` exposes all 86 trusted organizer operations: catalogue and packages, +`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 and their jobs, reports, CSV exports, audit, outbox, and support export. +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 @@ -380,7 +380,7 @@ requests. ## Reliability -**Retries and idempotency.** Reads retry 408, 429 and 5xx responses with backoff. Forty +**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`; on `seasons`, `create_season`, `update_season`, `delete_season`, @@ -391,7 +391,8 @@ mutations opt into exact header replay: `charts.create`, `charts.copy`, `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`, and `create_season_renewal_checkout`; +`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 @@ -407,7 +408,7 @@ before trying again. ```python SeatLayer( os.environ["SEATLAYER_SECRET_KEY"], - max_retries=3, # attempts for reads and the 40 exact-header-replay mutations + max_retries=3, # attempts for reads and the 41 exact-header-replay mutations timeout=30.0, # seconds, per attempt ) ``` @@ -438,7 +439,7 @@ holder, order, renewal, occurrence, reporting, export, outbox, and support opera | `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` | 86 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 and jobs, reports, CSV exports, 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` | diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index b12236b..b05e9fe 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -1893,6 +1893,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 @@ -1904,8 +1913,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)}")) @@ -2041,7 +2061,16 @@ def create_season_holder_import_from_season( dry_run: bool | None = None, idempotency_key: str | None = None, ) -> Any: - """Dry-run or commit renewal rights taken from a previous season's holders.""" + """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 @@ -2389,9 +2418,19 @@ def resend_season_order_tickets( self._path(season_key, f"/orders/{quote(order_id)}/resend"), body=body or None ) - def retrieve_season_hosted_renewals(self, season_key: str) -> Any: - """Retrieve renewal links, payments and emails for each offer.""" - return self._http.get(self._path(season_key, "/renewal-hosted")) + 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. @@ -2418,7 +2457,11 @@ def update_season_renewal_offer_contact( def create_season_renewal_notices( self, season_key: str, offer_ids: Sequence[str] | None = None ) -> Any: - """Email renewal invitations now and reminders before the deadline.""" + """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``. + """ body = _fields((("offerIds", None if offer_ids is None else list(offer_ids)),)) return self._http.post( self._path(season_key, "/renewal-notices"), body=body or None @@ -2520,6 +2563,20 @@ def refund_season_performance( 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")) diff --git a/tests/test_client.py b/tests/test_client.py index 02b624f..639fd95 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -382,6 +382,20 @@ def test_bulk_refund_of_a_date_retries_with_one_key(self) -> None: 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"}}, diff --git a/tests/test_seasons.py b/tests/test_seasons.py index 91a586d..f3e9ee6 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -26,7 +26,7 @@ def __exit__(self, *args: object) -> None: CSV = "holder,email\r\nA. Fan,fan@example.com\r\n" -def test_maps_all_86_season_operations_and_replay_classes() -> None: +def test_maps_all_87_season_operations_and_replay_classes() -> None: calls: list[Any] = [] def transport(request: Any, _timeout: float) -> FakeResponse: @@ -147,6 +147,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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. @@ -162,7 +163,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: idempotency_key="renew-1", ) - assert len(calls) == 86 + assert len(calls) == 87 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"), @@ -246,6 +247,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ("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"), @@ -315,6 +317,7 @@ def transport(request: Any, _timeout: float) -> FakeResponse: None, None, None, + None, { "returnUrl": "https://tickets.example/renewed", "buyerEmail": "ana@example.com", @@ -331,14 +334,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: caller_keys = { 48: "from-season-1", 50: "perf-1", 51: "attach-1", 52: "perf-del-1", 53: "plan-upd-1", - 80: "refund-date-1", 85: "renew-1", + 80: "refund-date-1", 86: "renew-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, 85, + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, 81, 86, } for index, request in enumerate(calls): headers = {name.lower(): value for name, value in request.header_items()} @@ -458,3 +461,49 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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", + ] From fdfbb2a4348dbca3a83c8210d04aa19a91a7c2de Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:47:59 +0530 Subject: [PATCH 09/14] Seasons: renewal notices for one offer batch (2.8b) --- CHANGELOG.md | 1 + src/seatlayer/resources.py | 15 ++++++++++++--- tests/test_seasons.py | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 756bde4..4cd875d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - `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. ## 0.8.0 (2026-09-10) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index b05e9fe..9ad2dbc 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -2455,14 +2455,23 @@ def update_season_renewal_offer_contact( ) def create_season_renewal_notices( - self, season_key: str, offer_ids: Sequence[str] | None = None + 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``. + 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)),)) + 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 ) diff --git a/tests/test_seasons.py b/tests/test_seasons.py index f3e9ee6..51279d3 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -507,3 +507,20 @@ def transport(request: Any, _timeout: float) -> FakeResponse: "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 From 4137e0b41853e24c3b306798de475958ad7346be Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 07:11:08 +0530 Subject: [PATCH 10/14] Seasons: single tickets on a season (2.15c) --- CHANGELOG.md | 1 + src/seatlayer/resources.py | 18 +++++++++++++++++- tests/test_seasons.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd875d..3f9718e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - `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) diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 9ad2dbc..88c277b 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -1628,11 +1628,19 @@ 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, ) -> 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. + """ 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 return self._http.post_with_header_replay( "/v1/seasons", body=body, idempotency_key=idempotency_key ) @@ -1651,6 +1659,7 @@ def update_season( 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, ) -> Any: """Rename a draft, or set how the Season sells. @@ -1660,6 +1669,9 @@ def update_season( sent to others, or a seat given back; ``None`` means until it starts. ``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. """ body: dict[str, Any] = {"expectedRevision": expected_revision} if name is not None: @@ -1667,7 +1679,11 @@ def update_season( if edition is not _UNSET: body["edition"] = edition body.update(_fields( - (("checkoutMode", checkout_mode), ("audience", audience)), + ( + ("checkoutMode", checkout_mode), + ("audience", audience), + ("singleTickets", single_tickets), + ), ( ("transferCutoffHours", transfer_cutoff_hours), ("returnCutoffHours", return_cutoff_hours), diff --git a/tests/test_seasons.py b/tests/test_seasons.py index 51279d3..ce10421 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -524,3 +524,40 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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"] From 6bf4ef27bc10c011a4b35c2fda83408e58f25156 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 12:36:31 +0530 Subject: [PATCH 11/14] Seasons: date cancel void job, 2-50 dates, singles job refusal, create_abandoned (p2b) The public API changes in p2b (b88ed794e..ee7df2921): season_date_cancel_void_job_id names the date_cancel_void job a date cancel starts (season.ticket.voided reason date_cancelled); season_holder_import_rows_per_call sizes a create_season_holder_import call by its dates, now 2 to 50 (a call over it raises SeatLayerValidationError invalid_rows with maxRows); update_season documents singleTicketDates.jobRefused and its exact-retry guard; create_event_series documents 409 create_abandoned. Both helpers are module functions exported from seatlayer. --- CHANGELOG.md | 15 +++++++++++ src/seatlayer/__init__.py | 3 +++ src/seatlayer/resources.py | 40 ++++++++++++++++++++++++++--- tests/test_holders_orders_series.py | 31 +++++++++++++++++++++- tests/test_seasons.py | 40 ++++++++++++++++++++++++++++- 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9718e..dd5c333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- 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 diff --git a/src/seatlayer/__init__.py b/src/seatlayer/__init__.py index 8ad2736..31a2c7a 100644 --- a/src/seatlayer/__init__.py +++ b/src/seatlayer/__init__.py @@ -13,6 +13,7 @@ SeatLayerRateLimitError, SeatLayerValidationError, ) +from .resources import season_date_cancel_void_job_id, season_holder_import_rows_per_call from .types import ( AccessLink, AccessLinkList, @@ -94,5 +95,7 @@ "WebhookSubscription", "WebhookVerificationError", "__version__", + "season_date_cancel_void_job_id", + "season_holder_import_rows_per_call", "verify_webhook", ] diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index 88c277b..e5369d2 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -1569,6 +1569,25 @@ 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))) + + class Seasons: """Fixed Renewable Season organizer operations for trusted backends. @@ -1671,7 +1690,11 @@ def update_season( 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. + 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. """ body: dict[str, Any] = {"expectedRevision": expected_revision} if name is not None: @@ -1884,7 +1907,10 @@ def create_season_holder_import( Each row is ``{"rowId", "holderRef", "priorPlanActivationId", "priorContractRef", "labels"}`` plus optional ``existingBookingRef``, - ``holderEmail`` and ``holderName``. + ``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, @@ -2016,7 +2042,10 @@ def create_season_amendment( 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. + 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`. """ body: dict[str, Any] = {"eventKey": event_key, "kind": kind} if starts_at is not None: @@ -2966,6 +2995,11 @@ def create_event_series( 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") diff --git a/tests/test_holders_orders_series.py b/tests/test_holders_orders_series.py index 3f0630c..5d82c89 100644 --- a/tests/test_holders_orders_series.py +++ b/tests/test_holders_orders_series.py @@ -11,7 +11,7 @@ import pytest from typing_extensions import Self -from seatlayer import SeatLayer, SeatLayerError +from seatlayer import SeatLayer, SeatLayerConflictError, SeatLayerError BASE = "https://api.seatlayer.io" CSV = "order,buyer\r\nSL-1,A. Fan\r\n" @@ -525,6 +525,35 @@ def test_create_event_series_from_an_event() -> None: 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"): diff --git a/tests/test_seasons.py b/tests/test_seasons.py index ce10421..788d9cf 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -4,11 +4,18 @@ 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, + SeatLayerValidationError, + season_date_cancel_void_job_id, + season_holder_import_rows_per_call, +) class FakeResponse(io.BytesIO): @@ -561,3 +568,34 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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_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 From 55207cc41593714bf97bb30fc4cf0ac67e7b9383 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 18:09:29 +0530 Subject: [PATCH 12/14] Docs: partial series prices, club switches, club-default cut-offs (p2b audit) updateEventSeries sends only the prices that change: each date keeps its own price for every category or tier left out. A season's null transfer or return cut-off means the club default from Settings > Ticket site, or until the date starts when the club has none. Holder transfers and returns through the secret key are not stopped by the club's customer switches; cut-offs still apply. --- CHANGELOG.md | 6 ++++++ src/seatlayer/resources.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5c333..fa4de19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- `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 diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index e5369d2..fb222fb 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -1685,7 +1685,8 @@ def update_season( ``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 until it starts. + 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 @@ -2733,7 +2734,11 @@ def create_holder_transfer( to_email: str, idempotency_key: str | None = None, ) -> Any: - """Send some of a holder's season tickets to an email address.""" + """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}, @@ -2765,7 +2770,8 @@ def create_holder_return( """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. + 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"), @@ -3066,8 +3072,10 @@ def update_event_series( ) -> Any: """Change the name, prices or sales of every upcoming date in a series. - ``sales="on"`` or ``"off"`` opens or pauses sales on every upcoming date, - each with its own go-live checks. + ``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. """ body = _fields((("name", name), ("prices", prices), ("sales", sales))) return self._http.mutation_with_header_replay( From e3ab392000f6d90d7e2d81dbe05e3b0276754f4d Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 19:58:23 +0530 Subject: [PATCH 13/14] Docs: a cancelled season date stays closed and can't be amended (p2b part C) A date its season cancelled never opens again: moving its sales to testable or live, or reopening it, answers 409 season_date_cancelled, and an event series "sales on" leaves it closed as a failed item of the job (season_date_cancelled; not in failedDates). A cancelled date can't be moved, postponed, replaced or marked as taken place: 409 performance_cancelled. --- CHANGELOG.md | 7 +++++++ src/seatlayer/resources.py | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4de19..4e9336c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- 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 diff --git a/src/seatlayer/resources.py b/src/seatlayer/resources.py index fb222fb..5e47360 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -459,6 +459,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: @@ -581,7 +585,11 @@ def transition_event_sales( expected_revision: int, reason: str | None = None, ) -> Any: - """Move an event's sales to another state, such as live or paused. Sent once.""" + """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 @@ -2046,7 +2054,9 @@ def create_season_amendment( 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`. + Follow it with :meth:`retrieve_season_bulk_job`. A cancelled date + can't be moved (``reschedule``), postponed or replaced: + ``409 performance_cancelled``. """ body: dict[str, Any] = {"eventKey": event_key, "kind": kind} if starts_at is not None: @@ -2321,7 +2331,10 @@ def mark_season_performance_past( force: bool | None = None, idempotency_key: str | None = None, ) -> Any: - """Mark a performance past so it stops blocking new Season sales.""" + """Mark a performance past so it stops blocking new Season sales. + + A cancelled date answers ``409 performance_cancelled``. + """ return self._http.post_with_header_replay( self._path(season_key, f"/performances/{quote(event_key)}/past"), body=_fields((("force", force),)), @@ -3076,6 +3089,9 @@ def update_event_series( 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( From a643e2269c59e7e2e2ff1e0ad88227567094566e Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:30:17 +0530 Subject: [PATCH 14/14] Season seats (0.9.0): set_aside_season_block, retrieve_season_block, release_season_block; season.block.ready/released webhook names; webhooks accept every typed name; the 202 date-leave answer guard. No version bump: publish only once the phase 4 API is on production --- CHANGELOG.md | 26 ++++++ README.md | 45 ++++++++++ src/seatlayer/__init__.py | 13 ++- src/seatlayer/resources.py | 145 +++++++++++++++++++++++++++----- src/seatlayer/types.py | 35 ++++++++ tests/test_client.py | 11 ++- tests/test_seasons.py | 167 ++++++++++++++++++++++++++++++++++++- 7 files changed, 413 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e9336c..2f6ab80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## Next: 0.9.0, season seats (joins the season 2a section under ## 0.9.0 in the version PR; publish only once the phase 4 API is live on production) + +- Season seats: `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. + ## Unreleased - A date its season cancelled never opens again: `transition_event_sales` to `testable` or diff --git a/README.md b/README.md index fdea3b0..85f2238 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,51 @@ if quote["problem"] is None: 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 diff --git a/src/seatlayer/__init__.py b/src/seatlayer/__init__.py index 31a2c7a..c802c41 100644 --- a/src/seatlayer/__init__.py +++ b/src/seatlayer/__init__.py @@ -13,7 +13,11 @@ SeatLayerRateLimitError, SeatLayerValidationError, ) -from .resources import season_date_cancel_void_job_id, season_holder_import_rows_per_call +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, @@ -35,6 +39,9 @@ InventoryItem, ManageCapability, ManageSession, + SeasonBlockReleaseScope, + SeasonDateLeavePending, + SeasonSeatSelection, TemplateInstantiateRequest, TicketRelease, TicketReleaseAction, @@ -73,6 +80,9 @@ "InventoryItem", "ManageCapability", "ManageSession", + "SeasonBlockReleaseScope", + "SeasonDateLeavePending", + "SeasonSeatSelection", "SeatLayer", "SeatLayerAuthError", "SeatLayerConflictError", @@ -95,6 +105,7 @@ "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/resources.py b/src/seatlayer/resources.py index 5e47360..d7c2c3d 100644 --- a/src/seatlayer/resources.py +++ b/src/seatlayer/resources.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Sequence -from typing import Any, Literal, cast +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, @@ -1303,26 +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", - "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", - }) + # 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 @@ -1596,6 +1581,22 @@ def season_holder_import_rows_per_call(dates: int) -> int: 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. @@ -1656,11 +1657,18 @@ def create_season( 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 @@ -1668,6 +1676,8 @@ def create_season( 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 ) @@ -1687,6 +1697,7 @@ def update_season( 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. @@ -1704,6 +1715,12 @@ def update_season( 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: @@ -1715,6 +1732,7 @@ def update_season( ("checkoutMode", checkout_mode), ("audience", audience), ("singleTickets", single_tickets), + ("inventoryMode", inventory_mode), ), ( ("transferCutoffHours", transfer_cutoff_hours), @@ -1795,7 +1813,9 @@ def open_season_sales(self, season_key: str, expected_revision: int) -> Any: ``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. + 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) @@ -2057,6 +2077,12 @@ def create_season_amendment( 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: @@ -2333,7 +2359,11 @@ def mark_season_performance_past( ) -> Any: """Mark a performance past so it stops blocking new Season sales. - A cancelled date answers ``409 performance_cancelled``. + 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"), @@ -2653,6 +2683,75 @@ 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. diff --git a/src/seatlayer/types.py b/src/seatlayer/types.py index e8339de..755cb57 100644 --- a/src/seatlayer/types.py +++ b/src/seatlayer/types.py @@ -59,6 +59,8 @@ "season.bulk_action.completed", "season.order.paid", "season.order.refunded", + "season.block.ready", + "season.block.released", "series.created", "series.dates_added", ] @@ -348,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 639fd95..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, ) @@ -862,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_seasons.py b/tests/test_seasons.py index 788d9cf..1065878 100644 --- a/tests/test_seasons.py +++ b/tests/test_seasons.py @@ -12,7 +12,10 @@ from seatlayer import ( SeatLayer, + SeatLayerConflictError, + SeatLayerError, SeatLayerValidationError, + is_season_date_leave_pending, season_date_cancel_void_job_id, season_holder_import_rows_per_call, ) @@ -33,7 +36,7 @@ def __exit__(self, *args: object) -> None: CSV = "holder,email\r\nA. Fan,fan@example.com\r\n" -def test_maps_all_87_season_operations_and_replay_classes() -> None: +def test_maps_all_90_season_operations_and_replay_classes() -> None: calls: list[Any] = [] def transport(request: Any, _timeout: float) -> FakeResponse: @@ -169,8 +172,16 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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) == 87 + 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"), @@ -260,6 +271,9 @@ def transport(request: Any, _timeout: float) -> FakeResponse: ("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 @@ -331,6 +345,15 @@ def transport(request: Any, _timeout: float) -> FakeResponse: "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. @@ -341,14 +364,14 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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", + 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, + 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()} @@ -570,6 +593,87 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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)] == [ @@ -599,3 +703,58 @@ def transport(request: Any, _timeout: float) -> FakeResponse: 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 == []