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