From c9b9eb2de06422edaaac758f00809c41aa0797da Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:54:12 +0530 Subject: [PATCH 01/15] Transport: a raw text read for the CSV exports --- .../io/seatlayer/SeatLayerHttpClient.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) 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); } From 0f51b67da8bd450f2c76b7049da50fe485f19773 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:54:12 +0530 Subject: [PATCH 02/15] Season operations: the 32 missing season methods, plus holders, orders, event sales and event series --- src/main/java/io/seatlayer/EventSeries.java | 99 ++++ src/main/java/io/seatlayer/Events.java | 200 +++++++ src/main/java/io/seatlayer/Holders.java | 204 +++++++ src/main/java/io/seatlayer/Orders.java | 248 ++++++++ src/main/java/io/seatlayer/Seasons.java | 553 ++++++++++++++++++ src/main/java/io/seatlayer/SeatLayer.java | 33 ++ .../java/io/seatlayer/SeasonPlatformTest.java | 348 +++++++++++ src/test/java/io/seatlayer/SeasonTest.java | 138 ++++- 8 files changed, 1819 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/seatlayer/EventSeries.java create mode 100644 src/main/java/io/seatlayer/Holders.java create mode 100644 src/main/java/io/seatlayer/Orders.java create mode 100644 src/test/java/io/seatlayer/SeasonPlatformTest.java diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java new file mode 100644 index 0000000..0872d42 --- /dev/null +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -0,0 +1,99 @@ +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. + * + * @param params the request body: {@code name}, {@code chartKey} 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: either {@code schedule} or {@code dates} + * @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. + * + *

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..40d8650 100644 --- a/src/main/java/io/seatlayer/Events.java +++ b/src/main/java/io/seatlayer/Events.java @@ -193,4 +193,204 @@ 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. + * + *

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..10d35b1 --- /dev/null +++ b/src/main/java/io/seatlayer/Holders.java @@ -0,0 +1,204 @@ +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 and + * ticket transfers, 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. + * + *

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")); + } +} diff --git a/src/main/java/io/seatlayer/Orders.java b/src/main/java/io/seatlayer/Orders.java new file mode 100644 index 0000000..27c5c3e --- /dev/null +++ b/src/main/java/io/seatlayer/Orders.java @@ -0,0 +1,248 @@ +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); + } + + /** + * 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 http.get( + "/v1/orders", + body( + "eventKey", eventKey, + "seasonKey", seasonKey, + "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..4838e0b 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -282,4 +282,557 @@ 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. + * + *

Header replay: the same idempotency key is reused on every retry. + * + * @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 + */ + 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} + * @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. + * + * @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. + * + *

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 per offer. + * + * @param seasonKey the Season key + * @return the decoded response + */ + public Map retrieveSeasonHostedRenewals(String seasonKey) { + return http.get(path(seasonKey, "/renewal-hosted")); + } + + /** + * 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}), or {@code null} for every 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 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")); + } } 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/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java new file mode 100644 index 0000000..dfc9ed5 --- /dev/null +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -0,0 +1,348 @@ +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 mapsAll11HolderOperations() { + 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"); + + 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))); + assertEquals("holder-update-1", calls.get(3).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")); + + 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))); + 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"); + + 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))); + assertEquals("series-1", calls.get(0).headers().get("Idempotency-Key")); + assertEquals("series-update-1", calls.get(5).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 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")); + } +} diff --git a/src/test/java/io/seatlayer/SeasonTest.java b/src/test/java/io/seatlayer/SeasonTest.java index 7c9432d..545f570 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -15,7 +16,7 @@ private record Call(String method, String url, Map headers, Stri } @Test - void mapsAll48SeasonOperationsAndReplayClasses() { + void mapsAll80SeasonOperationsAndReplayClasses() { List calls = new ArrayList<>(); SeatLayer sdk = SeatLayer.builder() .secretKey("sk_test_abc") @@ -75,8 +76,52 @@ 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); - assertEquals(48, calls.size()); + assertEquals(80, 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 +157,47 @@ 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"); 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); for (int index = 0; index < calls.size(); index++) { if (replayIndexes.contains(index)) { assertTrue(calls.get(index).headers().containsKey("Idempotency-Key")); @@ -126,5 +205,56 @@ 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")); + + // JSON bodies of the 32 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); + assertEquals(32, 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")); } } From 4676aae200a22b5bfac993aa3767fae61a03c15e Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 19:54:12 +0530 Subject: [PATCH 03/15] Docs: README and changelog for the 71 new operations --- CHANGELOG.md | 17 ++++++++++++ README.md | 74 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8adf5e2..cee86ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +- Added 32 Season operations to `seasons()`, for 80 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, sales and + seat reports, seat counts, and holder and order CSV exports. +- 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 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). +- 22 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. + The other 49 are single-attempt and send no key. + ## 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..93df5b8 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,9 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -Version `0.7.0` exposes all 48 trusted organizer operations through -`seatlayer.seasons()`. +`seatlayer.seasons()` exposes 80 trusted organizer operations: the 48 from +version `0.7.0`, plus performances, package prices, checkout tests, holders, +Season orders, hosted renewals, reports, and CSV exports. After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal(seasonKey)` sends no evidence body; SeatLayer @@ -116,6 +117,37 @@ 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. +## 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 @@ -315,17 +347,30 @@ 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. Thirty-six +mutations use exact header replay: + +- `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, + `workspaces().create`, and `performanceGroups().create` +- 23 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, + `updateSeasonPlan`, `deleteSeasonPlan`, `publishSeason`, `duplicateSeasonToLive`, + `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, + `createSeasonAmendment`, `createSeasonPerformances`, `attachSeasonPerformances`, + `deleteSeasonPerformance`, `markSeasonPerformancePast`, `createSeasonCheckoutTest`, + `replaceSeasonPackagePrices`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, + `refundSeasonOrder`, `cancelSeasonOrder`, and `cancelSeasonOrderDate` +- `holders().updateHolder`, `holders().createHolderTransfer`, `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 +401,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` | | `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()` | 80 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holders and holder imports, Season orders, renewals and hosted renewals, occurrence returns and amendments, reports and CSV exports, audit, outbox, and support export | Full reference: [SeatLayer Java server SDK guide](https://docs.seatlayer.io/server-sdk/java/) From 00c833f11da5f1a6f5c418c16fe2b7ad0d892540 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 20:43:34 +0530 Subject: [PATCH 04/15] Holders: createHolderReturn (give back one seat on one date) --- CHANGELOG.md | 3 +- README.md | 8 ++--- src/main/java/io/seatlayer/Holders.java | 20 +++++++++-- .../java/io/seatlayer/SeasonPlatformTest.java | 33 +++++++++++++++++-- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cee86ae..0048d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ 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). -- 22 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. +- 23 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. The other 49 are single-attempt and send no key. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 93df5b8..fa396c0 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ 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. Thirty-six +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Thirty-seven mutations use exact header replay: - `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, @@ -359,8 +359,8 @@ mutations use exact header replay: `deleteSeasonPerformance`, `markSeasonPerformancePast`, `createSeasonCheckoutTest`, `replaceSeasonPackagePrices`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, `refundSeasonOrder`, `cancelSeasonOrder`, and `cancelSeasonOrderDate` -- `holders().updateHolder`, `holders().createHolderTransfer`, `orders().refundEventOrder`, and - `orders().cancelEventOrder` +- `holders().updateHolder`, `holders().createHolderTransfer`, `holders().createHolderReturn`, + `orders().refundEventOrder`, and `orders().cancelEventOrder` - `eventSeries().createEventSeries`, `eventSeries().addEventSeriesDates`, and `eventSeries().updateEventSeries` @@ -404,7 +404,7 @@ outbox, and support operations. | `events()` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` `retrieveEventBuyerCheckout` `replaceEventBuyerCheckout` `retrieveEventPaymentGateway` `replaceEventPaymentGateway` `retrieveEventSalesReadiness` `preflightEventSales` `transitionEventSales` `retrieveEventBookingFee` `replaceEventBookingFee` `listEventDiscountCodes` `createEventDiscountCode` `updateEventDiscountCode` `endEventDiscountCode` `deleteEventDiscountCode` | | `eventSeries()` | `createEventSeries` `listEventSeries` `retrieveEventSeries` `addEventSeriesDates` `updateEventSeries` | | `orders()` | `listOrders` `retrieveOrder` `listOrderTickets` `listEventOrders` `retrieveEventOrder` `exportEventOrdersCsv` `refundEventOrder` `cancelEventOrder` `resendEventOrderTickets` | -| `holders()` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` | +| `holders()` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` | | `channels()` | `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` | diff --git a/src/main/java/io/seatlayer/Holders.java b/src/main/java/io/seatlayer/Holders.java index 10d35b1..3966371 100644 --- a/src/main/java/io/seatlayer/Holders.java +++ b/src/main/java/io/seatlayer/Holders.java @@ -6,8 +6,8 @@ import java.util.Map; /** - * Season ticket holders: their customer record, sign-in sessions, seasons, tickets and - * ticket transfers, plus the customer list. + * Season ticket holders: their customer record, sign-in sessions, seasons, tickets, ticket + * transfers and returns, plus the customer list. * *

A holder is addressed by the holder reference its imports and bookings carry. */ @@ -201,4 +201,20 @@ public Map cancelHolderTransfer(String holderRef, String transfe 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. + * + *

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); + } } diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index dfc9ed5..352a3da 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -79,7 +79,7 @@ private void assertCalls(List expected) { } @Test - void mapsAll11HolderOperations() { + void mapsAll12HolderOperations() { SeatLayer sdk = client("{}"); Holders holders = sdk.holders(); String ref = "holder a/b"; @@ -99,6 +99,8 @@ void mapsAll11HolderOperations() { 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"); String holder = "/v1/holders/holder%20a%2Fb"; assertCalls(List.of( @@ -124,8 +126,12 @@ void mapsAll11HolderOperations() { "{\"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 + "/transfers/tr%2F1/accept", null, false), + new Expected( + "POST " + holder + "/returns", + "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true))); assertEquals("holder-update-1", calls.get(3).headers().get("Idempotency-Key")); + assertEquals("return-1", calls.get(15).headers().get("Idempotency-Key")); } @Test @@ -311,6 +317,29 @@ void headerReplayOperationsRetryWithOneKey() { calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); } + @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 unsupportedAndDomainExactOperationsAreSingleAttempt() { SeatLayer resend = client(List.of(rateLimited())); From 22f8154e5a2e188a3ac1149fbba17529c20d5702 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 20:45:08 +0530 Subject: [PATCH 05/15] Holders test: the return is the 15th call (index 14) --- src/test/java/io/seatlayer/SeasonPlatformTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 352a3da..5fc1285 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -131,7 +131,7 @@ void mapsAll12HolderOperations() { "POST " + holder + "/returns", "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true))); assertEquals("holder-update-1", calls.get(3).headers().get("Idempotency-Key")); - assertEquals("return-1", calls.get(15).headers().get("Idempotency-Key")); + assertEquals("return-1", calls.get(14).headers().get("Idempotency-Key")); } @Test From 4a05b0b0896c197075181e17a770c7dd942e70a4 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:09:33 +0530 Subject: [PATCH 06/15] Seasons: bulk refund and jobs, price type and sale setting docs with wire tests, events seriesKey --- CHANGELOG.md | 17 ++- README.md | 19 +-- .../java/io/seatlayer/EventListOptions.java | 44 ++++++- src/main/java/io/seatlayer/Events.java | 1 + src/main/java/io/seatlayer/Seasons.java | 110 +++++++++++++++++- src/test/java/io/seatlayer/ClientTest.java | 16 +++ .../java/io/seatlayer/SeasonPlatformTest.java | 18 +++ src/test/java/io/seatlayer/SeasonTest.java | 73 +++++++++++- 8 files changed, 275 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0048d24..1049135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Added 32 Season operations to `seasons()`, for 80 in all: performances, package prices, plan +- Added 35 Season operations to `seasons()`, for 83 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, sales and - seat reports, seat counts, and holder and order CSV exports. + seat reports, seat counts, holder and order CSV exports, and bulk refunds 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 @@ -15,8 +15,17 @@ 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). -- 23 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. - The other 49 are single-attempt and send no key. +- 24 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. + The other 51 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. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index fa396c0..a9af04b 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,12 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`seatlayer.seasons()` exposes 80 trusted organizer operations: the 48 from -version `0.7.0`, plus performances, package prices, checkout tests, holders, -Season orders, hosted renewals, reports, and CSV exports. +`seatlayer.seasons()` exposes 83 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, reports, CSV exports, and the +bulk refund of a cancelled date (`refundSeasonPerformance`) with its jobs +(`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). After the test hold/book/cancel journey and matching webhook deliveries, `validateSeasonBuyerRehearsal(seasonKey)` sends no evidence body; SeatLayer @@ -256,6 +259,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 @@ -347,18 +352,18 @@ 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. Thirty-seven +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Thirty-eight mutations use exact header replay: - `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, `workspaces().create`, and `performanceGroups().create` -- 23 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, +- 24 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, `updateSeasonPlan`, `deleteSeasonPlan`, `publishSeason`, `duplicateSeasonToLive`, `createSeasonHolderImport`, `createSeasonHolderImportFromSeason`, `createSeasonRenewalOffers`, `createSeasonAmendment`, `createSeasonPerformances`, `attachSeasonPerformances`, `deleteSeasonPerformance`, `markSeasonPerformancePast`, `createSeasonCheckoutTest`, `replaceSeasonPackagePrices`, `attachSeasonPlanPerformance`, `deleteSeasonPlanPerformance`, - `refundSeasonOrder`, `cancelSeasonOrder`, and `cancelSeasonOrderDate` + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, and `refundSeasonPerformance` - `holders().updateHolder`, `holders().createHolderTransfer`, `holders().createHolderReturn`, `orders().refundEventOrder`, and `orders().cancelEventOrder` - `eventSeries().createEventSeries`, `eventSeries().addEventSeriesDates`, and @@ -411,7 +416,7 @@ outbox, and support operations. | `webhooks()` | `list` `create` `update` `delete` `listDeliveries` | | `workspaces()` | `list` `create` `retrieve` `update` | | `performanceGroups()` | `list` `create` `retrieve` `delete` `activate` `close` `retrieveLifecycle` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` `retrieveHold` `bookHold` `retrieveBooking` | -| `seasons()` | 80 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holders and holder imports, Season orders, renewals and hosted renewals, occurrence returns and amendments, reports and CSV exports, audit, outbox, and support export | +| `seasons()` | 83 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holders and holder imports, Season orders, renewals and hosted renewals, occurrence returns and amendments, bulk refunds 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/Events.java b/src/main/java/io/seatlayer/Events.java index 40d8650..040bf02 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"); } diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 4838e0b..79907fa 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -50,6 +50,20 @@ 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. + * + * @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 + * until it starts) + * @param idempotencyKey a stable key, or {@code null} to generate one + * @return the decoded response + */ public Map updateSeason( String seasonKey, Map params, String idempotencyKey) { return http.mutationWithHeaderReplay("PATCH", path(seasonKey, ""), params, idempotencyKey); @@ -139,6 +153,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 +195,19 @@ public Map validateSeasonBuyerRehearsal(String seasonKey) { return http.post(path(seasonKey, "/buyer-rehearsals/validate")); } + /** + * Imports incumbent holders, as a dry run or for real. + * + *

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); @@ -193,6 +227,13 @@ public Map listSeasonRenewalOffers(String seasonKey) { return http.get(path(seasonKey, "/renewal-offers")); } + /** + * 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 +245,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")); } @@ -238,6 +286,20 @@ public Map listSeasonOccurrences(String seasonKey) { return http.get(path(seasonKey, "/occurrences")); } + /** + * Amends one date of a published Season. + * + *

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}, {@code per_date} or {@code full}) 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); @@ -447,7 +509,10 @@ public Map listSeasonCheckoutTests(String seasonKey) { * * @param seasonKey the Season key * @param planKey the package (Plan) key - * @param params the request body: {@code prices}, and optionally {@code currency} + * @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 */ @@ -458,7 +523,8 @@ public Map replaceSeasonPackagePrices( } /** - * Lists every package price table for the Season. + * 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 @@ -835,4 +901,44 @@ public String exportSeasonOrdersCsv(String seasonKey) { 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, or {@code full}, what is left of each order) 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); + } + + /** + * 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 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/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 index 5fc1285..4ba5df0 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -317,6 +317,24 @@ void headerReplayOperationsRetryWithOneKey() { 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", "full"), null); + + assertEquals(Json.read(job), result); + assertCalls(List.of( + new Expected("POST /v1/seasons/sea_1/performances/ev_1/refunds", "{\"amount\":\"full\"}", true), + new Expected("POST /v1/seasons/sea_1/performances/ev_1/refunds", "{\"amount\":\"full\"}", true))); + assertEquals( + calls.get(0).headers().get("Idempotency-Key"), calls.get(1).headers().get("Idempotency-Key")); + } + @Test void holderReturnRetriesWithOneKey() { String created = "{\"return\":{\"returnId\":\"ret_1\",\"state\":\"returned\"," diff --git a/src/test/java/io/seatlayer/SeasonTest.java b/src/test/java/io/seatlayer/SeasonTest.java index 545f570..22e40e5 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -6,6 +6,7 @@ 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; @@ -16,7 +17,7 @@ private record Call(String method, String url, Map headers, Stri } @Test - void mapsAll80SeasonOperationsAndReplayClasses() { + void mapsAll83SeasonOperationsAndReplayClasses() { List calls = new ArrayList<>(); SeatLayer sdk = SeatLayer.builder() .secretKey("sk_test_abc") @@ -118,8 +119,12 @@ void mapsAll80SeasonOperationsAndReplayClasses() { 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"); - assertEquals(80, calls.size()); + assertEquals(83, calls.size()); assertEquals("{}", holdersCsv); assertEquals("{}", ordersCsv); assertNull(calls.get(26).body()); @@ -189,7 +194,10 @@ void mapsAll80SeasonOperationsAndReplayClasses() { "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"); + "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"); List actual = calls.stream() .map(call -> call.method() + " " + call.url().substring("https://api.seatlayer.io".length())) .toList(); @@ -197,7 +205,7 @@ void mapsAll80SeasonOperationsAndReplayClasses() { 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); + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80); for (int index = 0; index < calls.size(); index++) { if (replayIndexes.contains(index)) { assertTrue(calls.get(index).headers().containsKey("Idempotency-Key")); @@ -208,8 +216,9 @@ void mapsAll80SeasonOperationsAndReplayClasses() { 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")); - // JSON bodies of the 32 operations added after the first 48, in call order; null means no body. + // JSON bodies of the 35 operations added after the first 48, in call order; null means no body. List addedBodies = Arrays.asList( "{\"sourceSeasonId\":\"sea_2026\",\"successorPlanActivationId\":\"pa_1\"}", null, @@ -244,8 +253,11 @@ void mapsAll80SeasonOperationsAndReplayClasses() { null, null, null, + null, + "{\"amount\":\"per_date\",\"reason\":\"Storm\"}", + null, null); - assertEquals(32, addedBodies.size()); + assertEquals(35, 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); @@ -257,4 +269,53 @@ void mapsAll80SeasonOperationsAndReplayClasses() { } 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")); + } } From c283bd9e4d3240ea85f3ccb61430feb772571310 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Thu, 5 Nov 2026 23:32:30 +0530 Subject: [PATCH 07/15] Seasons and holders: renewal by API (2.20) --- CHANGELOG.md | 16 ++-- README.md | 38 ++++++++-- src/main/java/io/seatlayer/Holders.java | 51 ++++++++++++- src/main/java/io/seatlayer/Seasons.java | 76 ++++++++++++++++++- .../java/io/seatlayer/SeasonPlatformTest.java | 76 ++++++++++++++++++- src/test/java/io/seatlayer/SeasonTest.java | 27 +++++-- 6 files changed, 260 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049135..9ed0569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Added 35 Season operations to `seasons()`, for 83 in all: performances, package prices, plan +- Added 38 Season operations to `seasons()`, for 86 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, sales and - seat reports, seat counts, holder and order CSV exports, and bulk refunds with their jobs. + 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 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 @@ -15,8 +15,8 @@ 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). -- 24 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. - The other 51 are single-attempt and send no key. +- 26 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 @@ -26,6 +26,12 @@ `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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index a9af04b..831ca17 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,11 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`seatlayer.seasons()` exposes 83 trusted organizer operations: the 48 from +`seatlayer.seasons()` exposes 86 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, reports, CSV exports, and the +tests, holders, Season orders, hosted renewals, renewal quotes, intents and +checkouts, reports, CSV exports, and the bulk refund of a cancelled date (`refundSeasonPerformance`) with its jobs (`listSeasonBulkJobs`, `retrieveSeasonBulkJob`). @@ -120,6 +121,25 @@ 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 @@ -352,20 +372,22 @@ 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. Thirty-eight +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Forty mutations use exact header replay: - `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, `workspaces().create`, and `performanceGroups().create` -- 24 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, +- 25 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`, and `refundSeasonPerformance` + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, `refundSeasonPerformance`, and + `createSeasonRenewalCheckout` - `holders().updateHolder`, `holders().createHolderTransfer`, `holders().createHolderReturn`, - `orders().refundEventOrder`, and `orders().cancelEventOrder` + `holders().createHolderRenewalCheckout`, `orders().refundEventOrder`, and + `orders().cancelEventOrder` - `eventSeries().createEventSeries`, `eventSeries().addEventSeriesDates`, and `eventSeries().updateEventSeries` @@ -409,14 +431,14 @@ outbox, and support operations. | `events()` | `list` `listAll` `create` `retrieve` `retrieveConfigurationBinding` `updateConfigurationBinding` `update` `delete` `updateChart` `close` `reopen` `archive` `retrieveHoldTtl` `updateHoldTtl` `listTicketReleases` `updateTicketReleases` `closeTicketRelease` `retrieveReport` `retrieveLog` `retrieveEventBuyerCheckout` `replaceEventBuyerCheckout` `retrieveEventPaymentGateway` `replaceEventPaymentGateway` `retrieveEventSalesReadiness` `preflightEventSales` `transitionEventSales` `retrieveEventBookingFee` `replaceEventBookingFee` `listEventDiscountCodes` `createEventDiscountCode` `updateEventDiscountCode` `endEventDiscountCode` `deleteEventDiscountCode` | | `eventSeries()` | `createEventSeries` `listEventSeries` `retrieveEventSeries` `addEventSeriesDates` `updateEventSeries` | | `orders()` | `listOrders` `retrieveOrder` `listOrderTickets` `listEventOrders` `retrieveEventOrder` `exportEventOrdersCsv` `refundEventOrder` `cancelEventOrder` `resendEventOrderTickets` | -| `holders()` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` | +| `holders()` | `listCustomers` `retrieveHolder` `updateHolder` `revokeHolderSessions` `listHolderSeasons` `listHolderTickets` `retrieveHolderTicket` `createHolderTransfer` `listHolderTransfers` `cancelHolderTransfer` `acceptHolderTransfer` `createHolderReturn` `createHolderRenewalCheckout` `declineHolderRenewalOffer` | | `channels()` | `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()` | 83 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, holders and holder imports, Season orders, renewals and hosted renewals, occurrence returns and amendments, bulk refunds and jobs, reports and CSV exports, audit, outbox, and support export | +| `seasons()` | 86 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 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/Holders.java b/src/main/java/io/seatlayer/Holders.java index 3966371..379c5e6 100644 --- a/src/main/java/io/seatlayer/Holders.java +++ b/src/main/java/io/seatlayer/Holders.java @@ -7,7 +7,7 @@ /** * Season ticket holders: their customer record, sign-in sessions, seasons, tickets, ticket - * transfers and returns, plus the customer list. + * transfers, returns and renewals, plus the customer list. * *

A holder is addressed by the holder reference its imports and bookings carry. */ @@ -217,4 +217,53 @@ 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/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 79907fa..5be7b15 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -272,9 +272,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) { @@ -840,6 +866,54 @@ public Map resendSeasonRenewalOffer(String seasonKey, String off 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. * diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 4ba5df0..72a95ed 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -79,7 +79,7 @@ private void assertCalls(List expected) { } @Test - void mapsAll12HolderOperations() { + void mapsAll14HolderOperations() { SeatLayer sdk = client("{}"); Holders holders = sdk.holders(); String ref = "holder a/b"; @@ -101,6 +101,11 @@ void mapsAll12HolderOperations() { 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( @@ -129,9 +134,19 @@ void mapsAll12HolderOperations() { new Expected("POST " + holder + "/transfers/tr%2F1/accept", null, false), new Expected( "POST " + holder + "/returns", - "{\"ticketIds\":[\"tkt_0123456789abcdef01234567\"]}", true))); + "{\"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 @@ -358,6 +373,46 @@ void holderReturnRetriesWithOneKey() { 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())); @@ -391,5 +446,22 @@ void unsupportedAndDomainExactOperationsAreSingleAttempt() { () -> 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 22e40e5..c92703b 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -17,7 +17,7 @@ private record Call(String method, String url, Map headers, Stri } @Test - void mapsAll83SeasonOperationsAndReplayClasses() { + void mapsAll86SeasonOperationsAndReplayClasses() { List calls = new ArrayList<>(); SeatLayer sdk = SeatLayer.builder() .secretKey("sk_test_abc") @@ -123,8 +123,13 @@ void mapsAll83SeasonOperationsAndReplayClasses() { 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"); - assertEquals(83, calls.size()); + assertEquals(86, calls.size()); assertEquals("{}", holdersCsv); assertEquals("{}", ordersCsv); assertNull(calls.get(26).body()); @@ -197,7 +202,10 @@ void mapsAll83SeasonOperationsAndReplayClasses() { "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/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"); List actual = calls.stream() .map(call -> call.method() + " " + call.url().substring("https://api.seatlayer.io".length())) .toList(); @@ -205,7 +213,7 @@ void mapsAll83SeasonOperationsAndReplayClasses() { 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); + 48, 50, 51, 52, 53, 54, 55, 56, 58, 60, 61, 62, 66, 67, 68, 80, 85); for (int index = 0; index < calls.size(); index++) { if (replayIndexes.contains(index)) { assertTrue(calls.get(index).headers().containsKey("Idempotency-Key")); @@ -217,8 +225,9 @@ void mapsAll83SeasonOperationsAndReplayClasses() { 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")); - // JSON bodies of the 35 operations added after the first 48, in call order; null means no body. + // JSON bodies of the 38 operations added after the first 48, in call order; null means no body. List addedBodies = Arrays.asList( "{\"sourceSeasonId\":\"sea_2026\",\"successorPlanActivationId\":\"pa_1\"}", null, @@ -256,8 +265,12 @@ void mapsAll83SeasonOperationsAndReplayClasses() { null, "{\"amount\":\"per_date\",\"reason\":\"Storm\"}", null, - null); - assertEquals(35, addedBodies.size()); + null, + null, + null, + "{\"returnUrl\":\"https://tickets.example/renewed\",\"buyerEmail\":\"ana@example.com\"," + + "\"buyerName\":\"Ana Silva\",\"provider\":\"stripe\"}"); + assertEquals(38, 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); From e5cf44e2c3460ec66b9349795f92ac0c47717b7b Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 01:21:28 +0530 Subject: [PATCH 08/15] Seasons, series and orders: contract changes from the 2.23 gaps and the p2a-api review --- CHANGELOG.md | 6 ++++ src/main/java/io/seatlayer/EventSeries.java | 5 ++- src/main/java/io/seatlayer/Orders.java | 31 ++++++++++++++++++- src/main/java/io/seatlayer/Seasons.java | 21 ++++++++++--- .../java/io/seatlayer/SeasonPlatformTest.java | 14 ++++++--- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed0569..f78e158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ - Added `holders().createHolderRenewalCheckout`: the same checkout for one of a holder's offers, with header replay. - Added `holders().declineHolderRenewalOffer`: decline one of a holder's offers, with an optional `reason`. - 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`. ## 0.8.0 (2026-09-10) diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java index 0872d42..bd48ca4 100644 --- a/src/main/java/io/seatlayer/EventSeries.java +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -73,7 +73,10 @@ public Map retrieveEventSeries(String eventKey) { *

Header replay: the same idempotency key is reused on every retry. * * @param eventKey the series' event key - * @param params the request body: either {@code schedule} or {@code dates} + * @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 */ diff --git a/src/main/java/io/seatlayer/Orders.java b/src/main/java/io/seatlayer/Orders.java index 27c5c3e..8dd2fad 100644 --- a/src/main/java/io/seatlayer/Orders.java +++ b/src/main/java/io/seatlayer/Orders.java @@ -25,7 +25,7 @@ public final class Orders { * @return the decoded response */ public Map listOrders() { - return listOrders(null, null, null, null, null, null, null); + return listOrders(null, null, null, null, null, null, null, null); } /** @@ -48,11 +48,40 @@ public Map listOrders( 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, diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 5be7b15..918a526 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -60,7 +60,8 @@ public Map retrieveSeason(String seasonKey) { * {@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 - * until it starts) + * until it starts), and {@code audience} ({@code embed_only} or {@code public}; an ended + * Season cannot become public, {@code 409 sales_ended}) * @param idempotencyKey a stable key, or {@code null} to generate one * @return the decoded response */ @@ -117,6 +118,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); } @@ -321,8 +332,9 @@ public Map listSeasonOccurrences(String seasonKey) { * @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}, {@code per_date} or {@code full}) and - * {@code reason}; a refunding cancel answers with its {@code refundJob} + * {@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 */ @@ -985,7 +997,8 @@ public Map retrieveSeasonSeatCounts(String seasonKey) { * @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, or {@code full}, what is left of each order) and optionally {@code reason} + * 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} */ diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 72a95ed..5594c9b 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -168,6 +168,7 @@ void mapsAll9OrderOperations() { "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( @@ -193,7 +194,8 @@ void mapsAll9OrderOperations() { "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("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")); } @@ -266,6 +268,7 @@ void mapsAll5EventSeriesOperations() { 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( @@ -278,7 +281,8 @@ void mapsAll5EventSeriesOperations() { 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("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")); } @@ -340,12 +344,12 @@ void bulkRefundOfADateRetriesWithOneKey() { new SeatLayerHttpClient.Response(202, job, Map.of()))); Map result = sdk.seasons().refundSeasonPerformance( - "sea_1", "ev_1", Map.of("amount", "full"), null); + "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\":\"full\"}", true), - new Expected("POST /v1/seasons/sea_1/performances/ev_1/refunds", "{\"amount\":\"full\"}", true))); + 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")); } From cf0f775027eef113467256b2e85a56fdfd94561e Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 04:04:21 +0530 Subject: [PATCH 09/15] Event series: create from an event, and every date keeps the plain title (2.16c) createEventSeries takes fromEventKey: an event already set up becomes the first date under its own key and every other date is a copy of it; name and chartKey are then left out. Every date of a series carries the series' plain name. Matches seatmap slice 2.16c. --- CHANGELOG.md | 2 ++ src/main/java/io/seatlayer/EventSeries.java | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78e158..4328071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ - `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}`. ## 0.8.0 (2026-09-10) diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java index bd48ca4..818d470 100644 --- a/src/main/java/io/seatlayer/EventSeries.java +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -28,8 +28,14 @@ private static String path(String eventKey, String suffix) { *

Header replay: the same idempotency key is reused on every retry. The API answers * 202 when the dates are made as a job. * - * @param params the request body: {@code name}, {@code chartKey} and {@code schedule}, - * plus any optional fields such as {@code dryRun} + *

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 */ From f9dcb49628efb146a4cf46a84b35cd97810c39a2 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:02:32 +0530 Subject: [PATCH 10/15] Seasons: renewal at scale contract changes (2.8) Adds releaseSeasonLapsedOffers (POST /v1/seasons/{key}/renewal-offers/release-lapsed, no body, header replay, 202 with the job). retrieveSeasonHostedRenewals pages with cursor and limit (1-500) and answers nextCursor, and listSeasonRenewalOffers pages the same way (1-100). createSeasonRenewalOffers answers 201 {offers} for 100 contracts or fewer and 202 {job, batchId} above that. The bring-across (createSeasonHolderImportFromSeason) answers 202 with a holder_import job above 100 holders. Renewal emails take up to 20,000 offer ids; above that, 422 too_many_offer_ids. --- CHANGELOG.md | 11 ++- README.md | 15 ++-- src/main/java/io/seatlayer/Seasons.java | 90 +++++++++++++++++-- .../java/io/seatlayer/SeasonPlatformTest.java | 68 ++++++++++++++ src/test/java/io/seatlayer/SeasonTest.java | 18 ++-- 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4328071..aff4b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- Added 38 Season operations to `seasons()`, for 86 in all: performances, package prices, plan +- 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 with their jobs. + 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 @@ -15,7 +15,7 @@ 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). -- 26 of the new operations use header replay, with one `Idempotency-Key` reused on every retry. +- 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`. @@ -40,6 +40,11 @@ - Season holds, give-backs and date cancels can refuse with `performance_cancelled`, `refund_not_started` (503), `missing_manage_capability` (403) or `invalid_refund`; a holder's status can be `reserved`; hosted renewal readiness carries `missingPrices`. - `eventSeries().createEventSeries` takes `fromEventKey` 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`. ## 0.8.0 (2026-09-10) diff --git a/README.md b/README.md index 831ca17..e2d466f 100644 --- a/README.md +++ b/README.md @@ -93,12 +93,13 @@ The hint is best effort, not a data-residency guarantee. See the ## Fixed Renewable Seasons -`seatlayer.seasons()` exposes 86 trusted organizer operations: the 48 from +`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`) with its jobs +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, @@ -372,19 +373,19 @@ 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. Forty +exponential backoff and full jitter; `Retry-After` wins when the server sends it. Forty-one mutations use exact header replay: - `charts().create`, `charts().copy`, `templates().instantiateTemplate`, `events().create`, `workspaces().create`, and `performanceGroups().create` -- 25 on `seasons()`: `createSeason`, `updateSeason`, `deleteSeason`, `createSeasonPlan`, +- 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`, and - `createSeasonRenewalCheckout` + `refundSeasonOrder`, `cancelSeasonOrder`, `cancelSeasonOrderDate`, `refundSeasonPerformance`, + `releaseSeasonLapsedOffers`, and `createSeasonRenewalCheckout` - `holders().updateHolder`, `holders().createHolderTransfer`, `holders().createHolderReturn`, `holders().createHolderRenewalCheckout`, `orders().refundEventOrder`, and `orders().cancelEventOrder` @@ -438,7 +439,7 @@ outbox, and support operations. | `webhooks()` | `list` `create` `update` `delete` `listDeliveries` | | `workspaces()` | `list` `create` `retrieve` `update` | | `performanceGroups()` | `list` `create` `retrieve` `delete` `activate` `close` `retrieveLifecycle` `createBuyerAccessSession` `listBuyerAccessSessions` `revokeBuyerAccessSession` `retrieveHold` `bookHold` `retrieveBooking` | -| `seasons()` | 86 operations for catalogue and Plan lifecycle, performances, 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 and jobs, reports and CSV exports, audit, outbox, and support export | +| `seasons()` | 87 operations for catalogue and Plan lifecycle, performances, package prices, sales windows, checkout tests, buyer access and booking, 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/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 918a526..549e342 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -228,14 +228,50 @@ 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)); } /** @@ -386,13 +422,20 @@ public Map exportSeasonSupportSnapshot(String seasonKey) { /** * Dry-runs or commits renewal rights derived from a previous Season's holders. * - *

Header replay: the same idempotency key is reused on every retry. + *

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 + * @return the decoded response: the {@code import}, or the {@code job} */ public Map createSeasonHolderImportFromSeason( String seasonKey, Map params, String idempotencyKey) { @@ -803,13 +846,27 @@ public Map resendSeasonOrderTickets( } /** - * Retrieves hosted renewal links, payments and emails per offer. + * Retrieves hosted renewal links, payments and emails for the first page of offers. * * @param seasonKey the Season key - * @return the decoded response + * @return the decoded response, with the {@code offers} and {@code nextCursor} */ public Map retrieveSeasonHostedRenewals(String seasonKey) { - return http.get(path(seasonKey, "/renewal-hosted")); + 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)); } /** @@ -857,7 +914,8 @@ public Map createSeasonRenewalNotices(String seasonKey) { *

Single attempt; the server does not send the same notice twice. * * @param seasonKey the Season key - * @param params the optional request body ({@code offerIds}), or {@code null} for every offer + * @param params the optional request body ({@code offerIds}, at most 20,000; more is refused + * with {@code 422 too_many_offer_ids}), or {@code null} for every open offer * @return the decoded response */ public Map createSeasonRenewalNotices( @@ -1008,6 +1066,21 @@ public Map refundSeasonPerformance( 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). * @@ -1019,7 +1092,8 @@ public Map listSeasonBulkJobs(String seasonKey) { } /** - * Retrieves one bulk job with its counts and up to 500 failed items. + * 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 diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 5594c9b..870bf3f 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -354,6 +354,74 @@ void bulkRefundOfADateRetriesWithOneKey() { 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 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\"," diff --git a/src/test/java/io/seatlayer/SeasonTest.java b/src/test/java/io/seatlayer/SeasonTest.java index c92703b..d93ca52 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -17,7 +17,7 @@ private record Call(String method, String url, Map headers, Stri } @Test - void mapsAll86SeasonOperationsAndReplayClasses() { + void mapsAll87SeasonOperationsAndReplayClasses() { List calls = new ArrayList<>(); SeatLayer sdk = SeatLayer.builder() .secretKey("sk_test_abc") @@ -128,8 +128,9 @@ void mapsAll86SeasonOperationsAndReplayClasses() { 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(86, calls.size()); + assertEquals(87, calls.size()); assertEquals("{}", holdersCsv); assertEquals("{}", ordersCsv); assertNull(calls.get(26).body()); @@ -205,7 +206,8 @@ void mapsAll86SeasonOperationsAndReplayClasses() { "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/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(); @@ -213,7 +215,7 @@ void mapsAll86SeasonOperationsAndReplayClasses() { 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); + 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")); @@ -226,8 +228,9 @@ void mapsAll86SeasonOperationsAndReplayClasses() { 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 38 operations added after the first 48, in call order; null means no body. + // 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, @@ -269,8 +272,9 @@ void mapsAll86SeasonOperationsAndReplayClasses() { null, null, "{\"returnUrl\":\"https://tickets.example/renewed\",\"buyerEmail\":\"ana@example.com\"," - + "\"buyerName\":\"Ana Silva\",\"provider\":\"stripe\"}"); - assertEquals(38, addedBodies.size()); + + "\"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); From 2ae8a6e7b16f24e86a27d6e3faa319ca124821c4 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 05:48:05 +0530 Subject: [PATCH 11/15] Seasons: renewal notices for one offer batch (2.8b) --- CHANGELOG.md | 1 + src/main/java/io/seatlayer/Seasons.java | 3 ++- src/test/java/io/seatlayer/SeasonPlatformTest.java | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff4b14..ee9be01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - `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. ## 0.8.0 (2026-09-10) diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 549e342..3433fb2 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -915,7 +915,8 @@ public Map createSeasonRenewalNotices(String seasonKey) { * * @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 {@code null} for every open offer + * 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( diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 870bf3f..8607a59 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -396,6 +396,18 @@ void renewalOfferPagesSendCursorAndLimit() { new Expected("GET /v1/seasons/sea_1/renewal-offers", null, false))); } + @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\"}," From cefe4ca9e1d786510684a4aa59ad0374c7e5e3de Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 07:11:14 +0530 Subject: [PATCH 12/15] Seasons: single tickets on a season (2.15c) --- CHANGELOG.md | 1 + src/main/java/io/seatlayer/Seasons.java | 21 ++++++++++++++++--- .../java/io/seatlayer/SeasonPlatformTest.java | 19 +++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9be01..928f7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - `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) diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 3433fb2..5d06ae8 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -42,6 +42,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); } @@ -60,10 +72,13 @@ public Map retrieveSeason(String seasonKey) { * {@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 - * until it starts), and {@code audience} ({@code embed_only} or {@code public}; an ended - * Season cannot become public, {@code 409 sales_ended}) + * until it starts), {@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 + * @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) { diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 8607a59..319aac2 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -396,6 +396,25 @@ void renewalOfferPagesSendCursorAndLimit() { 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}"); From 298ebc2e7424665e6e0a9e259801c34c8abd74c3 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 12:34:09 +0530 Subject: [PATCH 13/15] Seasons: date cancel void job, 2-50 dates, singles job refusal, create_abandoned (p2b) The public API changes in p2b (b88ed794e..ee7df2921): Seasons.dateCancelVoidJobId names the date_cancel_void job a date cancel starts (season.ticket.voided reason date_cancelled); Seasons.holderImportRowsPerCall sizes a createSeasonHolderImport call by its dates, 2 to 50 (a call over it answers 422 invalid_rows with maxRows); updateSeason's singleTicketDates can carry jobRefused, and an exact retry is not applied twice; createEventSeries documents 409 create_abandoned. --- CHANGELOG.md | 12 +++++ src/main/java/io/seatlayer/EventSeries.java | 4 +- src/main/java/io/seatlayer/Seasons.java | 49 ++++++++++++++++++- .../java/io/seatlayer/SeasonPlatformTest.java | 27 ++++++++++ src/test/java/io/seatlayer/SeasonTest.java | 32 ++++++++++++ 5 files changed, 122 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928f7ce..fcca2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- 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 diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java index 818d470..4017206 100644 --- a/src/main/java/io/seatlayer/EventSeries.java +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -26,7 +26,9 @@ private static String path(String eventKey, String 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. + * 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 diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 5d06ae8..cb4eee0 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"); } @@ -65,7 +97,12 @@ public Map retrieveSeason(String seasonKey) { /** * Renames a draft Season, or sets how it sells. * - *

Header replay: the same idempotency key is reused on every retry. + *

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}, @@ -224,6 +261,11 @@ public Map validateSeasonBuyerRehearsal(String seasonKey) { /** * 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 @@ -377,6 +419,11 @@ public Map listSeasonOccurrences(String seasonKey) { /** * 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}. + * *

Header replay: the same idempotency key is reused on every retry. * * @param seasonKey the Season key diff --git a/src/test/java/io/seatlayer/SeasonPlatformTest.java b/src/test/java/io/seatlayer/SeasonPlatformTest.java index 319aac2..4ddffce 100644 --- a/src/test/java/io/seatlayer/SeasonPlatformTest.java +++ b/src/test/java/io/seatlayer/SeasonPlatformTest.java @@ -287,6 +287,33 @@ void mapsAll5EventSeriesOperations() { 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. diff --git a/src/test/java/io/seatlayer/SeasonTest.java b/src/test/java/io/seatlayer/SeasonTest.java index d93ca52..3daaf3c 100644 --- a/src/test/java/io/seatlayer/SeasonTest.java +++ b/src/test/java/io/seatlayer/SeasonTest.java @@ -2,6 +2,7 @@ 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; @@ -335,4 +336,35 @@ void priceTypesSaleSettingsHolderContactsAndDateCancelsReachTheWire() { 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()); + } } From b3afff58d32d524b7f7de2ab5b279fc639ef0e7e Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 18:09:29 +0530 Subject: [PATCH 14/15] Docs: partial series prices, club switches, club-default cut-offs (p2b audit) updateEventSeries sends only the prices that change: each date keeps its own price for every category or tier left out. A season's null transfer or return cut-off means the club default from Settings > Ticket site, or until the date starts when the club has none. Holder transfers and returns through the secret key are not stopped by the club's customer switches; cut-offs still apply. --- CHANGELOG.md | 6 ++++++ src/main/java/io/seatlayer/EventSeries.java | 3 +++ src/main/java/io/seatlayer/Holders.java | 6 ++++-- src/main/java/io/seatlayer/Seasons.java | 3 ++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcca2b8..6819c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- `updateEventSeries` sends only the prices that change: each date keeps its own price for every + category (or tier) left out, so weekend and weekday prices keep their difference. +- 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 diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java index 4017206..171df87 100644 --- a/src/main/java/io/seatlayer/EventSeries.java +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -96,6 +96,9 @@ public Map addEventSeriesDates( /** * 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. + * *

Header replay: the same idempotency key is reused on every retry. * * @param eventKey the series' event key diff --git a/src/main/java/io/seatlayer/Holders.java b/src/main/java/io/seatlayer/Holders.java index 379c5e6..4d743c1 100644 --- a/src/main/java/io/seatlayer/Holders.java +++ b/src/main/java/io/seatlayer/Holders.java @@ -152,7 +152,8 @@ public Map retrieveHolderTicket(String holderRef, String ticketI } /** - * Sends some of a holder's season tickets to an email. + * 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. * @@ -203,7 +204,8 @@ public Map acceptHolderTransfer(String holderRef, String transfe } /** - * Gives back a holder's seat on one date, one per ticket. + * 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. diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index cb4eee0..0702bfb 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -109,7 +109,8 @@ public Map retrieveSeason(String seasonKey) { * {@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 - * until it starts), {@code audience} ({@code embed_only} or {@code public}; an ended + * 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) From 6b2689c5eb28d3d00209dbace671461824cd8588 Mon Sep 17 00:00:00 2001 From: Navin Sharma Date: Fri, 6 Nov 2026 19:58:24 +0530 Subject: [PATCH 15/15] Docs: a cancelled season date stays closed and can't be amended (p2b part C) A date its season cancelled never opens again: moving its sales to testable or live, or reopening it, answers 409 season_date_cancelled, and an event series "sales on" leaves it closed as a failed item of the job (season_date_cancelled; not in failedDates). A cancelled date can't be moved, postponed, replaced or marked as taken place: 409 performance_cancelled. --- CHANGELOG.md | 6 ++++++ src/main/java/io/seatlayer/EventSeries.java | 4 ++++ src/main/java/io/seatlayer/Events.java | 11 ++++++++++- src/main/java/io/seatlayer/Seasons.java | 6 ++++-- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6819c5f..73eb8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- A date its season cancelled never opens again: `transitionEventSales` to `testable` or `live` + and `reopen` answer `409 season_date_cancelled`, and `updateEventSeries` with `sales: "on"` + leaves it closed as a failed item of the job (`season_date_cancelled`; not in `failedDates`). +- `createSeasonAmendment` refuses to move (`reschedule`), postpone or replace a cancelled date, + and `markSeasonPerformancePast` refuses to mark one as taken place: + `409 performance_cancelled`. - `updateEventSeries` sends only the prices that change: each date keeps its own price for every category (or tier) left out, so weekend and weekday prices keep their difference. - A season's `transferCutoffHours` / `returnCutoffHours` of `null` means the club default diff --git a/src/main/java/io/seatlayer/EventSeries.java b/src/main/java/io/seatlayer/EventSeries.java index 171df87..a4dc896 100644 --- a/src/main/java/io/seatlayer/EventSeries.java +++ b/src/main/java/io/seatlayer/EventSeries.java @@ -99,6 +99,10 @@ public Map addEventSeriesDates( *

{@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 diff --git a/src/main/java/io/seatlayer/Events.java b/src/main/java/io/seatlayer/Events.java index 040bf02..45c40dd 100644 --- a/src/main/java/io/seatlayer/Events.java +++ b/src/main/java/io/seatlayer/Events.java @@ -138,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"); } @@ -292,7 +299,9 @@ public Map preflightEventSales(String eventKey, MapSingle attempt; the {@code expectedRevision} compare-and-set makes a repeat safe to * reconcile. diff --git a/src/main/java/io/seatlayer/Seasons.java b/src/main/java/io/seatlayer/Seasons.java index 0702bfb..53d107a 100644 --- a/src/main/java/io/seatlayer/Seasons.java +++ b/src/main/java/io/seatlayer/Seasons.java @@ -423,7 +423,8 @@ public Map listSeasonOccurrences(String seasonKey) { *

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}. + * {@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. * @@ -715,7 +716,8 @@ public Map deleteSeasonPlanPerformance( } /** - * Marks a performance past so it stops blocking new Season sales. + * 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. *