Skip to content

Example: ticketing

Let’s build the path from choosing seats to entering a concert. We will add a programme, reserve a group, expire unpaid holds, accept payments and issue tickets. Then we will add delivery, admission, transfers, refunds and the organizer’s tools.

View on GitHub

Ticketing app with the After Hours event and upcoming performances.

The completed Ticketing app, showing its demo programme.

This continues Building your first app. The new challenge is coordinating several independent lifecycles: a reservation can expire while a payment is still in progress, and a ticket can change owner without changing the original purchase.

Use the current example as your working project:

Terminal window
git clone https://github.com/fluxzero-io/fluxzero-sample-ticketing.git
cd fluxzero-sample-ticketing
fz dev

Rebuild the classes and methods below in their existing files. The checkout supplies the completed solution, value types, session setup and React screens; source links show the full files around each excerpt. The Kotlin tabs use the example’s Java model and value types, so you can concentrate on each new behavior.

First try Night Lights: choose two places in the Recital Hall and reserve them. Sign in as demo-organizer to explore Operations and Entrance. The local profile can complete a box-office sale with a demo cash receipt; online Stripe checkout is a separate integration step later in this tutorial.

Start with places and performances. A Venue owns Halls. Each hall has versioned SeatingPlans containing sections and seats. An Event describes the show; a Performance puts it at a particular time in a particular plan.

Implement Performance with two parent relationships:

@Model(persistence = {ModelPersistence.EVENT_SOURCED,
ModelPersistence.DOCUMENT})
@With
public record Performance(@EntityId PerformanceId performanceId,
@Parent(pathInParent = "performances") EventId eventId,
@Parent(pathInParent = "performances") SeatingPlanId seatingPlanId,
PerformanceDetails details, Cancellation cancellation) {
public enum Cancellation { NONE, SETTLING, SETTLED }
public boolean cancelled() { return cancellation != Cancellation.NONE; }
}

The same performance is reachable through its show and its seating plan. It still has one identity and history. Use the existing CreateVenue, CreateHall, RegisterSeatingPlan and CreateEvent commands to establish those parents, then implement SchedulePerformance. Its application method creates the performance; its rule requires a future start and a price for every section in the selected plan.

Keep the seating plan immutable once registered. Seats, adjacency and wheelchair/companion links belong to that layout. Prices and available ticket types belong to the performance. This lets two dates use the same hall at different prices without changing either date’s layout.

Implement GetProgramme around a paged search for Performances. Search through the Event parent for a title and through the Venue ancestor for a city. Start with these lines from its query handler:

var search = Fluxzero.search(Performance.class);
if (term != null && !term.isBlank()) {
search = search.whereParent(Event.class, lookAhead(term, "details/title"));
}
if (city != null && !city.isBlank()) {
search = search.whereAncestor(Venue.class, match(city, true, "details/city"));
}
var items = search.sortBy("details/startsAt").sortBy("performanceId")
.skip(offset).fetch(limit + 1);

Return at most limit results and use the extra result to determine hasMore. Complete the result mapping with the show’s title, venue and layout summary. The seat map itself is loaded when the customer opens a performance.

Check: open frontend/src/Discover.jsx, connect its filters to the catalogue endpoint and search by title and city. The programme should contain performances, not one result per reservation or seat. DemoCatalog supplies the small test venues and the demo programme.

Add SeatInventory for each numbered seat and SectionInventory for standing capacity. Both belong to a performance. They represent occupancy; the seating plan remains the layout.

In SeatInventory, the central decision is small:

public boolean occupiedAt(Instant now) {
return productionHoldId != null || reservationId != null && (sold || now.isBefore(expiresAt));
}

A seat is occupied by a production block, a completed sale or a hold whose deadline is still in the future. GetAvailability turns that into remaining quantities per section; GetSeats supplies a bounded portion of the numbered layout.

Implement GetSeatSuggestions by following the plan’s nextSeatId links for adjacent seats. Keep suggestions as queries: a suggestion helps the customer choose but does not reserve anything. ReservationRules checks the submitted group, including wheelchair/companion relationships and ticket-type eligibility, when the customer actually reserves.

Wire frontend/src/SeatMap.jsx to those queries. Select two adjacent places, then select a discounted ticket type for one visitor. Both ticket types still occupy the same physical stock. The reservation will freeze each price so later financial operations can use the price actually paid.

A reservation owns the buyer’s selection, frozen admissions and prices, creation time, deadline and status. Implement ReserveTickets as an @InterceptApply decision. After checking the sales window and selection, it returns the reservation and all required inventory changes together.

These are the final lines of its decide method, after it has constructed reservation:

var changes = new ArrayList<Object>();
changes.add(new ReservationHeld(reservationId, reservation));
changes.addAll(InventoryChanges.hold(reservation, performance, now));
return changes;

Implement InventoryChanges to produce one ChangeSeatInventory for each numbered seat, or a count change for a standing section. The inventory command checks whether its current stock can accept the hold. Because those changes are one Model operation, a failure at the last seat also rolls back the first.

Keep ReservationHeld and the inventory changes internal with automaticHandling = DISABLED: clients submit ReserveTickets, which owns the complete decision.

Add a TicketingTutorialTest that extends TicketingTestSupport. Its fixture(false) creates the small test catalogue at NOW; seats(id, ...) builds a selection in SHOW. ALICE, BOB and OPERATOR are fixture users. Keep the test catalogue: its A1/A2/B1/B2 seats make the outcomes easy to inspect.

Reserve A2 for Alice, then let Bob request B1 and A2:

@Test
void wholeGroup() {
var rejected = new ReservationId("bob-rejected");
fixture(false).givenCommandsByUser(ALICE, seats(R, "A2"))
.whenCommandByUser(BOB, seats(rejected, "B1", "A2"))
.expectExceptionalResult(IllegalCommandException.class)
.expectThat(f -> assertNull(Fluxzero.loadModel(rejected).get()))
.andThen().whenCommandByUser(BOB, seats(new ReservationId("bob-order"), "B1", "B2"))
.expectSuccessfulResult();
}

The final booking proves that the failed attempt did not leave B1 occupied. Repeat the same idea with standing capacity: reserve four places in the six-place test section, reject a group of three, then accept a group of two. floor(id, count) in the test support constructs those standing selections.

A hold lasts fifteen minutes, capped at the performance start. Store that deadline on the reservation and add ReservationDeadlines as a handler. It schedules an owned expiry command for the current hold and cancels it when the reservation leaves HELD.

Implement the expiry command itself:

@NoUserRequired
public record ExpireReservation(@Parent @NotNull ReservationId reservationId) {
@InterceptApply Object ignoreStale(@Nullable Reservation reservation) {
return reservation != null && reservation.status() == ReservationStatus.HELD && !Fluxzero.currentTime().isBefore(reservation.expiresAt())
? this : null;
}
@Apply Reservation apply(Reservation reservation) { return reservation.withStatus(ReservationStatus.EXPIRED); }
}

The inventory already tests the deadline when deciding whether stock is occupied. Availability therefore does not depend on a timer arriving at precisely the deadline. The scheduled command updates the reservation’s status.

Advance time and book the same seat again:

@Test
void expiry() {
fixture(false).givenCommandsByUser(ALICE, seats(R, "A1"))
.whenTimeElapses(Duration.ofMinutes(15))
.expectThat(f -> assertEquals(ReservationStatus.EXPIRED, reservation().status()))
.andThen().whenCommandByUser(BOB, seats(new ReservationId("bob-order"), "A1"))
.expectSuccessfulResult();
}

Give Payment its own Model under Reservation. A payment attempt can fail, succeed or need a refund independently of whether the reservation still holds its places.

Implement StartPayment to check ownership and an active online hold, then create a pending payment for the reservation’s total. Only the payment integration may report money received. In RecordPaymentSuccess, first recognize a repeated confirmation, then decide whether this capture can still confirm the reservation:

@NotNull @Valid Money amount) {
@InterceptApply Object decide(Payment payment, Reservation reservation, Performance performance) {
if (payment.captured() != null) {
require(payment.captureReference().equals(captureReference) && payment.captured().equals(amount),
"Conflicting payment confirmation");
return null;
}
Instant receivedAt = Fluxzero.currentTime();
boolean accepted = reservation.holdsAt(receivedAt) && amount.equals(payment.expected());
accepted = accepted && !performance.cancelled() && receivedAt.isBefore(performance.details().startsAt());
var capture = new PaymentCaptured(paymentId, reservation.reservationId(), captureReference, amount, receivedAt, accepted);
var changes = new ArrayList<Object>();
changes.add(capture);
if (accepted) changes.addAll(InventoryChanges.sell(reservation, performance, receivedAt));
return changes;
}
}

Implement the @Apply methods on PaymentCaptured together: record the captured money, confirm the reservation if accepted, and create one Ticket per admission. If it cannot be accepted, retain the capture and create a refund obligation instead. Do not erase a financial fact because its reservation expired.

The fixture’s pending(false) reserves A1/A2 and starts their €70 payment. success() reports that exact amount:

@Test
void issueTickets() {
pending(false).whenCommandByUser(PAYMENTS, success())
.expectSuccessfulResult()
.expectNoSchedules()
.expectThat(f -> {
assertEquals(ReservationStatus.CONFIRMED, reservation().status());
assertEquals(2, Fluxzero.loadGraph(R).childModels(Ticket.class).size());
});
}
@Test
void latePayment() {
pending(false).givenElapsedTime(Duration.ofMinutes(15))
.givenCommandsByUser(BOB, seats(new ReservationId("replacement"), "A1", "A2"))
.whenCommandByUser(PAYMENTS, success())
.expectThat(f -> {
assertEquals(PaymentStatus.REFUND_REQUIRED, payment().status());
assertTrue(Fluxzero.loadGraph(R).childModels(Ticket.class).isEmpty());
});
}

The booking rules now work without a payment provider. Add the adapter in payment/stripe with the existing BeginStripePayment entry point. It starts the payment and records a provider-work request.

Use StripePaymentProcess, a @Stateful handler associated with PaymentId, to remember which provider operation is in progress. StripePaymentEffects reacts to that stored state and sends the next command or query. The provider attempt is separate from the business payment we just built.

Implement CreateStripeIntent using SendToStripe, the app’s shared authentication and request conventions:

@LocalOnly @RequiresAnyRole("PAYMENTS")
public record CreateStripeIntent(PaymentId paymentId, Money amount, String operationKey) implements SendToStripe<StripeIntent> {
@HandleCommand StripeIntent handle() { return StripeIntent.from(send()); }
@Override public WebRequest.Builder request() {
return WebRequest.post("https://api.stripe.com/v1/payment_intents")
.contentType("application/x-www-form-urlencoded")
.body(form(Map.of("amount", Long.toString(amount.minorUnits()), "currency", "eur",
"payment_method_types[]", "card", "capture_method", "automatic",
"metadata[payment_id]", paymentId.getFunctionalId(), "metadata[operation_key]", operationKey)));
}
}

The process retains operationKey; the request layer uses it as the provider’s idempotency key. StripePaymentEffects creates the intent once it has work to do, or reads the existing intent with FetchStripePaymentIntent. After validating the returned identity and amount, it sends RecordPaymentSuccess. That reaches the same rules we tested above.

Implement the endpoint using the existing ReceiveStripeWebhook verification command. It validates the signature against the original body before parsing it and publishes a correlated notification. The process then asks for a fresh provider observation. This is the small handoff inside StripePaymentProcess:

@HandleEvent StripePaymentProcess webhook(StripeWebhookReceived event) {
require(account.equals(event.account()), "Webhook identifies another Stripe account");
require(operationKey.equals(event.operationKey()), "Webhook payment correlation mismatch");
return notified(new Notification(paymentId, event.eventId(), event.objectId()));
}

Check: run StripeIntegrationTest and StripeWebhookTest with their controlled provider responses. Complete a checkout, deliver its callback again, and confirm that the purchase still has one set of tickets. Only then use the app’s Stripe sandbox profile for a browser checkout. Credentials and provider setup belong there, not in the reservation code.

Add an owner-controlled receipt address with SetReceiptEmail. Once the reservation changes to CONFIRMED, ConfirmationRequests publishes a delivery request for that address. Compare the event’s previous and current reservation so unrelated later changes do not request another confirmation.

Implement ConfirmationDelivery as a stateful handler associated with the reservation. It remembers the recipient, accepted result or retry state. Its acceptance method is deliberately small:

@HandleEvent ConfirmationDelivery duplicate(ConfirmationRequested event) { return this; }

Add ConfirmationEffects to send SendConfirmationMail and publish that acceptance. A temporary mail failure schedules a retry; it does not undo the paid reservation.

For the actual admission document, implement GetTicketPass to load a ticket owned by the current user and produce its signed credential. TicketPdf renders the printable version. The wallet adapters use the same owned-ticket data; their format and signing code do not change the ticket Model.

Check: record a receipt email before payment, complete the purchase and inspect the local inbox. Open the ticket, download its PDF and confirm that both identify the same performance and place. ConfirmationTest tests delivery; TicketPassTest tests ticket access and credentials. Apple and Google issuer setup is covered by the app’s delivery guide.

A valid ticket still needs an admission decision. Add a Gate for the performance and a CheckIn Model parented to the ticket. Implement CheckInTicket to check the performance, ticket validity, gate and staff permission, then create the admission record:

@RequiresUser
public record CheckInTicket(@NotNull TicketId ticketId, @NotNull PerformanceId performanceId) {
@AssertLegal Object allowed(Ticket ticket, Performance performance, @Nullable Gate gate) {
require(ticket.performanceId().equals(performanceId), "Ticket belongs to another performance");
require(ticket.status() == TicketStatus.VALID && !performance.cancelled(), "Ticket is not valid for admission");
require(gate != null && gate.open(), "Admission is closed");
return null;
}
@AssertLegal Object staff(User user) { return StaffPermission.forUser(performanceId, user, Permission.ADMISSION); }
@Apply CheckIn apply(User user, Instant timestamp) {
return new CheckIn(ticketId, timestamp, user.id());
}
}

The public scan route submits RedeemTicket, which verifies the signed credential and expands into CheckInTicket. Keep the scanner UI in frontend/src/Admission.jsx focused on its result.

The Model creation rejects a second check-in for the same ticket. Demonstrate it with a paid purchase:

@Test
void admitOnce() {
var scan = new CheckInTicket(new TicketId("alice-order:1"), SHOW);
paid(false).givenCommandsByUser(OPERATOR, new SetGateOpen(SHOW, true))
.whenCommandByUser(OPERATOR, scan)
.expectSuccessfulResult()
.andThen().whenCommandByUser(OPERATOR, scan)
.expectExceptionalResult(IllegalCommandException.class)
.expectNoEvents();
}

Transfer a ticket without transferring the purchase

Section titled “Transfer a ticket without transferring the purchase”

Implement OfferTicketTransfer to create a ticket-scoped invitation with a recipient and expiry. The ticket keeps its current owner until the recipient accepts.

In AcceptTicketTransfer, validate the invitation version, recipient and transferable state. Then update both the invitation and the ticket. This is the ticket update:

@Apply TicketTransfer transfer(TicketTransfer transfer) { return transfer.withStatus(TicketTransfer.Status.ACCEPTED); }

The credential version changes with ownership. An old QR code no longer passes verification; the reservation, payment and original invoice still belong to the original purchase.

Wire the offer and accept actions in frontend/src/Transfers.jsx to their existing endpoints. Check: buy a ticket, offer it to another signed-in customer and accept as that customer. The recipient can now open the ticket; try its old code at Entrance and see it refused. TicketTransferTest covers this complete path, including invitation cancellation.

Cancel purchases and return individual tickets

Section titled “Cancel purchases and return individual tickets”

Implement CancelReservation by checking customer ownership and whether admission has already been used. It delegates the actual changes to ReservationCancelled.changes so organizer and performance cancellation can reuse the same operation:

public static List<Object> changes(Reservation reservation, io.fluxzero.ticketing.catalog.api.model.Performance performance,
java.time.Instant now) {
var changes = new java.util.ArrayList<Object>(io.fluxzero.ticketing.booking.InventoryChanges.release(reservation, performance, now));
changes.add(new ReservationCancelled(reservation.reservationId()));
if (reservation.paidBy() != null) changes.add(new io.fluxzero.ticketing.payment.api.RefundRequired(reservation.paidBy()));
return changes;
}

ReservationCancelled changes the reservation and voids its tickets. A paid reservation also opens a refund obligation. Implement ConfirmRefund to record the completed repayment while keeping the original capture. The Stripe refund process executes that obligation using the same request/process/effect pattern as checkout.

Add a partial return in RefundTickets: select unused valid tickets, add their original prices, release only their inventory, void those tickets and create one Refund. A later cancellation returns only the remaining paid amount.

Check the simpler full cancellation first:

@Test
void cancelPurchase() {
paid(false).whenCommandByUser(ALICE, new CancelReservation(R))
.expectSuccessfulResult()
.expectThat(f -> {
assertEquals(ReservationStatus.CANCELLED, reservation().status());
assertEquals(PaymentStatus.REFUND_REQUIRED, payment().status());
assertEquals(new Money(7000, "EUR"), payment().captured());
})
.andThen().whenCommandByUser(BOB, seats(new ReservationId("new-buyer"), "A1", "A2"))
.expectSuccessfulResult();
}

Then follow PartialRefundTest for returning one ticket and cancelling the remainder. In Operations, display the retained payment and each refund separately so staff can see what is still pending.

Implement CancelPerformance to close the performance first. Let PerformanceCancellation settle its reservations using the shared cancellation operation. The performance moves through SETTLING to SETTLED; a delayed payment still reaches the earlier late-payment rule. This reuses the purchase rules instead of creating a second way to release stock or return money.

Add organizer permissions and box-office sales

Section titled “Add organizer permissions and box-office sales”

An organizer needs different actions from a customer. Add performance-scoped StaffAccess with MANAGE and ADMISSION permissions. Implement SetStaffAccess so only an operator can grant them; an empty permission set removes the grant. Each managed query and command checks its own performance through StaffPermission.

Build the organizer workspace with GetManagedPerformances, GetManagedOrders and GetManagedReservation. Page the lists, then load one selected order’s details. The browser is a client of these queries, not the owner of the permission decision.

Implement ReserveBoxOfficeTickets using the same reservation and inventory changes as online sales, with BOX_OFFICE as its channel. RecordBoxOfficePayment records a cash or external-terminal receipt and uses PaymentCaptured to issue tickets. The receipt is a staff-recorded financial fact; this command does not charge a card.

Add this scenario after the customer flows:

@Test
void boxOffice() {
fixture(false).givenCommandsByUser(IDENTITY, new RecordSignedInPerson("alice", "Alice"))
.givenCommandsByUser(OPERATOR, new ReserveBoxOfficeTickets(R, SHOW, "alice",
List.of(new Selection("stalls", "A1"))))
.whenCommandByUser(OPERATOR, new RecordBoxOfficePayment(R, BoxOfficeReceipt.Method.CASH,
"receipt-1", new Money(3500, "EUR")))
.expectSuccessfulResult()
.expectThat(f -> assertEquals(1, Fluxzero.loadGraph(R).childModels(Ticket.class).size()))
.andThen().whenCommandByUser(BOB, seats(new ReservationId("online"), "A1"))
.expectExceptionalResult(IllegalCommandException.class);
}

Add BlockProductionInventory and ReleaseProductionInventory for places retained by the organizer. Store their reason on a ProductionHold and use the same seat/section stock. These blocks have an explicit release; they are not fifteen-minute customer holds.

Check: block a seat in Operations, find it unavailable to a customer, then release it and reserve it online. ProductionAllocationTest checks that overlap with sales. The box-office and production screens now reuse the same inventory instead of maintaining separate capacities.

Offer newly available places to a waitlist

Section titled “Offer newly available places to a waitlist”

A waitlist entry remembers interest: performance, section, group size and ticket preference. Implement JoinWaitlist without touching stock. Only an offer reserves places.

In OfferWaitlistPlaces, check the manager’s permission and the customer’s requested group, then create a normal reservation, hold its inventory and attach its ID to the entry in one operation. The new reservation automatically gets the expiry and payment behavior already built.

Check that the offer really holds its two places:

@Test
void waitlist() {
var entry = new WaitlistEntryId("alice");
var offer = new ReservationId("waitlist:alice");
fixture(false).givenCommandsByUser(ALICE, new JoinWaitlist(entry, SHOW,
new WaitlistPreference("stalls", 2, "standard", false)))
.whenCommandByUser(OPERATOR, new OfferWaitlistPlaces(entry,
List.of(new Selection("stalls", "A1"), new Selection("stalls", "A2"))))
.expectSuccessfulResult()
.expectThat(f -> assertEquals(NOW.plus(Duration.ofMinutes(15)),
Fluxzero.loadModel(offer).get().expiresAt()))
.andThen().whenCommandByUser(BOB, seats(new ReservationId("other"), "A1"))
.expectExceptionalResult(IllegalCommandException.class);
}

Connect the customer and staff views in frontend/src/Waitlist.jsx. Staff select the offered group; this example does not implement automatic queue allocation. Once offered, the customer follows the ordinary checkout before the reservation deadline. WaitlistTest also checks expiry and competing offers.

Finish the financial record with an independent Invoice and CreditNote. Implement DraftInvoice by copying the confirmed purchase’s admissions and total into a draft. IssueInvoice marks it issued; cancelling the purchase must not silently rewrite it.

After cancellation, implement CreditInvoice to update the invoice status and create the full credit note:

@RequiresAnyRole("BILLING")
public record CreditInvoice(@NotNull InvoiceId invoiceId, @NotBlank String reason) {
@AssertLegal void validate(Invoice invoice, Reservation reservation) {
require(invoice.status() == InvoiceStatus.ISSUED, "Only an issued invoice can be credited");
require(reservation.status() == ReservationStatus.CANCELLED, "Credit requires a cancelled purchase");
}
@Apply Invoice apply(Invoice invoice) { return invoice.withStatus(InvoiceStatus.CREDITED); }
@Apply List<CreditNote> credit(Invoice invoice, Instant timestamp) {
return List.of(new CreditNote(new CreditNoteId(invoiceId.getFunctionalId()), invoiceId,
invoice.total(), reason, timestamp));
}
}

Check: start from paid(false), draft and issue an invoice as BILLING, cancel the purchase as ALICE, then credit the invoice as BILLING. The original total and payment capture must remain visible. The invoicingAndCreditKeepOriginalAmountsAndPaymentHistory scenario in TicketingTest shows the full sequence. Invoicing is a core feature here; the example does not yet have a billing screen.

The pieces are ready to expose together. Keep the existing authenticated browser session and same-origin request checks. Build BookingEndpoint around the commands and queries already tested. The reservation route needs only:

@HandlePost ReservationId reserve(ReserveTickets command, WebRequest request) {
BrowserRequests.requireSameOrigin(request);
Fluxzero.sendCommandAndWait(command);
return command.reservationId();
}

In frontend/src/Performance.jsx, collect the customer’s choices and call the existing post helper:

const reservationId = crypto.randomUUID();
await post("/api/reservations", {
reservationId,
performanceId,
selection: [
{ sectionId: "stalls", seatId: "A1", ticketType: "standard" },
{ sectionId: "stalls", seatId: "A2", ticketType: "standard" },
],
});
navigate(`/reservation/${reservationId}`);

Use the IDs and choices returned by the seat map in the actual screen. Purchase.jsx reads the reservation, displays the deadline and starts checkout. MyTickets.jsx reads owned tickets and incoming transfers. Refresh server state after each action and while a hold or payment is pending, using the app’s existing refresh hooks; a browser countdown is a display, not the authority to keep seats.

Run through one complete journey: find a show, reserve two places, complete a payment, open the tickets and admit one. Then try the distinct follow-ups: transfer the unused ticket, return an unused ticket from another purchase, and offer released places from the waitlist. Use Operations to compare the retained order, payment and refund records with what each customer sees.

The app now connects catalogue, inventory, purchasing, external delivery, admission and organizer workflows. Each feature adds its own decision while reusing the reservation, ticket and financial lifecycles already built.


© 2026 Fluxzero