diff --git a/CHANGELOG.md b/CHANGELOG.md index 8adf5e2..73eb8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## Unreleased + +- A date its season cancelled never opens again: `transitionEventSales` to `testable` or `live` + and `reopen` answer `409 season_date_cancelled`, and `updateEventSeries` with `sales: "on"` + leaves it closed as a failed item of the job (`season_date_cancelled`; not in `failedDates`). +- `createSeasonAmendment` refuses to move (`reschedule`), postpone or replace a cancelled date, + and `markSeasonPerformancePast` refuses to mark one as taken place: + `409 performance_cancelled`. +- `updateEventSeries` sends only the prices that change: each date keeps its own price for every + category (or tier) left out, so weekend and weekday prices keep their difference. +- A season's `transferCutoffHours` / `returnCutoffHours` of `null` means the club default + (Settings › Ticket site), or until the date starts when the club has none. +- `createHolderTransfer` and `createHolderReturn` are not stopped by the club's customer switches + in Settings › Ticket site; cut-offs still apply. +- Cancelling a date voids its tickets as a `date_cancel_void` job, named by + `Seasons.dateCancelVoidJobId(amendmentId)` (`sbj_void_…`); follow it with + `retrieveSeasonBulkJob`. `season.ticket.voided` says `date_cancelled` for each season ticket it + voids. +- A Season package holds 2 to 50 dates. `Seasons.holderImportRowsPerCall(dates)` gives the most + rows one `createSeasonHolderImport` call takes (100 up to 20 dates, 43 at 50); a call with more + answers `422 invalid_rows` with `maxRows` in the error's `body()`. +- `updateSeason`'s `singleTicketDates` can carry `jobRefused`, why a `season_singles` job could + not start; the change stands, and sending `singleTickets: true` again opens any date still not + open. An `updateSeason` retried with its first key and `expectedRevision` is not applied twice. +- `eventSeries().createEventSeries`: a series create given up for its key answers + `409 create_abandoned` with a message to send it again with a new idempotency key. +- Added 39 Season operations to `seasons()`, for 87 in all: performances, package prices, plan + edits and deletion, one-call publish, checkout tests, occurrence returns and reclaims, holders, + Season order refunds and cancellations, hosted renewals, renewal links and notices, renewal + quotes, intents and checkouts, sales and seat reports, seat counts, holder and order CSV exports, and bulk refunds and the release of lapsed renewal offers with their jobs. +- Added `holders()` (customers, holder reads and updates, sign-out, seasons, tickets, and ticket + transfers), `orders()` (orders across events and seasons, one event's orders, CSV export, + refunds, cancellations, and ticket emails), and `eventSeries()` (create, list, retrieve, add + dates, and update). +- Added `holders().createHolderReturn`: give back a holder's seat on one date, one per ticket. +- Added event sales operations to `events()`: buyer checkout, payment gateway, sales readiness, + preflight and transition, booking fee, and discount codes. +- The three CSV exports return the CSV text as a `String`. `deleteSeasonPlan` and + `deleteEventDiscountCode` return nothing (the API answers 204). +- 27 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. + The other 54 are single-attempt and send no key. +- Added `seasons().refundSeasonPerformance` (refund every order of a cancelled date as a job, with + header replay), `seasons().listSeasonBulkJobs`, and `seasons().retrieveSeasonBulkJob`. +- Documented the new request fields the `Map` bodies carry: `priceTypeKey` on each + `replaceSeasonPackagePrices` price; `checkoutMode`, `transferCutoffHours` and + `returnCutoffHours` on `updateSeason`; `holderEmail` and `holderName` on each + `createSeasonHolderImport` row; and `kind` `postpone` or `cancel`, with `refund` and `reason`, on + `createSeasonAmendment`. +- `EventListOptions` takes `seriesKey` (builder `seriesKey(...)`) to list one event series' dates; + the five-argument constructor stays. +- Added `seasons().retrieveSeasonRenewalQuote`: what renewing an offer costs, and whether SeatLayer checkout can take it now. +- Added `seasons().createSeasonRenewalIntent`: record a holder's intent for a renewal taken by phone or at the box office, so your own checkout can commit. +- Added `seasons().createSeasonRenewalCheckout`: a SeatLayer checkout that renews an offer with no hosted page, with header replay. +- Added `holders().createHolderRenewalCheckout`: the same checkout for one of a holder's offers, with header replay. +- Added `holders().declineHolderRenewalOffer`: decline one of a holder's offers, with an optional `reason`. +- Added a `declineSeasonRenewalOffer` overload that takes a `reason`, kept on the offer as `declineReason`. +- `updateSeason` takes `audience` (`embed_only` or `public`). +- `openSeasonSales` answers `warnings` (`checkout_test_missing` until a checkout test or rehearsal has passed) and can refuse with `409 plan_not_published` or `409 checkout_mode_not_chosen`. +- A cancelled date refunds per date only: an amendment's `refund` is `none` or `per_date`, and `refundSeasonPerformance` takes `amount: per_date` (`full` answers `422 date_refund_per_date_only`; refund a whole order with the order refund). +- `addEventSeriesDates` takes `{"retryFailed": true}` to make the failed dates again (`409 series_busy`, `422 nothing_to_retry`). +- Added a `listOrders` overload with `holderRef`; orders carry `holderRef` and `customerId`, and `buyer.email` can be null (a box office sale with no email). +- Season holds, give-backs and date cancels can refuse with `performance_cancelled`, `refund_not_started` (503), `missing_manage_capability` (403) or `invalid_refund`; a holder's status can be `reserved`; hosted renewal readiness carries `missingPrices`. +- `eventSeries().createEventSeries` takes `fromEventKey` in place of `name` and `chartKey`: an event you already set up becomes the first date under its own key, and every other date is a copy of it. It refuses with `404 event_not_found`, or `422 event_has_no_date`, `event_in_season`, `event_in_performance_group`, `event_in_series` or `invalid_request`. +- Every date of an event series carries the series' plain name (`Hamlet`, not `Hamlet · 3 Nov`); tell dates apart by `startsAt`. `updateEventSeries` renames each date that carries the series name, and a series name cannot hold `{n}`, `{date}` or `{time}`. +- Added `seasons().releaseSeasonLapsedOffers`: release the seats of every lapsed renewal offer as a job (`202` with the `job`), with header replay. An offer extended before its turn is skipped and keeps its seat. +- Added `retrieveSeasonHostedRenewals` and `listSeasonRenewalOffers` overloads that take `cursor` and `limit` (1-500 and 1-100); both answers carry `nextCursor`, null on the last page, and a bad cursor or limit answers `400 invalid_cursor` or `invalid_limit`. The one-argument forms read the first page. +- `createSeasonRenewalOffers` answers `201` with the `offers` for 100 contracts or fewer, and `202` with a `renewal_run` `job` and its `batchId` above that: follow the job with `retrieveSeasonBulkJob`, then page the offers with `listSeasonRenewalOffers`. It can refuse with `409 job_mismatch` or `503 season_not_configured`. +- `createSeasonHolderImportFromSeason` answers `202` with a `holder_import` `job` when a commit brings across more than 100 holders; the job's `summary` has the same counts when it completes. It can refuse with `409 job_mismatch`, or `503 job_busy` or `season_not_configured`. A retrieved bulk job carries `summary`, and its `kind` can be `holder_import`. +- `createSeasonRenewalNotices` takes up to 20,000 `offerIds`; more answers `422 too_many_offer_ids`. +- `createSeasonRenewalNotices` takes `batchId` instead of `offerIds`: email every open offer one `createSeasonRenewalOffers` batch made, such as the batch a `renewal_run` job made. +- Single tickets on a season: `createSeason` and `updateSeason` take `singleTickets` in their params (default `false`, Season only; anything else answers `422 invalid_single_tickets`). Every season read carries `singleTickets`, and a season on sale may carry `singleTicketsNotOpen`. `updateSeason`, `openSeasonSales` and `resumeSeasonSales` answer `singleTicketDates` (`direction`, `opened`, `closed`, `notOpened`, `jobId`) when they open or stop the dates' own sales; a `season_singles` job does it for more than 12 upcoming dates. + ## 0.8.0 (2026-09-10) - Added Event hosting regions: the `EventHostingRegion` enum, a `region` overload on event create, `defaultRegion` on workspace create, and `updateDefaultRegion`. Events created without a region run in Western Europe. diff --git a/README.md b/README.md index 4e84d3a..e2d466f 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,14 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -Version `0.7.0` exposes all 48 trusted organizer operations through -`seatlayer.seasons()`. +`seatlayer.seasons()` exposes 87 trusted organizer operations: the 48 from +version `0.7.0`, plus performances, package prices (each price may name a +`priceTypeKey`: `adult`, `child`, `concession`, `senior` or `student`), checkout +tests, holders, Season orders, hosted renewals, renewal quotes, intents and +checkouts, reports, CSV exports, and the +bulk refund of a cancelled date (`refundSeasonPerformance`) and the release of +lapsed renewal offers (`releaseSeasonLapsedOffers`) with their jobs +(`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal(seasonKey)` sends no evidence body; SeatLayer @@ -116,6 +122,56 @@ returned operation identity. Buyer-session minting and domain-exact booking, cancellation, and renewal actions remain single-attempt; only declared header-replay catalogue mutations retry automatically. +When SeatLayer takes the Season's payments, renew a holder with no hosted page: +read the price, start a checkout, and send the holder to `payment.url`. For a +renewal taken by phone or at the box office, `createSeasonRenewalIntent` records +the holder's intent before you commit the offer. A decline can carry the +holder's reason. + +```java +Map quote = seatlayer.seasons().retrieveSeasonRenewalQuote(seasonKey, offerId); +if (quote.get("problem") == null) { + Map started = seatlayer.seasons().createSeasonRenewalCheckout( + seasonKey, offerId, + Map.of("returnUrl", "https://tickets.yourclub.example/renewed"), + "renew-" + offerId); + // Send the holder to the "url" under started.get("payment"). +} +seatlayer.holders().declineHolderRenewalOffer( + "holder_123", offerId, Map.of("reason", "Moving away")); +``` + +## Orders, holders, event sales, and event series + +`orders()` reads orders across events and seasons and handles one event's +refunds, cancellations, and ticket emails. `holders()` covers Season ticket +holders, their tickets and transfers, and the customer list. Event checkout, +payment gateway, sales readiness, booking fee, and discount codes are on +`events()`, and `eventSeries()` plans many dated events from one schedule. + +```java +Map readiness = seatlayer.events().retrieveEventSalesReadiness("summer-gala"); +seatlayer.events().transitionEventSales("summer-gala", Map.of( + "to", "live", + "expectedRevision", 4)); + +Map orders = seatlayer.orders().listEventOrders("summer-gala"); +String csv = seatlayer.orders().exportEventOrdersCsv("summer-gala"); +seatlayer.orders().refundEventOrder( + "summer-gala", "ord_1042", Map.of("amountMinor", 500), "refund-ord_1042"); + +seatlayer.holders().createHolderTransfer("holder-1042", Map.of( + "ticketIds", List.of("tkt_1"), + "toEmail", "friend@example.com"), null); +``` + +Request bodies are maps in the API's own field names. To send an explicit +`null`, such as clearing a booking fee, use a map that allows null values +(`LinkedHashMap`). The three CSV exports (`seasons().exportSeasonHoldersCsv`, +`seasons().exportSeasonOrdersCsv`, `orders().exportEventOrdersCsv`) return the +CSV text as a `String`. `seasons().deleteSeasonPlan` and +`events().deleteEventDiscountCode` return nothing: the API answers 204. + ## Test vs live Keys carry their own mode. `sk_test_…` keys can only touch test-mode events and `sk_live_…` only @@ -224,6 +280,8 @@ exactly when you don't want that, and you can control it explicitly: seatlayer.events().list(EventListOptions.builder().limit(50).counts(false).build()); ``` +Add `.seriesKey("ser_1")` to list only the dates of one event series. + ## Keeping a hold alive When an order takes longer than the checkout window (an invoice, a phone sale), extend rather than @@ -315,17 +373,32 @@ support requests. All are unchecked, so they do not force `throws` clauses throu ## Reliability **Retries and idempotency.** Reads (`GET`/`HEAD`) retry connection failures, 408, 429 and 5xx with -exponential backoff and full jitter; `Retry-After` wins when the server sends it. Fourteen mutations -use exact header replay: `charts().create`, `charts().copy`, -`templates().instantiateTemplate`, `events().create`, `workspaces().create`, -`performanceGroups().create`, `seasons().createSeason`, `seasons().updateSeason`, -`seasons().deleteSeason`, `seasons().createSeasonPlan`, `seasons().duplicateSeasonToLive`, -`seasons().createSeasonHolderImport`, `seasons().createSeasonRenewalOffers`, and -`seasons().createSeasonAmendment`. They generate an `Idempotency-Key` when absent and reuse that key -across every attempt. Overloads that accept a key let you provide a stable provisioning key instead. +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Forty-one +mutations use exact header replay: + +- `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, + `workspaces().create`, and `performanceGroups().create` +- 26 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, + `updateSeasonPlan`, `deleteSeasonPlan`, `publishSeason`, `duplicateSeasonToLive`, + `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, + `createSeasonAmendment`, `createSeasonPerformances`, `attachSeasonPerformances`, + `deleteSeasonPerformance`, `markSeasonPerformancePast`, `createSeasonCheckoutTest`, + `replaceSeasonPackagePrices`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, `refundSeasonPerformance`, + `releaseSeasonLapsedOffers`, and `createSeasonRenewalCheckout` +- `holders().updateHolder`, `holders().createHolderTransfer`, `holders().createHolderReturn`, + `holders().createHolderRenewalCheckout`, `orders().refundEventOrder`, and + `orders().cancelEventOrder` +- `eventSeries().createEventSeries`, `eventSeries().addEventSeriesDates`, and + `eventSeries().updateEventSeries` + +They generate an `Idempotency-Key` when absent and reuse that key across every attempt. Overloads +that accept a key let you provide a stable provisioning key instead; pass `null` to have one +generated. All remaining SDK mutations are single-attempt: holds, bookings, lifecycle changes, channel -changes, show-once secret creation, and raw requests. Some have a server-side domain idempotency +changes, show-once secret creation, ticket and renewal emails, renewal links, discount codes, +event checkout, gateway, fee and sales changes, and raw requests. Some have a server-side domain idempotency contract, but the SDK does not retry them automatically. Reconcile bookings with their required `bookingRef`; never retry an unknown booking outcome as though the transport had made it safe. @@ -356,14 +429,17 @@ outbox, and support operations. | --- | --- | | `charts()` | `list` `listAll` `create` `retrieve` `update` `delete` `copy` `archive` `unarchive` `publish` | | `templates()` | `instantiateTemplate` | -| `events()` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` | +| `events()` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` `retrieveEventBuyerCheckout` `replaceEventBuyerCheckout` `retrieveEventPaymentGateway` `replaceEventPaymentGateway` `retrieveEventSalesReadiness` `preflightEventSales` `transitionEventSales` `retrieveEventBookingFee` `replaceEventBookingFee` `listEventDiscountCodes` `createEventDiscountCode` `updateEventDiscountCode` `endEventDiscountCode` `deleteEventDiscountCode` | +| `eventSeries()` | `createEventSeries` `listEventSeries` `retrieveEventSeries` `addEventSeriesDates` `updateEventSeries` | +| `orders()` | `listOrders` `retrieveOrder` `listOrderTickets` `listEventOrders` `retrieveEventOrder` `exportEventOrdersCsv` `refundEventOrder` `cancelEventOrder` `resendEventOrderTickets` | +| `holders()` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` `createHolderRenewalCheckout` `declineHolderRenewalOffer` | | `channels()` | `list` `create` `update` `updateAssignments` `listAllocation` `retrieveAccessPreview` `retrieveReport` `pause` `unpause` `archive` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` | | `inventory()` | `hold` `holdBestAvailable` `bookBestAvailable` `extendHold` `retrieveHold` `release` `book` `bookLabels` `boxOfficeBook` `unbook` `block` `unblock` `unblockAll` `retrieveAvailability` `updateAvailability` `listBookings` `retrieveBooking` | | `sessions()` | `createManageSession` `revokeManageSession` `createDesignerSession` `revokeDesignerSession` | | `webhooks()` | `list` `create` `update` `delete` `listDeliveries` | | `workspaces()` | `list` `create` `retrieve` `update` | | `performanceGroups()` | `list` `create` `retrieve` `delete` `activate` `close` `retrieveLifecycle` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` `retrieveHold` `bookHold` `retrieveBooking` | -| `seasons()` | 48 operations for catalogue and Plan lifecycle, sales windows, buyer access and booking, holder imports, renewals, occurrence amendments, reports, audit, outbox, and support export | +| `seasons()` | 87 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holders and holder imports, Season orders, renewals, hosted renewals and renewal checkouts, occurrence returns and amendments, bulk refunds, lapsed offer releases and jobs, reports and CSV exports, audit, outbox, and support export | Full reference: [SeatLayer Java server SDK guide](https://docs.seatlayer.io/server-sdk/java/) diff --git a/src/main/java/io/seatlayer/EventListOptions.java b/src/main/java/io/seatlayer/EventListOptions.java index 6976ee5..ce2d8e3 100644 --- a/src/main/java/io/seatlayer/EventListOptions.java +++ b/src/main/java/io/seatlayer/EventListOptions.java @@ -1,15 +1,39 @@ package io.seatlayer; -/** Filters and paging for {@link Events#list}. */ +/** + * Filters and paging for {@link Events#list}. + * + * @param workspaceId the workspace to list, or {@code null} for the caller's + * @param externalRef only the event with this caller-owned reference, or {@code null} + * @param limit the page size, or {@code null} for the default + * @param cursor the cursor from the previous page, or {@code null} to start + * @param counts whether to include live availability counts + * @param seriesKey only the dates of this event series, or {@code null} + */ public record EventListOptions( - String workspaceId, String externalRef, Integer limit, String cursor, boolean counts) { + String workspaceId, String externalRef, Integer limit, String cursor, boolean counts, + String seriesKey) { + + /** + * The options without a series filter, as before {@code seriesKey} was added. + * + * @param workspaceId the workspace to list, or {@code null} for the caller's + * @param externalRef only the event with this caller-owned reference, or {@code null} + * @param limit the page size, or {@code null} for the default + * @param cursor the cursor from the previous page, or {@code null} to start + * @param counts whether to include live availability counts + */ + public EventListOptions( + String workspaceId, String externalRef, Integer limit, String cursor, boolean counts) { + this(workspaceId, externalRef, limit, cursor, counts, null); + } public static Builder builder() { return new Builder(); } EventListOptions withCursor(String next) { - return new EventListOptions(workspaceId, externalRef, limit, next, counts); + return new EventListOptions(workspaceId, externalRef, limit, next, counts, seriesKey); } public static final class Builder { @@ -19,6 +43,7 @@ public static final class Builder { private String cursor; // Most callers of a single page want counts; listAll flips this off. private boolean counts = true; + private String seriesKey; public Builder workspaceId(String value) { this.workspaceId = value; @@ -47,8 +72,19 @@ public Builder counts(boolean value) { return this; } + /** + * Lists only the dates of one event series. + * + * @param value the event series key + * @return this builder + */ + public Builder seriesKey(String value) { + this.seriesKey = value; + return this; + } + public EventListOptions build() { - return new EventListOptions(workspaceId, externalRef, limit, cursor, counts); + return new EventListOptions(workspaceId, externalRef, limit, cursor, counts, seriesKey); } } } diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java new file mode 100644 index 0000000..a4dc896 --- /dev/null +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -0,0 +1,117 @@ +package io.seatlayer; + +import static io.seatlayer.SeatLayerHttpClient.body; +import static io.seatlayer.SeatLayerHttpClient.encode; + +import java.util.Map; + +/** + * Event series: many dated events planned from one schedule and changed together. + * + *

A series is addressed by its SeatLayer event key. + */ +public final class EventSeries { + + private final SeatLayerHttpClient http; + + EventSeries(SeatLayerHttpClient http) { + this.http = http; + } + + private static String path(String eventKey, String suffix) { + return "/v1/event-series/" + encode(eventKey) + suffix; + } + + /** + * Plans an event series from a schedule, or makes it as a job. + * + *

Header replay: the same idempotency key is reused on every retry. The API answers + * 202 when the dates are made as a job. A series from an event that never started and was + * given up answers {@code 409 create_abandoned} for that key every time: send the request + * again with a new idempotency key. + * + *

Every date carries the series name as its name. Give {@code name} and {@code chartKey}, + * or {@code fromEventKey}: an event you already set up becomes the first date under its own + * key, and every other date is a copy of it (floor plan, prices, checkout, poster and its own + * seat blocks, none of its orders, holds or sales). The series takes the event's name, zone + * and floor plan. + * + * @param params the request body: {@code name}, {@code chartKey} and {@code schedule}, or + * {@code fromEventKey} and {@code schedule}, plus any optional fields such as {@code dryRun} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createEventSeries(Map params, String idempotencyKey) { + return http.postWithHeaderReplay("/v1/event-series", params, idempotencyKey); + } + + /** + * Lists the first page of event series. + * + * @return the decoded response + */ + public Map listEventSeries() { + return listEventSeries(null, null); + } + + /** + * Lists event series. Null arguments are not sent. + * + * @param limit the page size, or {@code null} for the server default + * @param cursor the cursor from the previous page, or {@code null} + * @return the decoded response + */ + public Map listEventSeries(Integer limit, String cursor) { + return http.get("/v1/event-series", body("limit", limit, "cursor", cursor)); + } + + /** + * Retrieves an event series, its dates and its job. + * + * @param eventKey the series' event key + * @return the decoded response + */ + public Map retrieveEventSeries(String eventKey) { + return http.get(path(eventKey, "")); + } + + /** + * Adds dates to an event series, copied from its first date. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param eventKey the series' event key + * @param params the request body: one of {@code schedule}, {@code dates}, or + * {@code retryFailed: true} to make again every date that failed to be made (up to 100; + * {@code 422 nothing_to_retry} when none did, {@code 409 series_busy} while another change + * runs) + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map addEventSeriesDates( + String eventKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay(path(eventKey, "/dates"), params, idempotencyKey); + } + + /** + * Changes the name, prices or sales of every upcoming date in the series. + * + *

{@code prices} takes {@code categories}: only the categories whose price changes (for a + * tiered category, only the tiers sent). Every price left out keeps each date's own value. + * + *

{@code sales: "on"} never opens a date its season cancelled: that date is a failed item + * of the job ({@code season_date_cancelled}), counted in the job's {@code failed} but not in + * {@code failedDates}, which are the dates that could not be made. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param eventKey the series' event key + * @param params the request body: any of {@code name}, {@code prices} and {@code sales} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map updateEventSeries( + String eventKey, Map params, String idempotencyKey) { + return http.mutationWithHeaderReplay("PATCH", path(eventKey, ""), params, idempotencyKey); + } +} diff --git a/src/main/java/io/seatlayer/Events.java b/src/main/java/io/seatlayer/Events.java index d0fde29..45c40dd 100644 --- a/src/main/java/io/seatlayer/Events.java +++ b/src/main/java/io/seatlayer/Events.java @@ -32,6 +32,7 @@ public Page> list(EventListOptions options) { if (!options.counts()) { query.put("counts", "0"); } + query.put("seriesKey", options.seriesKey()); return Page.of(http.get("/v1/events", query), "events"); } @@ -137,6 +138,13 @@ public Map close(String eventKey) { return http.post("/v1/events/" + encode(eventKey) + "/close"); } + /** + * Opens a paused event's sales again. A date its season cancelled never opens again: + * {@code 409 season_date_cancelled}. + * + * @param eventKey the event key + * @return the decoded response + */ public Map reopen(String eventKey) { return http.post("/v1/events/" + encode(eventKey) + "/reopen"); } @@ -193,4 +201,206 @@ public Map retrieveLog(String eventKey, Integer limit, Long befo "/v1/events/" + encode(eventKey) + "/log", body("limit", limit, "before", before)); } + + /** + * Retrieves who takes payment for the event, and what blocks a change. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map retrieveEventBuyerCheckout(String eventKey) { + return http.get("/v1/events/" + encode(eventKey) + "/buyer-checkout"); + } + + /** + * Chooses SeatLayer checkout or your own checkout for the event. + * + *

Single attempt; the {@code expectedRevision} compare-and-set makes a repeat safe to + * reconcile. + * + * @param eventKey the event key + * @param params the request body: {@code buyerCheckout} and {@code expectedRevision} + * @return the decoded response + */ + public Map replaceEventBuyerCheckout(String eventKey, Map params) { + return http.put("/v1/events/" + encode(eventKey) + "/buyer-checkout", params); + } + + /** + * Retrieves the event's payment gateway and the gateways it could use. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map retrieveEventPaymentGateway(String eventKey) { + return retrieveEventPaymentGateway(eventKey, null); + } + + /** + * Retrieves the event's payment gateway and the gateways it could use. + * + * @param eventKey the event key + * @param currency check the options against this currency instead of the event's, or + * {@code null} + * @return the decoded response + */ + public Map retrieveEventPaymentGateway(String eventKey, String currency) { + return http.get( + "/v1/events/" + encode(eventKey) + "/payment-gateway", body("currency", currency)); + } + + /** + * Replaces the event's payment gateway, sending no request body. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map replaceEventPaymentGateway(String eventKey) { + return replaceEventPaymentGateway(eventKey, null); + } + + /** + * Assigns one of the organization's connected payment gateways to the event. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param params the optional request body ({@code connectionId}, {@code provider}), or + * {@code null} + * @return the decoded response + */ + public Map replaceEventPaymentGateway(String eventKey, Map params) { + return http.put("/v1/events/" + encode(eventKey) + "/payment-gateway", params); + } + + /** + * Retrieves what the event still needs before its next sales step. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map retrieveEventSalesReadiness(String eventKey) { + return http.get("/v1/events/" + encode(eventKey) + "/sales/readiness"); + } + + /** + * Records a test order as the event's sales preflight. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param params the request body: {@code orderId} + * @return the decoded response + */ + public Map preflightEventSales(String eventKey, Map params) { + return http.post("/v1/events/" + encode(eventKey) + "/sales/preflight", params); + } + + /** + * Moves the event's sales to testable, live, paused or ended. A date its season cancelled + * never opens again: moving it to {@code testable} or {@code live} answers + * {@code 409 season_date_cancelled}. + * + *

Single attempt; the {@code expectedRevision} compare-and-set makes a repeat safe to + * reconcile. + * + * @param eventKey the event key + * @param params the request body: {@code to} and {@code expectedRevision}, and optionally + * {@code reason} + * @return the decoded response + */ + public Map transitionEventSales(String eventKey, Map params) { + return http.post("/v1/events/" + encode(eventKey) + "/sales/transition", params); + } + + /** + * Retrieves the event's booking fee. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map retrieveEventBookingFee(String eventKey) { + return http.get("/v1/events/" + encode(eventKey) + "/booking-fee"); + } + + /** + * Sets or clears the event's booking fee. + * + *

Single attempt, never retried automatically. To clear the fee, send + * {@code bookingFeeMinor} as {@code null} in a map that allows null values. + * + * @param eventKey the event key + * @param params the request body: {@code bookingFeeMinor} + * @return the decoded response + */ + public Map replaceEventBookingFee(String eventKey, Map params) { + return http.put("/v1/events/" + encode(eventKey) + "/booking-fee", params); + } + + /** + * Lists the event's discount codes. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map listEventDiscountCodes(String eventKey) { + return http.get("/v1/events/" + encode(eventKey) + "/discount-codes"); + } + + /** + * Creates a discount code for the event. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param params the request body: {@code code} and {@code kind}, plus any optional fields + * @return the decoded response + */ + public Map createEventDiscountCode(String eventKey, Map params) { + return http.post("/v1/events/" + encode(eventKey) + "/discount-codes", params); + } + + /** + * Changes or pauses a discount code. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param codeId the discount code id + * @param params the request body: the fields to change + * @return the decoded response + */ + public Map updateEventDiscountCode( + String eventKey, String codeId, Map params) { + return http.patch( + "/v1/events/" + encode(eventKey) + "/discount-codes/" + encode(codeId), params); + } + + /** + * Ends a discount code for good. + * + *

Single attempt; ending an ended code again has the same result. + * + * @param eventKey the event key + * @param codeId the discount code id + * @return the decoded response + */ + public Map endEventDiscountCode(String eventKey, String codeId) { + return http.post( + "/v1/events/" + encode(eventKey) + "/discount-codes/" + encode(codeId) + "/end"); + } + + /** + * Deletes a discount code nobody has used. The API answers 204 No Content. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param codeId the discount code id + */ + public void deleteEventDiscountCode(String eventKey, String codeId) { + http.delete("/v1/events/" + encode(eventKey) + "/discount-codes/" + encode(codeId)); + } } diff --git a/src/main/java/io/seatlayer/Holders.java b/src/main/java/io/seatlayer/Holders.java new file mode 100644 index 0000000..4d743c1 --- /dev/null +++ b/src/main/java/io/seatlayer/Holders.java @@ -0,0 +1,271 @@ +package io.seatlayer; + +import static io.seatlayer.SeatLayerHttpClient.body; +import static io.seatlayer.SeatLayerHttpClient.encode; + +import java.util.Map; + +/** + * Season ticket holders: their customer record, sign-in sessions, seasons, tickets, ticket + * transfers, returns and renewals, plus the customer list. + * + *

A holder is addressed by the holder reference its imports and bookings carry. + */ +public final class Holders { + + private final SeatLayerHttpClient http; + + Holders(SeatLayerHttpClient http) { + this.http = http; + } + + private static String path(String holderRef, String suffix) { + return "/v1/holders/" + encode(holderRef) + suffix; + } + + /** + * Lists the first page of customers. + * + * @return the decoded response + */ + public Map listCustomers() { + return listCustomers(null, null, null, null); + } + + /** + * Lists or finds customers by email or holder reference. Null filters are not sent. + * + * @param email only the customer with this email, or {@code null} + * @param holderRef only the customer linked to this holder reference, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listCustomers( + String email, String holderRef, String cursor, Integer limit) { + return http.get( + "/v1/customers", + body("email", email, "holderRef", holderRef, "cursor", cursor, "limit", limit)); + } + + /** + * Retrieves the customer behind a holder reference. + * + * @param holderRef the holder reference + * @return the decoded response + */ + public Map retrieveHolder(String holderRef) { + return http.get(path(holderRef, "")); + } + + /** + * Sets a holder's email, name or phone. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param holderRef the holder reference + * @param params the request body: any of {@code email}, {@code name} and {@code phone} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map updateHolder( + String holderRef, Map params, String idempotencyKey) { + return http.mutationWithHeaderReplay("PATCH", path(holderRef, ""), params, idempotencyKey); + } + + /** + * Signs the holder's customer out of the ticket site everywhere. + * + * @param holderRef the holder reference + * @return the decoded response + */ + public Map revokeHolderSessions(String holderRef) { + return revokeHolderSessions(holderRef, null); + } + + /** + * Signs the holder's customer out of the ticket site. + * + *

Single attempt; signing out twice has the same result as once. + * + * @param holderRef the holder reference + * @param params the optional request body ({@code sessionId} for one session), or + * {@code null} for every session + * @return the decoded response + */ + public Map revokeHolderSessions(String holderRef, Map params) { + return http.post(path(holderRef, "/sessions/revoke"), params); + } + + /** + * Lists the seasons a holder holds seats in. + * + * @param holderRef the holder reference + * @return the decoded response + */ + public Map listHolderSeasons(String holderRef) { + return http.get(path(holderRef, "/seasons")); + } + + /** + * Lists the first page of a holder's tickets, date by date. + * + * @param holderRef the holder reference + * @return the decoded response + */ + public Map listHolderTickets(String holderRef) { + return listHolderTickets(holderRef, null, null, null, null, null); + } + + /** + * Lists a holder's tickets, date by date. Null filters are not sent. + * + * @param holderRef the holder reference + * @param seasonKey only tickets of this Season, or {@code null} + * @param from only dates starting at or after this time, in epoch milliseconds, or {@code null} + * @param to only dates starting before this time, in epoch milliseconds, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listHolderTickets( + String holderRef, String seasonKey, Long from, Long to, String cursor, Integer limit) { + return http.get( + path(holderRef, "/tickets"), + body( + "seasonKey", seasonKey, + "from", from, + "to", to, + "cursor", cursor, + "limit", limit)); + } + + /** + * Retrieves one of a holder's tickets with its QR. + * + * @param holderRef the holder reference + * @param ticketId the ticket id + * @return the decoded response + */ + public Map retrieveHolderTicket(String holderRef, String ticketId) { + return http.get(path(holderRef, "/tickets/" + encode(ticketId))); + } + + /** + * Sends some of a holder's season tickets to an email. Turning customer sends off in Settings › + * Ticket site doesn't stop your server; cut-offs still apply. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param holderRef the holder reference + * @param params the request body: {@code ticketIds} and {@code toEmail} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createHolderTransfer( + String holderRef, Map params, String idempotencyKey) { + return http.postWithHeaderReplay(path(holderRef, "/transfers"), params, idempotencyKey); + } + + /** + * Lists the transfers a holder sent or was sent. + * + * @param holderRef the holder reference + * @return the decoded response + */ + public Map listHolderTransfers(String holderRef) { + return http.get(path(holderRef, "/transfers")); + } + + /** + * Cancels a transfer that has not been accepted. + * + *

Single attempt; the server applies a repeat exactly once. + * + * @param holderRef the sending holder's reference + * @param transferId the transfer id + * @return the decoded response + */ + public Map cancelHolderTransfer(String holderRef, String transferId) { + return http.post(path(holderRef, "/transfers/" + encode(transferId) + "/cancel")); + } + + /** + * Accepts a transfer for the recipient holder. + * + *

Single attempt; the server applies a repeat exactly once. + * + * @param holderRef the recipient holder's reference + * @param transferId the transfer id + * @return the decoded response + */ + public Map acceptHolderTransfer(String holderRef, String transferId) { + return http.post(path(holderRef, "/transfers/" + encode(transferId) + "/accept")); + } + + /** + * Gives back a holder's seat on one date, one per ticket. Turning give-backs off in Settings › + * Ticket site doesn't stop your server; cut-offs still apply. + * + *

Header replay: the same idempotency key is reused on every retry. A repeat answers with + * the same return. + * + * @param holderRef the holder reference + * @param params the request body: {@code ticketIds}, 1 to 20 tickets of one season + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response, with the return under {@code return} + */ + public Map createHolderReturn( + String holderRef, Map params, String idempotencyKey) { + return http.postWithHeaderReplay(path(holderRef, "/returns"), params, idempotencyKey); + } + + /** + * Starts a SeatLayer checkout that renews one of a holder's offers ({@code renewalOffer} on + * {@link #listHolderSeasons(String)}). Send the buyer to {@code payment.url}. + * + *

Header replay: the same idempotency key is reused on every retry and answers the same + * order. + * + * @param holderRef the holder reference + * @param offerId the renewal offer id + * @param params the request body: {@code returnUrl} (on one of the organization's allowed + * domains), plus optional {@code buyerEmail}, {@code buyerName} and {@code provider} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createHolderRenewalCheckout( + String holderRef, String offerId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(holderRef, "/renewal-offers/" + encode(offerId) + "/checkout"), params, idempotencyKey); + } + + /** + * Declines one of a holder's renewal offers and releases its seats, with no reason. + * + *

Single attempt; declining again answers the same offer. + * + * @param holderRef the holder reference + * @param offerId the renewal offer id + * @return the decoded response + */ + public Map declineHolderRenewalOffer(String holderRef, String offerId) { + return declineHolderRenewalOffer(holderRef, offerId, null); + } + + /** + * Declines one of a holder's renewal offers and releases its seats. + * + *

Single attempt; declining again answers the same offer, with the first reason. + * + * @param holderRef the holder reference + * @param offerId the renewal offer id + * @param params the optional request body ({@code reason}, 1 to 200 characters, kept on the + * offer as {@code declineReason}), or {@code null} to send no body + * @return the decoded response + */ + public Map declineHolderRenewalOffer( + String holderRef, String offerId, Map params) { + return http.post(path(holderRef, "/renewal-offers/" + encode(offerId) + "/decline"), params); + } +} diff --git a/src/main/java/io/seatlayer/Orders.java b/src/main/java/io/seatlayer/Orders.java new file mode 100644 index 0000000..8dd2fad --- /dev/null +++ b/src/main/java/io/seatlayer/Orders.java @@ -0,0 +1,277 @@ +package io.seatlayer; + +import static io.seatlayer.SeatLayerHttpClient.body; +import static io.seatlayer.SeatLayerHttpClient.encode; + +import java.util.Map; + +/** + * Orders SeatLayer took: the order list across events and seasons, one event's orders, + * refunds, cancellations and ticket emails. + * + *

Season order refunds and cancellations live on {@link Seasons}. + */ +public final class Orders { + + private final SeatLayerHttpClient http; + + Orders(SeatLayerHttpClient http) { + this.http = http; + } + + /** + * Lists the first page of orders across events and seasons. + * + * @return the decoded response + */ + public Map listOrders() { + return listOrders(null, null, null, null, null, null, null, null); + } + + /** + * Lists orders across events and seasons. Null filters are not sent. + * + * @param eventKey only this event's orders, or {@code null} + * @param seasonKey only this Season's orders, renewals included, or {@code null} + * @param status only orders in this state, or {@code null} + * @param from created at or after this time, in epoch milliseconds, or {@code null} + * @param to created before this time, in epoch milliseconds, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listOrders( + String eventKey, + String seasonKey, + String status, + Long from, + Long to, + String cursor, + Integer limit) { + return listOrders(eventKey, seasonKey, null, status, from, to, cursor, limit); + } + + /** + * Lists orders across events and seasons, optionally one holder's. Null filters are not sent. + * Each order carries {@code holderRef} and {@code customerId}; {@code buyer.email} is null for + * a box office sale taken without one. + * + * @param eventKey only this event's orders, or {@code null} + * @param seasonKey only this Season's orders, renewals included, or {@code null} + * @param holderRef only this holder's orders (the Season order with this id and renewals + * offered to this reference), or {@code null} + * @param status only orders in this state, or {@code null} + * @param from created at or after this time, in epoch milliseconds, or {@code null} + * @param to created before this time, in epoch milliseconds, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listOrders( + String eventKey, + String seasonKey, + String holderRef, + String status, + Long from, + Long to, + String cursor, + Integer limit) { + return http.get( + "/v1/orders", + body( + "eventKey", eventKey, + "seasonKey", seasonKey, + "holderRef", holderRef, + "status", status, + "from", from, + "to", to, + "cursor", cursor, + "limit", limit)); + } + + /** + * Retrieves an order and its lines. + * + * @param orderId the order id + * @return the decoded response + */ + public Map retrieveOrder(String orderId) { + return http.get("/v1/orders/" + encode(orderId)); + } + + /** + * Lists an order's tickets with their scan links. + * + * @param orderId the order id + * @return the decoded response + */ + public Map listOrderTickets(String orderId) { + return http.get("/v1/orders/" + encode(orderId) + "/tickets"); + } + + /** + * Lists the first page of one event's orders. + * + * @param eventKey the event key + * @return the decoded response + */ + public Map listEventOrders(String eventKey) { + return listEventOrders(eventKey, null, null, null, null, null, null, null); + } + + /** + * Lists one event's orders. Null filters are not sent. + * + * @param eventKey the event key + * @param q search by buyer name, email, order number or seat, or {@code null} + * @param status only orders in this state, or {@code null} + * @param paidBy only orders paid this way, or {@code null} + * @param from paid at or after this time, in epoch milliseconds, or {@code null} + * @param to paid before this time, in epoch milliseconds, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listEventOrders( + String eventKey, + String q, + String status, + String paidBy, + Long from, + Long to, + String cursor, + Integer limit) { + return http.get( + "/v1/events/" + encode(eventKey) + "/orders", + body( + "q", q, + "status", status, + "paidBy", paidBy, + "from", from, + "to", to, + "cursor", cursor, + "limit", limit)); + } + + /** + * Retrieves one event order with its guests and tickets. + * + * @param eventKey the event key + * @param orderId the order id + * @return the decoded response + */ + public Map retrieveEventOrder(String eventKey, String orderId) { + return http.get("/v1/events/" + encode(eventKey) + "/orders/" + encode(orderId)); + } + + /** + * Downloads all of one event's orders as CSV. + * + * @param eventKey the event key + * @return the CSV text, exactly as the API sent it + */ + public String exportEventOrdersCsv(String eventKey) { + return exportEventOrdersCsv(eventKey, null, null, null, null, null); + } + + /** + * Downloads one event's matching orders as CSV. Null filters are not sent. + * + * @param eventKey the event key + * @param q search by buyer name, email, order number or seat, or {@code null} + * @param status only orders in this state, or {@code null} + * @param paidBy only orders paid this way, or {@code null} + * @param from paid at or after this time, in epoch milliseconds, or {@code null} + * @param to paid before this time, in epoch milliseconds, or {@code null} + * @return the CSV text, exactly as the API sent it + */ + public String exportEventOrdersCsv( + String eventKey, String q, String status, String paidBy, Long from, Long to) { + return http.getText( + "/v1/events/" + encode(eventKey) + "/orders.csv", + body("q", q, "status", status, "paidBy", paidBy, "from", from, "to", to)); + } + + /** + * Refunds an event order in full through its payment provider. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param eventKey the event key + * @param orderId the order id + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map refundEventOrder( + String eventKey, String orderId, String idempotencyKey) { + return refundEventOrder(eventKey, orderId, null, idempotencyKey); + } + + /** + * Refunds an event order in full or in part through its payment provider. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param eventKey the event key + * @param orderId the order id + * @param params the optional request body ({@code amountMinor}), or {@code null} for a full refund + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map refundEventOrder( + String eventKey, String orderId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + "/v1/events/" + encode(eventKey) + "/orders/" + encode(orderId) + "/refund", + params, + idempotencyKey); + } + + /** + * Cancels an event order without moving money. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param eventKey the event key + * @param orderId the order id + * @param params the request body: {@code reason}, plus any optional fields + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map cancelEventOrder( + String eventKey, String orderId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + "/v1/events/" + encode(eventKey) + "/orders/" + encode(orderId) + "/cancel", + params, + idempotencyKey); + } + + /** + * Emails an event order's tickets again, to the address on the order. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param orderId the order id + * @return the decoded response + */ + public Map resendEventOrderTickets(String eventKey, String orderId) { + return resendEventOrderTickets(eventKey, orderId, null); + } + + /** + * Emails an event order's tickets again. + * + *

Single attempt, never retried automatically. + * + * @param eventKey the event key + * @param orderId the order id + * @param params the optional request body ({@code email}), or {@code null} + * @return the decoded response + */ + public Map resendEventOrderTickets( + String eventKey, String orderId, Map params) { + return http.post( + "/v1/events/" + encode(eventKey) + "/orders/" + encode(orderId) + "/resend", + params); + } +} diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index f448e11..53d107a 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -4,6 +4,7 @@ import static io.seatlayer.SeatLayerHttpClient.encode; import java.util.Map; +import java.util.Objects; /** Fixed Renewable Season organizer operations for trusted backends. */ public final class Seasons { @@ -18,6 +19,37 @@ private static String path(String seasonKey, String suffix) { return "/v1/seasons/" + encode(seasonKey) + suffix; } + /** + * The job voiding a cancelled date's tickets: {@code sbj_void_} and the cancel's + * {@code amendmentId}. Cancelling a date starts it (a repeated cancel joins it); follow it with + * {@link #retrieveSeasonBulkJob}. Each Season ticket it voids is told by + * {@code season.ticket.voided} with reason {@code date_cancelled}. + * + *

Not an API call: the cancel's answer does not name the job, so this names it. + * + * @param amendmentId the {@code amendmentId} of the {@code cancel} amendment + * @return the {@code date_cancel_void} job id + */ + public static String dateCancelVoidJobId(String amendmentId) { + return "sbj_void_" + Objects.requireNonNull(amendmentId, "amendmentId"); + } + + /** + * The most rows one {@link #createSeasonHolderImport} call takes for a package of + * {@code dates} dates. Each row is fenced on every date: 100 up to 20 dates, then + * {@code floor(2300 / (dates + 3))}, 43 at 50. A call with more answers + * {@code 422 invalid_rows} with {@code maxRows}. + * + *

Not an API call. + * + * @param dates the number of dates in the package (2 to 50) + * @return the most rows one import call takes, at least 1 + */ + public static int holderImportRowsPerCall(int dates) { + long perCall = 2300L / (Math.max(1, dates) + 3L); + return (int) Math.max(1, Math.min(100, perCall)); + } + public Map listSeasons() { return http.get("/v1/seasons"); } @@ -42,6 +74,18 @@ public Map createSeason(Map params) { return createSeason(params, null); } + /** + * Creates a draft Season. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param params the request body: {@code name}, {@code eventKeys} or + * {@code sourcePerformanceGroupKeys}, and optionally {@code edition}, {@code mode} and + * {@code singleTickets} ({@code true}: each date also sells its own single tickets once + * the Season is on sale; the default {@code false} is Season only) + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ public Map createSeason(Map params, String idempotencyKey) { return http.postWithHeaderReplay("/v1/seasons", params, idempotencyKey); } @@ -50,6 +94,30 @@ public Map retrieveSeason(String seasonKey) { return http.get(path(seasonKey, "")); } + /** + * Renames a draft Season, or sets how it sells. + * + *

Header replay: the same idempotency key is reused on every retry. A retry with the first + * key and the same {@code expectedRevision} is not applied twice. + * + *

When a {@code season_singles} job could not start, {@code singleTicketDates} carries + * {@code jobRefused} saying why. The change still stands, and the dates wait in + * {@code singleTicketsNotOpen} until you send {@code singleTickets: true} again. + * + * @param seasonKey the Season key + * @param params the request body: {@code expectedRevision}, plus any of {@code name}, + * {@code edition}, {@code checkoutMode} ({@code seatlayer} or {@code server}; it cannot go + * back to undecided and is fixed once a live booking exists), {@code transferCutoffHours} + * and {@code returnCutoffHours} (hours before a date starts, 0 to 720, or {@code null} for + * the club default in Settings › Ticket site, or until it starts when the club has none), + * {@code audience} ({@code embed_only} or {@code public}; an ended + * Season cannot become public, {@code 409 sales_ended}), and {@code singleTickets} (turned + * on while the Season is on sale, each upcoming date's own sales open through its + * readiness checks; turned off, they stop) + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response; a change to {@code singleTickets} names the dates it opened + * or stopped in {@code singleTicketDates} + */ public Map updateSeason( String seasonKey, Map params, String idempotencyKey) { return http.mutationWithHeaderReplay("PATCH", path(seasonKey, ""), params, idempotencyKey); @@ -103,6 +171,16 @@ private Map sales(String seasonKey, String action, int expectedR path(seasonKey, "/sales/" + action), body("expectedRevision", expectedRevision)); } + /** + * Opens sales. The answer's {@code warnings} say what is advised but not required, such as + * {@code checkout_test_missing} until a checkout test or rehearsal has passed. Refused with + * {@code 409 plan_not_published} or {@code 409 checkout_mode_not_chosen} until a package is + * published and a checkout mode is chosen. + * + * @param seasonKey the Season key + * @param expectedRevision the Season revision the caller last read + * @return the decoded response, with {@code season} and {@code warnings} + */ public Map openSeasonSales(String seasonKey, int expectedRevision) { return sales(seasonKey, "open", expectedRevision); } @@ -139,6 +217,13 @@ public Map revokeSeasonBuyerAccessSession(String seasonKey, Stri return http.delete(path(seasonKey, "/buyer-access-sessions/" + encode(sessionId))); } + /** + * Retrieves a Season hold. Each seat carries the {@code priceTypeKey} it is held as. + * + * @param seasonKey the Season key + * @param operationId the hold operation id + * @return the decoded response + */ public Map retrieveSeasonHold(String seasonKey, String operationId) { return http.get(path(seasonKey, "/holds/" + encode(operationId))); } @@ -174,6 +259,24 @@ public Map validateSeasonBuyerRehearsal(String seasonKey) { return http.post(path(seasonKey, "/buyer-rehearsals/validate")); } + /** + * Imports incumbent holders, as a dry run or for real. + * + *

A package holds 2 to 50 dates. A call takes at most + * {@link #holderImportRowsPerCall(int)} rows for the package's dates (100 up to 20 dates, 43 + * at 50); more is refused with {@code 422 invalid_rows}, and the error's {@code body()} carries + * {@code maxRows}. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param params the request body: {@code successorPlanActivationId}, {@code rows} and optionally + * {@code dryRun}; each row takes {@code rowId}, {@code holderRef}, + * {@code priorPlanActivationId}, {@code priorContractRef} and {@code labels}, plus optional + * {@code existingBookingRef}, {@code holderEmail} and {@code holderName} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ public Map createSeasonHolderImport( String seasonKey, Map params, String idempotencyKey) { return http.postWithHeaderReplay(path(seasonKey, "/imports"), params, idempotencyKey); @@ -183,16 +286,59 @@ public Map retrieveSeasonHolderImport(String seasonKey, String i return http.get(path(seasonKey, "/imports/" + encode(importId))); } + /** + * Makes one renewal offer per eligible prior Contract. + * + *

For 100 contracts or fewer the offers are made in this call: {@code 201} with the + * {@code offers}. For more than 100, a {@code renewal_run} job makes them: {@code 202} with the + * {@code job} and its {@code batchId}. Follow the job with {@link #retrieveSeasonBulkJob}, + * then read the offers with {@link #listSeasonRenewalOffers(String, String, Integer)}. Can + * refuse with {@code 409 job_mismatch} or {@code 503 season_not_configured}. + * + *

Header replay: the same idempotency key is reused on every retry and answers the same job. + * + * @param seasonKey the Season key + * @param params the request body: {@code deadlineAt}, and optionally + * {@code successorPlanActivationId} and {@code contractIds} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response: the {@code offers}, or the {@code job} and {@code batchId} + */ public Map createSeasonRenewalOffers( String seasonKey, Map params, String idempotencyKey) { return http.postWithHeaderReplay( path(seasonKey, "/renewal-offers"), params, idempotencyKey); } + /** + * Lists the first page of renewal offers, newest first. + * + * @param seasonKey the Season key + * @return the decoded response, with the {@code offers} and {@code nextCursor} + */ public Map listSeasonRenewalOffers(String seasonKey) { - return http.get(path(seasonKey, "/renewal-offers")); + return listSeasonRenewalOffers(seasonKey, null, null); } + /** + * Lists renewal offers, one page at a time, newest first. Null arguments are not sent. + * + * @param seasonKey the Season key + * @param cursor the {@code nextCursor} from the previous page, or {@code null} + * @param limit offers a page, 1-100, or {@code null} for 100 + * @return the decoded response, with the {@code offers} and {@code nextCursor} (null on the + * last page) + */ + public Map listSeasonRenewalOffers(String seasonKey, String cursor, Integer limit) { + return http.get(path(seasonKey, "/renewal-offers"), body("cursor", cursor, "limit", limit)); + } + + /** + * Retrieves a renewal offer, with its {@code seats} and the {@code priceTypeKey} each renews at. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ public Map retrieveSeasonRenewalOffer(String seasonKey, String offerId) { return http.get(path(seasonKey, "/renewal-offers/" + encode(offerId))); } @@ -204,6 +350,13 @@ public Map extendSeasonRenewalOffer( body("deadlineAt", deadlineAt)); } + /** + * Inspects a renewal offer, with its {@code seats} and the {@code priceTypeKey} each renews at. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ public Map inspectSeasonRenewalOffer(String seasonKey, String offerId) { return http.get(path(seasonKey, "/renewal-offers/" + encode(offerId) + "/inspect")); } @@ -224,9 +377,35 @@ public Map commitSeasonRenewalOffer( "planActivationId", planActivationId)); } + /** + * Declines a renewal offer and releases its seats at once, with no reason. + * + *

Single attempt; a repeated decline answers the same offer. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ public Map declineSeasonRenewalOffer(String seasonKey, String offerId) { + return declineSeasonRenewalOffer(seasonKey, offerId, null); + } + + /** + * Declines a renewal offer and releases its seats at once. + * + *

Single attempt; a repeated decline answers the same offer and keeps the first reason. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @param params the optional request body ({@code reason}, 1 to 200 characters, kept on the + * offer as {@code declineReason}), or {@code null} for none + * @return the decoded response + */ + public Map declineSeasonRenewalOffer( + String seasonKey, String offerId, Map params) { return http.post( - path(seasonKey, "/renewal-offers/" + encode(offerId) + "/decline"), Map.of()); + path(seasonKey, "/renewal-offers/" + encode(offerId) + "/decline"), + params == null ? Map.of() : params); } public Map releaseSeasonRenewalOffer(String seasonKey, String offerId) { @@ -238,6 +417,27 @@ public Map listSeasonOccurrences(String seasonKey) { return http.get(path(seasonKey, "/occurrences")); } + /** + * Amends one date of a published Season. + * + *

A {@code cancel} also voids every live ticket for the date as a {@code date_cancel_void} + * job, {@link #dateCancelVoidJobId(String)} of the answer's {@code amendmentId}; follow it with + * {@link #retrieveSeasonBulkJob}. Each Season ticket it voids is told by + * {@code season.ticket.voided} with reason {@code date_cancelled}. A cancelled date can't be + * moved ({@code reschedule}), postponed or replaced: {@code 409 performance_cancelled}. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param params the request body: {@code eventKey} and {@code kind} ({@code reschedule}, + * {@code replace}, {@code cancel_exception}, {@code postpone} or {@code cancel}), plus + * optional {@code startsAt}, {@code name}, {@code planActivationId}, and, with + * {@code cancel} only, {@code refund} ({@code none} or {@code per_date}, each order's + * share of the date; a whole-order refund is the order refund) and {@code reason}; a + * refunding cancel answers with its {@code refundJob} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ public Map createSeasonAmendment( String seasonKey, Map params, String idempotencyKey) { return http.postWithHeaderReplay(path(seasonKey, "/amendments"), params, idempotencyKey); @@ -282,4 +482,690 @@ public Map listSeasonAudit(String seasonKey) { public Map exportSeasonSupportSnapshot(String seasonKey) { return http.get(path(seasonKey, "/export")); } + + /** + * Dry-runs or commits renewal rights derived from a previous Season's holders. + * + *

A dry run answers {@code 200} and a commit of 100 holders or fewer {@code 201}, both with + * the {@code import}. A commit of more than 100 holders answers {@code 202} with the + * {@code job}: a {@code holder_import} job brings them across. Follow it with + * {@link #retrieveSeasonBulkJob}; its {@code summary} has the same counts when it completes. + * Can refuse with {@code 409 job_mismatch} or {@code 503 job_busy} or + * {@code season_not_configured}. + * + *

Header replay: the same idempotency key is reused on every retry and answers the same job. + * + * @param seasonKey the Season key + * @param params the request body: {@code sourceSeasonId} and {@code successorPlanActivationId}, + * and optionally {@code dryRun} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response: the {@code import}, or the {@code job} + */ + public Map createSeasonHolderImportFromSeason( + String seasonKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/imports/from-season"), params, idempotencyKey); + } + + /** + * Lists the Season's performances and their package membership, in every lifecycle state. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map listSeasonPerformances(String seasonKey) { + return http.get(path(seasonKey, "/performances")); + } + + /** + * Creates performances inside a draft Season from a repeat rule. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param params the request body: {@code titlePattern}, {@code timezone} and {@code repeat}, + * plus any optional fields + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createSeasonPerformances( + String seasonKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay(path(seasonKey, "/performances"), params, idempotencyKey); + } + + /** + * Attaches existing Events to a draft Season and its packages. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param params the request body: {@code eventKeys}, and optionally {@code attachToPlanKeys} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map attachSeasonPerformances( + String seasonKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/performances/attach"), params, idempotencyKey); + } + + /** + * Removes a performance from a draft Season and from every package. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param eventKey the performance's Event key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map deleteSeasonPerformance( + String seasonKey, String eventKey, String idempotencyKey) { + return http.mutationWithHeaderReplay( + "DELETE", path(seasonKey, "/performances/" + encode(eventKey)), null, idempotencyKey); + } + + /** + * Renames a draft package or changes the performances it covers. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param planKey the package (Plan) key + * @param params the request body: {@code name} and/or {@code eventKeys} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map updateSeasonPlan( + String seasonKey, String planKey, Map params, String idempotencyKey) { + return http.mutationWithHeaderReplay( + "PATCH", path(seasonKey, "/plans/" + encode(planKey)), params, idempotencyKey); + } + + /** + * Deletes a draft package that is not the Season's last one. The API answers 204 No Content. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param planKey the package (Plan) key + * @param idempotencyKey a stable key, or {@code null} to generate one + */ + public void deleteSeasonPlan(String seasonKey, String planKey, String idempotencyKey) { + http.mutationWithHeaderReplay( + "DELETE", path(seasonKey, "/plans/" + encode(planKey)), null, idempotencyKey); + } + + /** + * Activates the Season structure and publishes every draft package in one retryable call. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map publishSeason(String seasonKey, String idempotencyKey) { + return http.postWithHeaderReplay(path(seasonKey, "/publish"), null, idempotencyKey); + } + + /** + * Runs a server-side hold and release that proves the Season buyer path, with default options. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createSeasonCheckoutTest(String seasonKey, String idempotencyKey) { + return createSeasonCheckoutTest(seasonKey, null, idempotencyKey); + } + + /** + * Runs a server-side hold and release that proves the Season buyer path. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param params the optional request body ({@code planKey}, {@code quantity}), or {@code null} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createSeasonCheckoutTest( + String seasonKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay(path(seasonKey, "/checkout-tests"), params, idempotencyKey); + } + + /** + * Lists recent Season checkout tests, newest first. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map listSeasonCheckoutTests(String seasonKey) { + return http.get(path(seasonKey, "/checkout-tests")); + } + + /** + * Replaces a package's price table, per chart category, in minor units. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param planKey the package (Plan) key + * @param params the request body: {@code prices}, and optionally {@code currency}; each price + * takes {@code categoryKey}, {@code unitPrice} and an optional {@code priceTypeKey} + * ({@code adult}, the default, {@code child}, {@code concession}, {@code senior} or + * {@code student}) + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map replaceSeasonPackagePrices( + String seasonKey, String planKey, Map params, String idempotencyKey) { + return http.mutationWithHeaderReplay( + "PUT", path(seasonKey, "/plans/" + encode(planKey) + "/prices"), params, idempotencyKey); + } + + /** + * Lists every package price table for the Season. Each price carries its + * {@code priceTypeKey} and {@code priceTypeLabel}. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map listSeasonPrices(String seasonKey) { + return http.get(path(seasonKey, "/prices")); + } + + /** + * Adds a performance to a published package and extends its allocations. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param planKey the package (Plan) key + * @param params the request body: {@code eventKey} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map attachSeasonPlanPerformance( + String seasonKey, String planKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/plans/" + encode(planKey) + "/performances"), params, idempotencyKey); + } + + /** + * Removes a performance from a published package and releases its allocations. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param planKey the package (Plan) key + * @param eventKey the performance's Event key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map deleteSeasonPlanPerformance( + String seasonKey, String planKey, String eventKey, String idempotencyKey) { + return http.mutationWithHeaderReplay( + "DELETE", + path(seasonKey, "/plans/" + encode(planKey) + "/performances/" + encode(eventKey)), + null, + idempotencyKey); + } + + /** + * Marks a performance past so it stops blocking new Season sales. A cancelled date answers + * {@code 409 performance_cancelled}. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param eventKey the performance's Event key + * @param params the request body, which may be empty or carry {@code force} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map markSeasonPerformancePast( + String seasonKey, String eventKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/performances/" + encode(eventKey) + "/past"), params, idempotencyKey); + } + + /** + * Returns one Season occurrence allocation to ordinary Event inventory. + * + *

Single attempt: the server makes a repeat with the same {@code actionId} exact, so + * reconcile an unknown outcome by sending the same body again yourself. + * + * @param seasonKey the Season key + * @param eventKey the occurrence's Event key + * @param params the request body: {@code actionId}, {@code bookingRef}, + * {@code planActivationId}, {@code labels} and {@code source} + * @return the decoded response + */ + public Map returnSeasonOccurrenceInventory( + String seasonKey, String eventKey, Map params) { + return http.post(path(seasonKey, "/occurrences/" + encode(eventKey) + "/returns"), params); + } + + /** + * Reclaims returned occurrence inventory while it is still free. + * + *

Single attempt: the server makes a repeat with the same {@code actionId} exact. + * + * @param seasonKey the Season key + * @param eventKey the occurrence's Event key + * @param params the request body: {@code actionId}, {@code returnActionId}, + * {@code bookingRef}, {@code planActivationId}, {@code labels} and {@code source} + * @return the decoded response + */ + public Map reclaimSeasonOccurrenceInventory( + String seasonKey, String eventKey, Map params) { + return http.post(path(seasonKey, "/occurrences/" + encode(eventKey) + "/reclaims"), params); + } + + /** + * Lists the first page of Season ticket holders. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map listSeasonHolders(String seasonKey) { + return listSeasonHolders(seasonKey, null, null, null, null); + } + + /** + * Lists Season ticket holders, one page at a time. Null filters are not sent. + * + * @param seasonKey the Season key + * @param q search by holder name, email, reference, order number or seat, or {@code null} + * @param status only holders in this state, or {@code null} + * @param cursor the cursor from the previous page, or {@code null} + * @param limit the page size, or {@code null} for the server default + * @return the decoded response + */ + public Map listSeasonHolders( + String seasonKey, String q, String status, String cursor, Integer limit) { + return http.get( + path(seasonKey, "/holders"), + body("q", q, "status", status, "cursor", cursor, "limit", limit)); + } + + /** + * Refunds a Season order in full through its payment provider. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map refundSeasonOrder( + String seasonKey, String orderId, String idempotencyKey) { + return refundSeasonOrder(seasonKey, orderId, null, idempotencyKey); + } + + /** + * Refunds a Season order in full or in part through its payment provider. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param params the optional request body ({@code amountMinor}), or {@code null} for a full refund + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map refundSeasonOrder( + String seasonKey, String orderId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/orders/" + encode(orderId) + "/refund"), params, idempotencyKey); + } + + /** + * Cancels a Season order without moving money and releases every date. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param params the request body: {@code reason}, plus any optional fields + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map cancelSeasonOrder( + String seasonKey, String orderId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/orders/" + encode(orderId) + "/cancel"), params, idempotencyKey); + } + + /** + * Takes back one date of a Season order and puts its seats back on sale. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param eventKey the date's Event key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map cancelSeasonOrderDate( + String seasonKey, String orderId, String eventKey, String idempotencyKey) { + return cancelSeasonOrderDate(seasonKey, orderId, eventKey, null, idempotencyKey); + } + + /** + * Takes back one date of a Season order and puts its seats back on sale. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param eventKey the date's Event key + * @param params the optional request body ({@code actionId}), or {@code null} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map cancelSeasonOrderDate( + String seasonKey, + String orderId, + String eventKey, + Map params, + String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/orders/" + encode(orderId) + "/dates/" + encode(eventKey) + "/cancel"), + params, + idempotencyKey); + } + + /** + * Emails a Season order's tickets again, to the address on the order. + * + *

Single attempt, never retried automatically. + * + * @param seasonKey the Season key + * @param orderId the order id + * @return the decoded response + */ + public Map resendSeasonOrderTickets(String seasonKey, String orderId) { + return resendSeasonOrderTickets(seasonKey, orderId, null); + } + + /** + * Emails a Season order's tickets again. + * + *

Single attempt, never retried automatically. + * + * @param seasonKey the Season key + * @param orderId the order id + * @param params the optional request body ({@code email}), or {@code null} + * @return the decoded response + */ + public Map resendSeasonOrderTickets( + String seasonKey, String orderId, Map params) { + return http.post(path(seasonKey, "/orders/" + encode(orderId) + "/resend"), params); + } + + /** + * Retrieves hosted renewal links, payments and emails for the first page of offers. + * + * @param seasonKey the Season key + * @return the decoded response, with the {@code offers} and {@code nextCursor} + */ + public Map retrieveSeasonHostedRenewals(String seasonKey) { + return retrieveSeasonHostedRenewals(seasonKey, null, null); + } + + /** + * Retrieves hosted renewal links, payments and emails per offer, one page at a time, newest + * first. Null arguments are not sent. + * + * @param seasonKey the Season key + * @param cursor the {@code nextCursor} from the previous page, or {@code null} + * @param limit offers a page, 1-500, or {@code null} for 500 + * @return the decoded response, with the {@code offers} and {@code nextCursor} (null on the + * last page) + */ + public Map retrieveSeasonHostedRenewals(String seasonKey, String cursor, Integer limit) { + return http.get(path(seasonKey, "/renewal-hosted"), body("cursor", cursor, "limit", limit)); + } + + /** + * Makes a new renewal link for one holder; the old link stops working. + * + *

Single attempt, never retried automatically: the new link is shown once. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ + public Map rotateSeasonRenewalOfferLink(String seasonKey, String offerId) { + return http.post(path(seasonKey, "/renewal-offers/" + encode(offerId) + "/link")); + } + + /** + * Sets the name and email a renewal offer is sent to. + * + *

Single attempt; the server applies a repeat of the same contact exactly once. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @param params the request body: {@code email} and/or {@code name} + * @return the decoded response + */ + public Map updateSeasonRenewalOfferContact( + String seasonKey, String offerId, Map params) { + return http.post( + path(seasonKey, "/renewal-offers/" + encode(offerId) + "/contact"), params); + } + + /** + * Emails renewal invitations now, and reminders before the deadline, for every offer. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map createSeasonRenewalNotices(String seasonKey) { + return createSeasonRenewalNotices(seasonKey, null); + } + + /** + * Emails renewal invitations now, and reminders before the deadline. + * + *

Single attempt; the server does not send the same notice twice. + * + * @param seasonKey the Season key + * @param params the optional request body ({@code offerIds}, at most 20,000; more is refused + * with {@code 422 too_many_offer_ids}; or instead {@code batchId}, every open offer one + * {@code createSeasonRenewalOffers} batch made), or {@code null} for every open offer + * @return the decoded response + */ + public Map createSeasonRenewalNotices( + String seasonKey, Map params) { + return http.post(path(seasonKey, "/renewal-notices"), params); + } + + /** + * Sends one holder a new renewal invitation. + * + *

Single attempt, never retried automatically. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ + public Map resendSeasonRenewalOffer(String seasonKey, String offerId) { + return http.post(path(seasonKey, "/renewal-offers/" + encode(offerId) + "/resend")); + } + + /** + * Retrieves what renewing an offer costs, priced as the SeatLayer checkout will charge it. + * + *

{@code problem} is null when SeatLayer checkout can take the renewal now; otherwise it + * says why not. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ + public Map retrieveSeasonRenewalQuote(String seasonKey, String offerId) { + return http.get(path(seasonKey, "/renewal-offers/" + encode(offerId) + "/quote")); + } + + /** + * Records that the holder means to renew, for a renewal taken by phone or at the box office, + * so your own checkout can commit the offer. + * + *

Single attempt; a repeat answers the same offer. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @return the decoded response + */ + public Map createSeasonRenewalIntent(String seasonKey, String offerId) { + return http.post(path(seasonKey, "/renewal-offers/" + encode(offerId) + "/intent")); + } + + /** + * Starts a SeatLayer checkout that renews an offer, with no hosted page. Send the buyer to + * {@code payment.url}; paying renews the offer. + * + *

Header replay: the same idempotency key is reused on every retry and answers the same + * order. + * + * @param seasonKey the Season key + * @param offerId the renewal offer id + * @param params the request body: {@code returnUrl} (on one of the organization's allowed + * domains), plus optional {@code buyerEmail}, {@code buyerName} and {@code provider} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ + public Map createSeasonRenewalCheckout( + String seasonKey, String offerId, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/renewal-offers/" + encode(offerId) + "/checkout"), params, idempotencyKey); + } + + /** + * Retrieves Season money, seats, holders and renewals as totals. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map retrieveSeasonSalesReport(String seasonKey) { + return http.get(path(seasonKey, "/reports/sales")); + } + + /** + * Retrieves the seats still free per package and per date. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map retrieveSeasonSeatsReport(String seasonKey) { + return http.get(path(seasonKey, "/reports/seats")); + } + + /** + * Downloads every Season ticket holder as CSV. + * + * @param seasonKey the Season key + * @return the CSV text, exactly as the API sent it + */ + public String exportSeasonHoldersCsv(String seasonKey) { + return exportSeasonHoldersCsv(seasonKey, null, null); + } + + /** + * Downloads the matching Season ticket holders as CSV. Null filters are not sent. + * + * @param seasonKey the Season key + * @param q search by holder name, email, reference, order number or seat, or {@code null} + * @param status only holders in this state, or {@code null} + * @return the CSV text, exactly as the API sent it + */ + public String exportSeasonHoldersCsv(String seasonKey, String q, String status) { + return http.getText(path(seasonKey, "/reports/holders.csv"), body("q", q, "status", status)); + } + + /** + * Downloads every Season order SeatLayer took as CSV. + * + * @param seasonKey the Season key + * @return the CSV text, exactly as the API sent it + */ + public String exportSeasonOrdersCsv(String seasonKey) { + return http.getText(path(seasonKey, "/reports/orders.csv"), null); + } + + /** + * Retrieves Season seats sold per package and per date. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map retrieveSeasonSeatCounts(String seasonKey) { + return http.get(path(seasonKey, "/seat-counts")); + } + + /** + * Refunds every SeatLayer order holding a cancelled date, as a job. Follow it with + * {@link #retrieveSeasonBulkJob}. + * + *

Header replay: the same idempotency key is reused on every retry and replays the same job. + * + * @param seasonKey the Season key + * @param eventKey the cancelled date's Event key + * @param params the request body: {@code amount} ({@code per_date}, the date's share of each + * order; {@code full} is refused with {@code 422 date_refund_per_date_only}, so refund a + * whole order with the order refund) and optionally {@code reason} + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response, with the {@code job} + */ + public Map refundSeasonPerformance( + String seasonKey, String eventKey, Map params, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/performances/" + encode(eventKey) + "/refunds"), params, idempotencyKey); + } + + /** + * Releases the seats of every lapsed renewal offer, as a job. An offer extended before its + * turn is skipped and keeps its seat. Follow it with {@link #retrieveSeasonBulkJob}. + * + *

Header replay: the same idempotency key is reused on every retry and replays the same job. + * + * @param seasonKey the Season key + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response, with the {@code job} + */ + public Map releaseSeasonLapsedOffers(String seasonKey, String idempotencyKey) { + return http.postWithHeaderReplay( + path(seasonKey, "/renewal-offers/release-lapsed"), null, idempotencyKey); + } + + /** + * Lists the Season's bulk jobs, newest first (100 at most). + * + * @param seasonKey the Season key + * @return the decoded response, with the {@code jobs} + */ + public Map listSeasonBulkJobs(String seasonKey) { + return http.get(path(seasonKey, "/jobs")); + } + + /** + * Retrieves one bulk job with its counts, its {@code summary} (empty until it completes) and + * up to 500 failed items. + * + * @param seasonKey the Season key + * @param jobId the job id + * @return the decoded response, with the {@code job} + */ + public Map retrieveSeasonBulkJob(String seasonKey, String jobId) { + return http.get(path(seasonKey, "/jobs/" + encode(jobId))); + } } diff --git a/src/main/java/io/seatlayer/SeatLayer.java b/src/main/java/io/seatlayer/SeatLayer.java index 7bf063e..a7498d2 100644 --- a/src/main/java/io/seatlayer/SeatLayer.java +++ b/src/main/java/io/seatlayer/SeatLayer.java @@ -20,7 +20,10 @@ public final class SeatLayer { private final Charts charts; private final Channels channels; private final Events events; + private final EventSeries eventSeries; + private final Holders holders; private final Inventory inventory; + private final Orders orders; private final PerformanceGroups performanceGroups; private final Seasons seasons; private final Sessions sessions; @@ -38,7 +41,10 @@ private SeatLayer(Builder builder) { this.charts = new Charts(http); this.channels = new Channels(http); this.events = new Events(http); + this.eventSeries = new EventSeries(http); + this.holders = new Holders(http); this.inventory = new Inventory(http); + this.orders = new Orders(http); this.performanceGroups = new PerformanceGroups(http); this.seasons = new Seasons(http); this.sessions = new Sessions(http); @@ -64,10 +70,37 @@ public Events events() { return events; } + /** + * Event series: dated events planned from one schedule and changed together. + * + * @return the event series resource + */ + public EventSeries eventSeries() { + return eventSeries; + } + + /** + * Season ticket holders, their tickets and transfers, and the customer list. + * + * @return the holders resource + */ + public Holders holders() { + return holders; + } + public Inventory inventory() { return inventory; } + /** + * Orders across events and seasons, and one event's orders, refunds and cancellations. + * + * @return the orders resource + */ + public Orders orders() { + return orders; + } + /** Fixed multi-performance lifecycle and host-side booking coordination. */ public PerformanceGroups performanceGroups() { return performanceGroups; diff --git a/src/main/java/io/seatlayer/SeatLayerHttpClient.java b/src/main/java/io/seatlayer/SeatLayerHttpClient.java index 5eecce1..4a83241 100644 --- a/src/main/java/io/seatlayer/SeatLayerHttpClient.java +++ b/src/main/java/io/seatlayer/SeatLayerHttpClient.java @@ -106,6 +106,24 @@ private Map performRequest( Object body, boolean headerReplay, String idempotencyKey) { + Response response = + exchange(method, path, query, body, headerReplay, idempotencyKey, "application/json"); + if (response.status() == 204 || response.body() == null || response.body().isBlank()) { + return Map.of(); + } + Object decoded = Json.read(response.body()); + return decoded instanceof Map ? Json.readObject(response.body()) : Map.of("data", decoded); + } + + /** Sends with auth, idempotency and retry, and returns the 2xx response undecoded. */ + private Response exchange( + String method, + String path, + Map query, + Object body, + boolean headerReplay, + String idempotencyKey, + String accept) { StringBuilder url = new StringBuilder(baseUrl).append(path); if (query != null && !query.isEmpty()) { @@ -124,7 +142,7 @@ private Map performRequest( Map headers = new LinkedHashMap<>(); headers.put("Authorization", "Bearer " + secretKey); - headers.put("Accept", "application/json"); + headers.put("Accept", accept); headers.put("User-Agent", "seatlayer-java"); String payload = null; @@ -159,11 +177,7 @@ private Map performRequest( } if (response.status() >= 200 && response.status() < 300) { - if (response.status() == 204 || response.body() == null || response.body().isBlank()) { - return Map.of(); - } - Object decoded = Json.read(response.body()); - return decoded instanceof Map ? Json.readObject(response.body()) : Map.of("data", decoded); + return response; } Map errorBody; @@ -197,6 +211,12 @@ Map get(String path, Map query) { return performRequest("GET", path, query, null, false, null); } + /** A read whose body is text, such as a CSV export, returned as sent rather than decoded. */ + String getText(String path, Map query) { + String text = exchange("GET", path, query, null, false, null, "text/csv, application/json").body(); + return text == null ? "" : text; + } + Map post(String path) { return performRequest("POST", path, null, null, false, null); } diff --git a/src/test/java/io/seatlayer/ClientTest.java b/src/test/java/io/seatlayer/ClientTest.java index a18a9e6..a9a704a 100644 --- a/src/test/java/io/seatlayer/ClientTest.java +++ b/src/test/java/io/seatlayer/ClientTest.java @@ -521,6 +521,22 @@ void listAllSkipsCounts() { assertTrue(call(0).url().contains("counts=0")); } + @Test + @DisplayName("events filter by series") + void eventsFilterBySeries() { + SeatLayer sdk = client(List.of(Stub.of(200, "{\"events\":[]}"), Stub.of(200, "{\"events\":[]}"))); + sdk.events().list(EventListOptions.builder().seriesKey("ser a/b").limit(10).build()); + assertTrue(call(0).url().endsWith("/v1/events?limit=10&seriesKey=ser+a%2Fb"), call(0).url()); + for (Map ignored + : sdk.events().listAll(EventListOptions.builder().counts(false).seriesKey("ser_1").build())) { + // drain + } + assertTrue(call(1).url().contains("seriesKey=ser_1"), call(1).url()); + assertEquals( + new EventListOptions("ws_1", null, 5, null, true, null), + new EventListOptions("ws_1", null, 5, null, true)); + } + @Test @DisplayName("a single explicit page keeps counts") void singlePageKeepsCounts() { diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java new file mode 100644 index 0000000..4ddffce --- /dev/null +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -0,0 +1,597 @@ +package io.seatlayer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Holders, orders, event sales and event series: routes, bodies, replay classes, CSV and 204s. */ +final class SeasonPlatformTest { + + private static final String BASE = "https://api.seatlayer.io"; + + private record Call(String method, String url, Map headers, String body) { + } + + /** One expected request: verb plus path and query, the JSON body or null, and header replay. */ + private record Expected(String request, String body, boolean replay) { + } + + private final List calls = new ArrayList<>(); + + /** Answers every request with 200 and the same body. */ + private SeatLayer client(String responseBody) { + return SeatLayer.builder() + .secretKey("sk_test_abc") + .transport((method, url, headers, requestBody) -> { + calls.add(new Call(method, url, Map.copyOf(headers), requestBody)); + return new SeatLayerHttpClient.Response(200, responseBody, Map.of()); + }) + .build(); + } + + /** Answers requests from a queue, in order. */ + private SeatLayer client(List responses) { + Deque queue = new ArrayDeque<>(responses); + return SeatLayer.builder() + .secretKey("sk_test_abc") + .transport((method, url, headers, requestBody) -> { + calls.add(new Call(method, url, Map.copyOf(headers), requestBody)); + if (queue.isEmpty()) { + throw new AssertionError("more requests than queued responses"); + } + return queue.poll(); + }) + .build(); + } + + private static SeatLayerHttpClient.Response rateLimited() { + return new SeatLayerHttpClient.Response( + 429, "{\"error\":\"rate_limited\"}", Map.of("retry-after", "0")); + } + + private void assertCalls(List expected) { + assertEquals(expected.size(), calls.size()); + for (int index = 0; index < expected.size(); index++) { + Call call = calls.get(index); + Expected want = expected.get(index); + String label = "call " + index + " " + want.request(); + assertEquals(want.request(), call.method() + " " + call.url().substring(BASE.length()), label); + if (want.body() == null) { + assertNull(call.body(), label); + } else { + assertEquals(Json.read(want.body()), Json.read(call.body()), label); + } + if (want.replay()) { + assertTrue(call.headers().containsKey("Idempotency-Key"), label); + } else { + assertNull(call.headers().get("Idempotency-Key"), label); + } + } + } + + @Test + void mapsAll14HolderOperations() { + SeatLayer sdk = client("{}"); + Holders holders = sdk.holders(); + String ref = "holder a/b"; + + holders.listCustomers("fan@example.com", "h/1", "c/1", 10); + holders.listCustomers(); + holders.retrieveHolder(ref); + holders.updateHolder(ref, Map.of("email", "new@example.com"), "holder-update-1"); + holders.revokeHolderSessions(ref); + holders.revokeHolderSessions(ref, Map.of("sessionId", "csn_0123456789abcdef01234567")); + holders.listHolderSeasons(ref); + holders.listHolderTickets(ref, "sea/a", 1700000000000L, 1800000000000L, "c/2", 20); + holders.listHolderTickets(ref); + holders.retrieveHolderTicket(ref, "tkt/1"); + holders.createHolderTransfer( + ref, Map.of("ticketIds", List.of("tkt_1"), "toEmail", "friend@example.com"), null); + holders.listHolderTransfers(ref); + holders.cancelHolderTransfer(ref, "tr/1"); + holders.acceptHolderTransfer(ref, "tr/1"); + holders.createHolderReturn( + ref, Map.of("ticketIds", List.of("tkt_0123456789abcdef01234567")), "return-1"); + holders.createHolderRenewalCheckout(ref, "offer/1", Map.of( + "returnUrl", "https://tickets.example/renewed", "buyerEmail", "cara@example.com", + "buyerName", "Cara", "provider", "stripe"), "renew-1"); + holders.declineHolderRenewalOffer(ref, "offer/1", Map.of("reason", "Moving away")); + holders.declineHolderRenewalOffer(ref, "offer/1"); + + String holder = "/v1/holders/holder%20a%2Fb"; + assertCalls(List.of( + new Expected( + "GET /v1/customers?email=fan%40example.com&holderRef=h%2F1&cursor=c%2F1&limit=10", + null, false), + new Expected("GET /v1/customers", null, false), + new Expected("GET " + holder, null, false), + new Expected("PATCH " + holder, "{\"email\":\"new@example.com\"}", true), + new Expected("POST " + holder + "/sessions/revoke", null, false), + new Expected( + "POST " + holder + "/sessions/revoke", + "{\"sessionId\":\"csn_0123456789abcdef01234567\"}", false), + new Expected("GET " + holder + "/seasons", null, false), + new Expected( + "GET " + holder + + "/tickets?seasonKey=sea%2Fa&from=1700000000000&to=1800000000000&cursor=c%2F2&limit=20", + null, false), + new Expected("GET " + holder + "/tickets", null, false), + new Expected("GET " + holder + "/tickets/tkt%2F1", null, false), + new Expected( + "POST " + holder + "/transfers", + "{\"ticketIds\":[\"tkt_1\"],\"toEmail\":\"friend@example.com\"}", true), + new Expected("GET " + holder + "/transfers", null, false), + new Expected("POST " + holder + "/transfers/tr%2F1/cancel", null, false), + new Expected("POST " + holder + "/transfers/tr%2F1/accept", null, false), + new Expected( + "POST " + holder + "/returns", + "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true), + new Expected( + "POST " + holder + "/renewal-offers/offer%2F1/checkout", + "{\"returnUrl\":\"https://tickets.example/renewed\",\"buyerEmail\":\"cara@example.com\"," + + "\"buyerName\":\"Cara\",\"provider\":\"stripe\"}", + true), + new Expected( + "POST " + holder + "/renewal-offers/offer%2F1/decline", + "{\"reason\":\"Moving away\"}", false), + new Expected("POST " + holder + "/renewal-offers/offer%2F1/decline", null, false))); + assertEquals("holder-update-1", calls.get(3).headers().get("Idempotency-Key")); + assertEquals("return-1", calls.get(14).headers().get("Idempotency-Key")); + assertEquals("renew-1", calls.get(15).headers().get("Idempotency-Key")); + } + + @Test + void mapsAll9OrderOperations() { + SeatLayer sdk = client("{}"); + Orders orders = sdk.orders(); + + orders.listOrders("ev/1", "sea/a", "confirmed", 1700000000000L, 1800000000000L, "c/1", 50); + orders.listOrders(); + orders.retrieveOrder("ord/1"); + orders.listOrderTickets("ord/1"); + orders.listEventOrders("ev/1", "ann lee", "refunded", "card", 1L, 2L, "c/2", 25); + orders.listEventOrders("ev/1"); + orders.retrieveEventOrder("ev/1", "ord/1"); + orders.exportEventOrdersCsv("ev/1", "ann", "confirmed", "card", 1L, 2L); + orders.refundEventOrder("ev/1", "ord/1", Map.of("amountMinor", 250), "event-refund-1"); + orders.refundEventOrder("ev/1", "ord/1", null); + orders.cancelEventOrder( + "ev/1", "ord/1", Map.of("reason", "event_cancelled", "notifyBuyer", true), "event-cancel-1"); + orders.resendEventOrderTickets("ev/1", "ord/1"); + orders.resendEventOrderTickets("ev/1", "ord/1", Map.of("email", "fan@example.com")); + orders.listOrders(null, null, "ord/1", null, null, null, null, null); + + String order = "/v1/events/ev%2F1/orders/ord%2F1"; + assertCalls(List.of( + new Expected( + "GET /v1/orders?eventKey=ev%2F1&seasonKey=sea%2Fa&status=confirmed" + + "&from=1700000000000&to=1800000000000&cursor=c%2F1&limit=50", + null, false), + new Expected("GET /v1/orders", null, false), + new Expected("GET /v1/orders/ord%2F1", null, false), + new Expected("GET /v1/orders/ord%2F1/tickets", null, false), + new Expected( + "GET /v1/events/ev%2F1/orders?q=ann+lee&status=refunded&paidBy=card" + + "&from=1&to=2&cursor=c%2F2&limit=25", + null, false), + new Expected("GET /v1/events/ev%2F1/orders", null, false), + new Expected("GET " + order, null, false), + new Expected( + "GET /v1/events/ev%2F1/orders.csv?q=ann&status=confirmed&paidBy=card&from=1&to=2", + null, false), + new Expected("POST " + order + "/refund", "{\"amountMinor\":250}", true), + new Expected("POST " + order + "/refund", null, true), + new Expected( + "POST " + order + "/cancel", + "{\"reason\":\"event_cancelled\",\"notifyBuyer\":true}", true), + new Expected("POST " + order + "/resend", null, false), + new Expected("POST " + order + "/resend", "{\"email\":\"fan@example.com\"}", false), + new Expected("GET /v1/orders?holderRef=ord%2F1", null, false))); + assertEquals("event-refund-1", calls.get(8).headers().get("Idempotency-Key")); + assertEquals("event-cancel-1", calls.get(10).headers().get("Idempotency-Key")); + } + + @Test + void mapsAll14EventSalesOperations() { + SeatLayer sdk = client("{}"); + Events events = sdk.events(); + String key = "ev/1"; + Map clearFee = new LinkedHashMap<>(); + clearFee.put("bookingFeeMinor", null); + + events.retrieveEventBuyerCheckout(key); + events.replaceEventBuyerCheckout(key, Map.of("buyerCheckout", "seatlayer", "expectedRevision", 3)); + events.retrieveEventPaymentGateway(key, "EUR"); + events.retrieveEventPaymentGateway(key); + events.replaceEventPaymentGateway(key, Map.of("connectionId", "conn_1")); + events.replaceEventPaymentGateway(key); + events.retrieveEventSalesReadiness(key); + events.preflightEventSales(key, Map.of("orderId", "ord_1")); + events.transitionEventSales(key, Map.of("to", "live", "expectedRevision", 4)); + events.retrieveEventBookingFee(key); + events.replaceEventBookingFee(key, Map.of("bookingFeeMinor", 150)); + events.replaceEventBookingFee(key, clearFee); + events.listEventDiscountCodes(key); + events.createEventDiscountCode(key, Map.of("code", "EARLY", "kind", "percent", "percent", 10)); + events.updateEventDiscountCode(key, "code/1", Map.of("active", false)); + events.endEventDiscountCode(key, "code/1"); + events.deleteEventDiscountCode(key, "code/1"); + + String event = "/v1/events/ev%2F1"; + assertCalls(List.of( + new Expected("GET " + event + "/buyer-checkout", null, false), + new Expected( + "PUT " + event + "/buyer-checkout", + "{\"buyerCheckout\":\"seatlayer\",\"expectedRevision\":3}", false), + new Expected("GET " + event + "/payment-gateway?currency=EUR", null, false), + new Expected("GET " + event + "/payment-gateway", null, false), + new Expected("PUT " + event + "/payment-gateway", "{\"connectionId\":\"conn_1\"}", false), + new Expected("PUT " + event + "/payment-gateway", null, false), + new Expected("GET " + event + "/sales/readiness", null, false), + new Expected("POST " + event + "/sales/preflight", "{\"orderId\":\"ord_1\"}", false), + new Expected( + "POST " + event + "/sales/transition", "{\"to\":\"live\",\"expectedRevision\":4}", false), + new Expected("GET " + event + "/booking-fee", null, false), + new Expected("PUT " + event + "/booking-fee", "{\"bookingFeeMinor\":150}", false), + new Expected("PUT " + event + "/booking-fee", "{\"bookingFeeMinor\":null}", false), + new Expected("GET " + event + "/discount-codes", null, false), + new Expected( + "POST " + event + "/discount-codes", + "{\"code\":\"EARLY\",\"kind\":\"percent\",\"percent\":10}", false), + new Expected("PATCH " + event + "/discount-codes/code%2F1", "{\"active\":false}", false), + new Expected("POST " + event + "/discount-codes/code%2F1/end", null, false), + new Expected("DELETE " + event + "/discount-codes/code%2F1", null, false))); + } + + @Test + void mapsAll5EventSeriesOperations() { + SeatLayer sdk = client("{}"); + EventSeries series = sdk.eventSeries(); + + series.createEventSeries( + Map.of( + "name", "Summer nights", + "chartKey", "chart_1", + "schedule", Map.of("repeat", "weekly", "count", 4)), + "series-1"); + series.listEventSeries(10, "c/1"); + series.listEventSeries(); + series.retrieveEventSeries("ev/1"); + series.addEventSeriesDates("ev/1", Map.of("dates", List.of("2027-06-01T19:30")), null); + series.updateEventSeries("ev/1", Map.of("name", "Summer nights 2027"), "series-update-1"); + series.addEventSeriesDates("ev/1", Map.of("retryFailed", true), null); + + assertCalls(List.of( + new Expected( + "POST /v1/event-series", + "{\"name\":\"Summer nights\",\"chartKey\":\"chart_1\"," + + "\"schedule\":{\"repeat\":\"weekly\",\"count\":4}}", + true), + new Expected("GET /v1/event-series?limit=10&cursor=c%2F1", null, false), + new Expected("GET /v1/event-series", null, false), + new Expected("GET /v1/event-series/ev%2F1", null, false), + new Expected( + "POST /v1/event-series/ev%2F1/dates", "{\"dates\":[\"2027-06-01T19:30\"]}", true), + new Expected("PATCH /v1/event-series/ev%2F1", "{\"name\":\"Summer nights 2027\"}", true), + new Expected("POST /v1/event-series/ev%2F1/dates", "{\"retryFailed\":true}", true))); + assertEquals("series-1", calls.get(0).headers().get("Idempotency-Key")); + assertEquals("series-update-1", calls.get(5).headers().get("Idempotency-Key")); + } + + @Test + void givenUpSeriesCreateAnswers409CreateAbandonedOnceWithTheNewKeyMessage() { + // The default client retries a header-replay create (3 attempts); the queued 202 would + // answer a retry, so a single call proves the 409 is not retried. + SeatLayer sdk = client(List.of( + new SeatLayerHttpClient.Response( + 409, + "{\"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.\"}", + Map.of()), + new SeatLayerHttpClient.Response(202, "{\"job\":{\"jobId\":\"job_1\"}}", Map.of()))); + Map schedule = Map.of( + "rule", "daily", "from", "2026-11-06", "until", "2026-11-08", "times", List.of("20:00")); + + SeatLayerConflictException error = assertThrows( + SeatLayerConflictException.class, + () -> sdk.eventSeries().createEventSeries( + Map.of("fromEventKey", "ev_1", "schedule", schedule), "series-3")); + + assertEquals(409, error.status()); + assertEquals("create_abandoned", error.code()); + assertTrue(error.getMessage().contains("new Idempotency-Key"), error.getMessage()); + assertEquals(1, calls.size()); + assertEquals("series-3", calls.get(0).headers().get("Idempotency-Key")); + } + + @Test + void csvExportsReturnTheRawText() { + // Not JSON on purpose: decoding it would throw. + String csv = "order,buyer\nord_1,\"Lee, Ann\"\n"; + SeatLayer sdk = client(csv); + + assertEquals(csv, sdk.seasons().exportSeasonHoldersCsv("sea/a")); + assertEquals(csv, sdk.seasons().exportSeasonOrdersCsv("sea/a")); + assertEquals(csv, sdk.orders().exportEventOrdersCsv("ev/1")); + + assertEquals(3, calls.size()); + assertEquals(BASE + "/v1/seasons/sea%2Fa/reports/holders.csv", calls.get(0).url()); + assertEquals(BASE + "/v1/seasons/sea%2Fa/reports/orders.csv", calls.get(1).url()); + assertEquals(BASE + "/v1/events/ev%2F1/orders.csv", calls.get(2).url()); + for (Call call : calls) { + assertEquals("GET", call.method()); + assertTrue(call.headers().get("Accept").startsWith("text/csv")); + assertNull(call.headers().get("Idempotency-Key")); + } + } + + @Test + void noContentDeletesReturnNothing() { + SeatLayer sdk = client(List.of( + new SeatLayerHttpClient.Response(204, "", Map.of()), + new SeatLayerHttpClient.Response(204, "", Map.of()))); + + sdk.seasons().deleteSeasonPlan("sea/a", "plan/1", "plan-delete-2"); + sdk.events().deleteEventDiscountCode("ev/1", "code/1"); + + assertCalls(List.of( + new Expected("DELETE /v1/seasons/sea%2Fa/plans/plan%2F1", null, true), + new Expected("DELETE /v1/events/ev%2F1/discount-codes/code%2F1", null, false))); + assertEquals("plan-delete-2", calls.get(0).headers().get("Idempotency-Key")); + } + + @Test + void headerReplayOperationsRetryWithOneKey() { + SeatLayer sdk = client(List.of( + rateLimited(), + new SeatLayerHttpClient.Response(200, "{\"ok\":true}", Map.of()))); + + sdk.orders().refundEventOrder("ev_1", "ord_1", null); + + assertEquals(2, calls.size()); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + } + + @Test + void bulkRefundOfADateRetriesWithOneKey() { + String job = "{\"job\":{\"jobId\":\"sbj_1\",\"kind\":\"bulk_refund\",\"state\":\"planning\"}}"; + SeatLayer sdk = client(List.of( + rateLimited(), + new SeatLayerHttpClient.Response(202, job, Map.of()))); + + Map result = sdk.seasons().refundSeasonPerformance( + "sea_1", "ev_1", Map.of("amount", "per_date"), null); + + assertEquals(Json.read(job), result); + assertCalls(List.of( + new Expected("POST /v1/seasons/sea_1/performances/ev_1/refunds", "{\"amount\":\"per_date\"}", true), + new Expected("POST /v1/seasons/sea_1/performances/ev_1/refunds", "{\"amount\":\"per_date\"}", true))); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + } + + @Test + void lapsedOfferReleaseRetriesWithOneKeyGeneratedOrGiven() { + String job = "{\"job\":{\"jobId\":\"sbj_2\",\"kind\":\"lapsed_release\",\"state\":\"planning\"}}"; + SeatLayer sdk = client(List.of( + rateLimited(), + new SeatLayerHttpClient.Response(202, job, Map.of()), + rateLimited(), + new SeatLayerHttpClient.Response(202, job, Map.of()))); + + Map result = sdk.seasons().releaseSeasonLapsedOffers("sea/a", null); + sdk.seasons().releaseSeasonLapsedOffers("sea_1", "release-1"); + + assertEquals(Json.read(job), result); + assertCalls(List.of( + new Expected("POST /v1/seasons/sea%2Fa/renewal-offers/release-lapsed", null, true), + new Expected("POST /v1/seasons/sea%2Fa/renewal-offers/release-lapsed", null, true), + new Expected("POST /v1/seasons/sea_1/renewal-offers/release-lapsed", null, true), + new Expected("POST /v1/seasons/sea_1/renewal-offers/release-lapsed", null, true))); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + assertEquals("release-1", calls.get(2).headers().get("Idempotency-Key")); + assertEquals("release-1", calls.get(3).headers().get("Idempotency-Key")); + } + + @Test + void renewalOfferPagesSendCursorAndLimit() { + SeatLayer sdk = client("{\"offers\":[],\"nextCursor\":null}"); + + Map hosted = sdk.seasons().retrieveSeasonHostedRenewals("sea/a", "c/1", 500); + sdk.seasons().listSeasonRenewalOffers("sea/a", "c/2", 100); + sdk.seasons().retrieveSeasonHostedRenewals("sea_1", null, 25); + sdk.seasons().listSeasonRenewalOffers("sea_1", null, null); + + assertTrue(hosted.containsKey("nextCursor")); + assertNull(hosted.get("nextCursor")); + assertCalls(List.of( + new Expected("GET /v1/seasons/sea%2Fa/renewal-hosted?cursor=c%2F1&limit=500", null, false), + new Expected("GET /v1/seasons/sea%2Fa/renewal-offers?cursor=c%2F2&limit=100", null, false), + new Expected("GET /v1/seasons/sea_1/renewal-hosted?limit=25", null, false), + new Expected("GET /v1/seasons/sea_1/renewal-offers", null, false))); + } + + @Test + void singleTicketsReachTheWireOnCreateAndUpdate() { + String updated = "{\"season\":{\"key\":\"sea_1\",\"singleTickets\":true},\"singleTicketDates\":" + + "{\"direction\":\"open\",\"opened\":[\"ev_1\"],\"closed\":[],\"notOpened\":[],\"jobId\":null}}"; + SeatLayer sdk = client(updated); + + sdk.seasons().createSeason( + Map.of("name", "Series", "eventKeys", List.of("ev_1", "ev_2"), "singleTickets", true), "create-1"); + Map answer = sdk.seasons().updateSeason( + "sea/a", Map.of("expectedRevision", 5, "singleTickets", false), "update-1"); + + assertEquals(Json.read(updated), answer); + assertCalls(List.of( + new Expected( + "POST /v1/seasons", + "{\"name\":\"Series\",\"eventKeys\":[\"ev_1\",\"ev_2\"],\"singleTickets\":true}", true), + new Expected("PATCH /v1/seasons/sea%2Fa", "{\"expectedRevision\":5,\"singleTickets\":false}", true))); + } + + @Test + void renewalNoticesForOneOfferBatchSendItsBatchId() { + SeatLayer sdk = client("{\"scheduled\":240,\"withoutEmail\":3}"); + + Map sent = + sdk.seasons().createSeasonRenewalNotices("sea/a", Map.of("batchId", "srb/1")); + + assertEquals(Json.read("{\"scheduled\":240,\"withoutEmail\":3}"), sent); + assertCalls(List.of( + new Expected("POST /v1/seasons/sea%2Fa/renewal-notices", "{\"batchId\":\"srb/1\"}", false))); + } + + @Test + void renewalRunAndBringAcrossAnswerAJobAbove100() { + String renewalRun = "{\"job\":{\"jobId\":\"sbj_3\",\"kind\":\"renewal_run\",\"state\":\"planning\"}," + + "\"batchId\":\"srb_1\"}"; + String holderImport = "{\"job\":{\"jobId\":\"sbj_4\",\"kind\":\"holder_import\",\"state\":\"planning\"}}"; + SeatLayer sdk = client(List.of( + new SeatLayerHttpClient.Response(202, renewalRun, Map.of()), + new SeatLayerHttpClient.Response(202, holderImport, Map.of()))); + + Map offers = sdk.seasons().createSeasonRenewalOffers( + "sea_1", Map.of("deadlineAt", 123), "offers-1"); + Map imported = sdk.seasons().createSeasonHolderImportFromSeason( + "sea_1", Map.of("sourceSeasonId", "sea_0", "successorPlanActivationId", "pa_1"), "from-1"); + + assertEquals("srb_1", offers.get("batchId")); + assertEquals(Json.read(renewalRun), offers); + assertNull(offers.get("offers")); + assertEquals(Json.read(holderImport), imported); + assertNull(imported.get("import")); + assertCalls(List.of( + new Expected("POST /v1/seasons/sea_1/renewal-offers", "{\"deadlineAt\":123}", true), + new Expected( + "POST /v1/seasons/sea_1/imports/from-season", + "{\"sourceSeasonId\":\"sea_0\",\"successorPlanActivationId\":\"pa_1\"}", true))); + } + + @Test + void holderReturnRetriesWithOneKey() { + String created = "{\"return\":{\"returnId\":\"ret_1\",\"state\":\"returned\"," + + "\"seasonKey\":\"sea_1\",\"tickets\":[],\"createdAt\":1}}"; + SeatLayer sdk = client(List.of( + rateLimited(), + new SeatLayerHttpClient.Response(201, created, Map.of()))); + + Map result = sdk.holders().createHolderReturn( + "a/b", Map.of("ticketIds", List.of("tkt_0123456789abcdef01234567")), null); + + assertEquals(Json.read(created), result); + assertCalls(List.of( + new Expected( + "POST /v1/holders/a%2Fb/returns", + "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true), + new Expected( + "POST /v1/holders/a%2Fb/returns", + "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true))); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + } + + @Test + void renewalCheckoutsRetryWithOneKeyGeneratedOrGiven() { + String started = "{\"orderId\":\"ord_1\",\"payment\":{\"url\":\"https://pay.example/1\"}}"; + SeatLayer sdk = client(List.of( + rateLimited(), + new SeatLayerHttpClient.Response(201, started, Map.of()), + rateLimited(), + new SeatLayerHttpClient.Response(201, started, Map.of()))); + Map body = Map.of("returnUrl", "https://tickets.example/r"); + + Map result = sdk.holders().createHolderRenewalCheckout("a/b", "offer_1", body, null); + sdk.seasons().createSeasonRenewalCheckout("sea_1", "offer_1", body, "renew-1"); + + assertEquals(Json.read(started), result); + String bodyJson = "{\"returnUrl\":\"https://tickets.example/r\"}"; + assertCalls(List.of( + new Expected("POST /v1/holders/a%2Fb/renewal-offers/offer_1/checkout", bodyJson, true), + new Expected("POST /v1/holders/a%2Fb/renewal-offers/offer_1/checkout", bodyJson, true), + new Expected("POST /v1/seasons/sea_1/renewal-offers/offer_1/checkout", bodyJson, true), + new Expected("POST /v1/seasons/sea_1/renewal-offers/offer_1/checkout", bodyJson, true))); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + assertEquals("renew-1", calls.get(2).headers().get("Idempotency-Key")); + assertEquals("renew-1", calls.get(3).headers().get("Idempotency-Key")); + } + + @Test + void seasonDeclineSendsTheReasonOrAnEmptyObject() { + SeatLayer sdk = client("{}"); + + sdk.seasons().declineSeasonRenewalOffer("sea/a", "offer/1", Map.of("reason", "Moving away")); + sdk.seasons().declineSeasonRenewalOffer("sea_1", "offer_1"); + + assertCalls(List.of( + new Expected( + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/decline", + "{\"reason\":\"Moving away\"}", false), + new Expected("POST /v1/seasons/sea_1/renewal-offers/offer_1/decline", "{}", false))); + } + + @Test + void unsupportedAndDomainExactOperationsAreSingleAttempt() { + SeatLayer resend = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> resend.orders().resendEventOrderTickets("ev_1", "ord_1")); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + + calls.clear(); + SeatLayer rotate = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> rotate.seasons().rotateSeasonRenewalOfferLink("sea_1", "offer_1")); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + + calls.clear(); + SeatLayer discount = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> discount.events().createEventDiscountCode( + "ev_1", Map.of("code", "EARLY", "kind", "percent", "percent", 10))); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + + calls.clear(); + SeatLayer transfer = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> transfer.holders().acceptHolderTransfer("holder_1", "tr_1")); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + + calls.clear(); + SeatLayer intent = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> intent.seasons().createSeasonRenewalIntent("sea_1", "offer_1")); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + + calls.clear(); + SeatLayer decline = client(List.of(rateLimited())); + assertThrows( + SeatLayerRateLimitException.class, + () -> decline.holders().declineHolderRenewalOffer( + "holder_1", "offer_1", Map.of("reason", "Moving away"))); + assertEquals(1, calls.size()); + assertNull(calls.get(0).headers().get("Idempotency-Key")); + } +} diff --git a/src/test/java/io/seatlayer/SeasonTest.java b/src/test/java/io/seatlayer/SeasonTest.java index 7c9432d..3daaf3c 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -2,9 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -15,7 +18,7 @@ private record Call(String method, String url, Map headers, Stri } @Test - void mapsAll48SeasonOperationsAndReplayClasses() { + void mapsAll87SeasonOperationsAndReplayClasses() { List calls = new ArrayList<>(); SeatLayer sdk = SeatLayer.builder() .secretKey("sk_test_abc") @@ -75,8 +78,62 @@ void mapsAll48SeasonOperationsAndReplayClasses() { seasons.replaySeasonOutbox(key, "occurrence/1"); seasons.listSeasonAudit(key); seasons.exportSeasonSupportSnapshot(key); + seasons.createSeasonHolderImportFromSeason( + key, Map.of("sourceSeasonId", "sea_2026", "successorPlanActivationId", "pa_1"), "from-1"); + seasons.listSeasonPerformances(key); + seasons.createSeasonPerformances( + key, + Map.of("titlePattern", "Match", "timezone", "Europe/London", "repeat", "weekly", "count", 3), + "perf-1"); + seasons.attachSeasonPerformances(key, Map.of("eventKeys", List.of("ev_1")), null); + seasons.deleteSeasonPerformance(key, "ev/1", "perf-del-1"); + seasons.updateSeasonPlan(key, "plan/1", Map.of("name", "Gold"), "plan-upd-1"); + seasons.deleteSeasonPlan(key, "plan/2", "plan-del-1"); + seasons.publishSeason(key, "publish-1"); + seasons.createSeasonCheckoutTest(key, Map.of("planKey", "plan/1", "quantity", 2), "test-1"); + seasons.listSeasonCheckoutTests(key); + seasons.replaceSeasonPackagePrices( + key, "plan/1", Map.of("prices", List.of(Map.of("category", "A", "amountMinor", 5000))), "prices-1"); + seasons.listSeasonPrices(key); + seasons.attachSeasonPlanPerformance(key, "plan/1", Map.of("eventKey", "ev_3"), "attach-1"); + seasons.deleteSeasonPlanPerformance(key, "plan/1", "ev/3", "detach-1"); + seasons.markSeasonPerformancePast(key, "ev/1", Map.of("force", true), "past-1"); + seasons.returnSeasonOccurrenceInventory(key, "ev/1", Map.of( + "actionId", "ret_1", "bookingRef", "order_1", "planActivationId", "pa_1", + "labels", List.of("A-1"), "source", Map.of("kind", "holder"))); + seasons.reclaimSeasonOccurrenceInventory(key, "ev/1", Map.of( + "actionId", "rec_1", "returnActionId", "ret_1", "bookingRef", "order_1", + "planActivationId", "pa_1", "labels", List.of("A-1"), "source", Map.of("kind", "holder"))); + seasons.listSeasonHolders(key, "ann lee", "active", "c/2", 25); + seasons.refundSeasonOrder(key, "ord/1", Map.of("amountMinor", 500), "refund-1"); + seasons.cancelSeasonOrder(key, "ord/1", Map.of("reason", "customer_request"), "cancel-order-1"); + seasons.cancelSeasonOrderDate(key, "ord/1", "ev/2", "cancel-date-1"); + seasons.resendSeasonOrderTickets(key, "ord/1", Map.of("email", "fan@example.com")); + seasons.retrieveSeasonHostedRenewals(key); + seasons.rotateSeasonRenewalOfferLink(key, "offer/1"); + seasons.updateSeasonRenewalOfferContact( + key, "offer/1", Map.of("email", "fan@example.com", "name", "Ann Lee")); + seasons.createSeasonRenewalNotices(key); + seasons.resendSeasonRenewalOffer(key, "offer/1"); + seasons.retrieveSeasonSalesReport(key); + seasons.retrieveSeasonSeatsReport(key); + String holdersCsv = seasons.exportSeasonHoldersCsv(key, "ann", "active"); + String ordersCsv = seasons.exportSeasonOrdersCsv(key); + seasons.retrieveSeasonSeatCounts(key); + seasons.refundSeasonPerformance( + key, "ev/1", Map.of("amount", "per_date", "reason", "Storm"), "refund-date-1"); + seasons.listSeasonBulkJobs(key); + seasons.retrieveSeasonBulkJob(key, "job/1"); + seasons.retrieveSeasonRenewalQuote(key, "offer/1"); + seasons.createSeasonRenewalIntent(key, "offer/1"); + seasons.createSeasonRenewalCheckout(key, "offer/1", Map.of( + "returnUrl", "https://tickets.example/renewed", "buyerEmail", "ana@example.com", + "buyerName", "Ana Silva", "provider", "stripe"), "renew-1"); + seasons.releaseSeasonLapsedOffers(key, "release-lapsed-1"); - assertEquals(48, calls.size()); + assertEquals(87, calls.size()); + assertEquals("{}", holdersCsv); + assertEquals("{}", ordersCsv); assertNull(calls.get(26).body()); List expected = List.of( "GET /v1/seasons?workspaceId=ws+1&structureState=draft&limit=20&cursor=c%2F1", @@ -112,13 +169,54 @@ void mapsAll48SeasonOperationsAndReplayClasses() { "GET /v1/seasons/sea%2Fa/support-lookups?holderRef=holder+a%2Fb", "GET /v1/seasons/sea%2Fa/outbox", "POST /v1/seasons/sea%2Fa/outbox/occurrence%2F1/replay", - "GET /v1/seasons/sea%2Fa/audit", "GET /v1/seasons/sea%2Fa/export"); + "GET /v1/seasons/sea%2Fa/audit", "GET /v1/seasons/sea%2Fa/export", + "POST /v1/seasons/sea%2Fa/imports/from-season", + "GET /v1/seasons/sea%2Fa/performances", + "POST /v1/seasons/sea%2Fa/performances", + "POST /v1/seasons/sea%2Fa/performances/attach", + "DELETE /v1/seasons/sea%2Fa/performances/ev%2F1", + "PATCH /v1/seasons/sea%2Fa/plans/plan%2F1", + "DELETE /v1/seasons/sea%2Fa/plans/plan%2F2", + "POST /v1/seasons/sea%2Fa/publish", + "POST /v1/seasons/sea%2Fa/checkout-tests", + "GET /v1/seasons/sea%2Fa/checkout-tests", + "PUT /v1/seasons/sea%2Fa/plans/plan%2F1/prices", + "GET /v1/seasons/sea%2Fa/prices", + "POST /v1/seasons/sea%2Fa/plans/plan%2F1/performances", + "DELETE /v1/seasons/sea%2Fa/plans/plan%2F1/performances/ev%2F3", + "POST /v1/seasons/sea%2Fa/performances/ev%2F1/past", + "POST /v1/seasons/sea%2Fa/occurrences/ev%2F1/returns", + "POST /v1/seasons/sea%2Fa/occurrences/ev%2F1/reclaims", + "GET /v1/seasons/sea%2Fa/holders?q=ann+lee&status=active&cursor=c%2F2&limit=25", + "POST /v1/seasons/sea%2Fa/orders/ord%2F1/refund", + "POST /v1/seasons/sea%2Fa/orders/ord%2F1/cancel", + "POST /v1/seasons/sea%2Fa/orders/ord%2F1/dates/ev%2F2/cancel", + "POST /v1/seasons/sea%2Fa/orders/ord%2F1/resend", + "GET /v1/seasons/sea%2Fa/renewal-hosted", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/link", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/contact", + "POST /v1/seasons/sea%2Fa/renewal-notices", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/resend", + "GET /v1/seasons/sea%2Fa/reports/sales", + "GET /v1/seasons/sea%2Fa/reports/seats", + "GET /v1/seasons/sea%2Fa/reports/holders.csv?q=ann&status=active", + "GET /v1/seasons/sea%2Fa/reports/orders.csv", + "GET /v1/seasons/sea%2Fa/seat-counts", + "POST /v1/seasons/sea%2Fa/performances/ev%2F1/refunds", + "GET /v1/seasons/sea%2Fa/jobs", + "GET /v1/seasons/sea%2Fa/jobs/job%2F1", + "GET /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/quote", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/intent", + "POST /v1/seasons/sea%2Fa/renewal-offers/offer%2F1/checkout", + "POST /v1/seasons/sea%2Fa/renewal-offers/release-lapsed"); List actual = calls.stream() .map(call -> call.method() + " " + call.url().substring("https://api.seatlayer.io".length())) .toList(); assertEquals(expected, actual); - List replayIndexes = List.of(2, 4, 5, 10, 18, 27, 29, 38); + List replayIndexes = List.of( + 2, 4, 5, 10, 18, 27, 29, 38, + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, 85, 86); for (int index = 0; index < calls.size(); index++) { if (replayIndexes.contains(index)) { assertTrue(calls.get(index).headers().containsKey("Idempotency-Key")); @@ -126,5 +224,147 @@ void mapsAll48SeasonOperationsAndReplayClasses() { assertNull(calls.get(index).headers().get("Idempotency-Key")); } } + assertEquals("from-1", calls.get(48).headers().get("Idempotency-Key")); + assertEquals("plan-del-1", calls.get(54).headers().get("Idempotency-Key")); + assertEquals("cancel-date-1", calls.get(68).headers().get("Idempotency-Key")); + assertEquals("refund-date-1", calls.get(80).headers().get("Idempotency-Key")); + assertEquals("renew-1", calls.get(85).headers().get("Idempotency-Key")); + assertEquals("release-lapsed-1", calls.get(86).headers().get("Idempotency-Key")); + + // JSON bodies of the 39 operations added after the first 48, in call order; null means no body. + List addedBodies = Arrays.asList( + "{\"sourceSeasonId\":\"sea_2026\",\"successorPlanActivationId\":\"pa_1\"}", + null, + "{\"titlePattern\":\"Match\",\"timezone\":\"Europe/London\",\"repeat\":\"weekly\",\"count\":3}", + "{\"eventKeys\":[\"ev_1\"]}", + null, + "{\"name\":\"Gold\"}", + null, + null, + "{\"planKey\":\"plan/1\",\"quantity\":2}", + null, + "{\"prices\":[{\"category\":\"A\",\"amountMinor\":5000}]}", + null, + "{\"eventKey\":\"ev_3\"}", + null, + "{\"force\":true}", + "{\"actionId\":\"ret_1\",\"bookingRef\":\"order_1\",\"planActivationId\":\"pa_1\"," + + "\"labels\":[\"A-1\"],\"source\":{\"kind\":\"holder\"}}", + "{\"actionId\":\"rec_1\",\"returnActionId\":\"ret_1\",\"bookingRef\":\"order_1\"," + + "\"planActivationId\":\"pa_1\",\"labels\":[\"A-1\"],\"source\":{\"kind\":\"holder\"}}", + null, + "{\"amountMinor\":500}", + "{\"reason\":\"customer_request\"}", + null, + "{\"email\":\"fan@example.com\"}", + null, + null, + "{\"email\":\"fan@example.com\",\"name\":\"Ann Lee\"}", + null, + null, + null, + null, + null, + null, + null, + "{\"amount\":\"per_date\",\"reason\":\"Storm\"}", + null, + null, + null, + null, + "{\"returnUrl\":\"https://tickets.example/renewed\",\"buyerEmail\":\"ana@example.com\"," + + "\"buyerName\":\"Ana Silva\",\"provider\":\"stripe\"}", + null); + assertEquals(39, addedBodies.size()); + for (int offset = 0; offset < addedBodies.size(); offset++) { + Call call = calls.get(48 + offset); + String label = "call " + (48 + offset) + " " + expected.get(48 + offset); + if (addedBodies.get(offset) == null) { + assertNull(call.body(), label); + } else { + assertEquals(Json.read(addedBodies.get(offset)), Json.read(call.body()), label); + } + } + assertTrue(calls.get(77).headers().get("Accept").startsWith("text/csv")); + } + + @Test + void priceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWire() { + List calls = new ArrayList<>(); + SeatLayer sdk = SeatLayer.builder() + .secretKey("sk_test_abc") + .transport((method, url, headers, requestBody) -> { + calls.add(new Call(method, url, Map.copyOf(headers), requestBody)); + return new SeatLayerHttpClient.Response(200, "{}", Map.of()); + }) + .build(); + Seasons seasons = sdk.seasons(); + Map clearReturnCutoff = new LinkedHashMap<>(); + clearReturnCutoff.put("expectedRevision", 3); + clearReturnCutoff.put("checkoutMode", "seatlayer"); + clearReturnCutoff.put("transferCutoffHours", 24); + clearReturnCutoff.put("returnCutoffHours", null); + + seasons.replaceSeasonPackagePrices("sea_1", "plan_1", Map.of("prices", List.of( + Map.of("categoryKey", "stalls", "unitPrice", 18000), + Map.of("categoryKey", "stalls", "priceTypeKey", "child", "unitPrice", 9000))), null); + seasons.updateSeason("sea_1", clearReturnCutoff, null); + seasons.createSeasonHolderImport("sea_1", Map.of( + "successorPlanActivationId", "pa_1", + "rows", List.of(Map.of( + "rowId", "r1", "holderRef", "h1", "priorPlanActivationId", "pa_0", + "priorContractRef", "c1", "labels", List.of("A-1"), + "holderEmail", "ana@example.com", "holderName", "Ana Silva"))), null); + seasons.createSeasonAmendment("sea_1", Map.of( + "eventKey", "ev_night_3", "kind", "cancel", "refund", "per_date", "reason", "Storm"), + "night-3-storm"); + + List bodies = List.of( + "{\"prices\":[{\"categoryKey\":\"stalls\",\"unitPrice\":18000}," + + "{\"categoryKey\":\"stalls\",\"priceTypeKey\":\"child\",\"unitPrice\":9000}]}", + "{\"expectedRevision\":3,\"checkoutMode\":\"seatlayer\",\"transferCutoffHours\":24," + + "\"returnCutoffHours\":null}", + "{\"successorPlanActivationId\":\"pa_1\",\"rows\":[{\"rowId\":\"r1\",\"holderRef\":\"h1\"," + + "\"priorPlanActivationId\":\"pa_0\",\"priorContractRef\":\"c1\",\"labels\":[\"A-1\"]," + + "\"holderEmail\":\"ana@example.com\",\"holderName\":\"Ana Silva\"}]}", + "{\"eventKey\":\"ev_night_3\",\"kind\":\"cancel\",\"refund\":\"per_date\",\"reason\":\"Storm\"}"); + assertEquals(bodies.size(), calls.size()); + for (int index = 0; index < bodies.size(); index++) { + assertEquals(Json.read(bodies.get(index)), Json.read(calls.get(index).body()), "call " + index); + assertTrue(calls.get(index).headers().containsKey("Idempotency-Key"), "call " + index); + } + assertTrue(calls.get(1).body().contains("\"returnCutoffHours\":null")); + assertEquals("night-3-storm", calls.get(3).headers().get("Idempotency-Key")); + } + + @Test + void namesTheDateCancelVoidJobAndSizesAHolderImportCallByItsDates() { + assertEquals("sbj_void_amd_1", Seasons.dateCancelVoidJobId("amd_1")); + assertEquals( + List.of(100, 100, 95, 43), + List.of(1, 20, 21, 50).stream().map(Seasons::holderImportRowsPerCall).toList()); + + List calls = new ArrayList<>(); + SeatLayer sdk = SeatLayer.builder() + .secretKey("sk_test_abc") + .transport((method, url, headers, requestBody) -> { + calls.add(new Call(method, url, Map.copyOf(headers), requestBody)); + return new SeatLayerHttpClient.Response( + 422, + "{\"error\":\"invalid_rows\",\"code\":\"invalid_rows\"," + + "\"message\":\"At most 43 rows a call for this package.\",\"maxRows\":43}", + Map.of()); + }) + .build(); + + SeatLayerValidationException error = assertThrows( + SeatLayerValidationException.class, + () -> sdk.seasons().createSeasonHolderImport( + "sea_1", Map.of("successorPlanActivationId", "pa_1", "rows", List.of()), null)); + + assertEquals(422, error.status()); + assertEquals("invalid_rows", error.code()); + assertEquals(43L, error.body().get("maxRows")); + assertEquals(1, calls.size()); } }