GUIDED TOUR
Buy a ticket
Follow one order through registration, manual payment, approval, and email delivery.
Mina wants two General tickets priced at ৳1,200 each. With no discount, the server calculates a total of 240,000 paisa (৳2,400). Mina, the event, and these amounts are invented for this tour; the behavior comes from the application.
By the end, you will know which writes share a transaction, who confirms the payment, and why delivery failures do not undo issued tickets.
ONE ORDER · FOUR BOUNDARIES
- Register and reserveOrder + hold + audit commit together
- Pay and submitManual transfer, then transaction ID
- Verify and issueAdmin decision + tickets commit together
- DeliverQueue and worker, after commit
1. Register with IDs and quantities
The registration action validates the form and calls the order service. It does not trust a price supplied by the browser. The service checks the event's registration window, the ticket type's sale window, and the requested quantity, then reads prices and any applicable promo from the database.
Follow the registration action → order service →
This illustrative call imports the actual pure quantity validator. Hover the
function or valid to inspect its types. TypeScript checks that the argument
is a number; the validator checks the integer and range rules at runtime.
const = 2;
const valid = ();The implementation below is extracted during the build:
export function isValidOrderQuantity(quantity: number): boolean {
return (
Number.isInteger(quantity) &&
quantity >= MIN_TICKETS_PER_ORDER &&
quantity <= MAX_TICKETS_PER_ORDER
);
}See the validation tests and promo tests for refusal paths.
2. Reserve inventory and create the order together
The service opens a transaction, checks the buyer's open-order cap, and reserves
two tickets. It inserts the order and an order.created audit row in that same
transaction. A transaction commits all those writes or rolls them all back.
The order starts as pending_payment. Stock is held, not sold. The reservation
is a conditional UPDATE, so competing requests cannot both claim stock that
only one request can satisfy.
async reserve(ticketTypeId, quantity, tx = db) {
assertPositiveInteger(quantity);
// UPDATE ticket_types SET quantity_reserved = quantity_reserved + $qty
// WHERE id = $id AND quantity_total - quantity_sold - quantity_reserved >= $qty
// RETURNING id
const rows = await tx
.update(ticketTypes)
.set({
quantityReserved: sql`${ticketTypes.quantityReserved} + ${quantity}`,
})
.where(
and(
eq(ticketTypes.id, ticketTypeId),
sql`${ticketTypes.quantityTotal} - ${ticketTypes.quantitySold} - ${ticketTypes.quantityReserved} >= ${quantity}`,
),
)
.returning({ id: ticketTypes.id });
return rows.length === 1;
}If the UPDATE returns no row, the service reports sold out. If a later insert fails, the transaction rolls the hold back too. A reference collision can retry the complete transaction with a new reference; it cannot leave the old hold behind.
Explore two buyers racing for the last ticket, then inspect the order transaction tests and order creation decision.
3. Transfer money, then submit its transaction ID
Mina transfers ৳2,400 through bKash outside the application. She then submits the transaction ID and the sending phone number on her order page. This is a payment claim awaiting a person's verification, not an automatic callback.
submitPayment normalizes digits, trims whitespace, and uppercases the
transaction ID. It locks the order row, checks the current status and hold
cutoff, updates the order to pending_verification, and records an audit row.
The database's unique index prevents another order from using the same ID.
The buyer sees a 20-minute countdown. The server accepts a transaction ID for
a further two minutes as a grace period. At the cutoff, an unpaid order can
lapse even before the expiry job updates its stored status. Once the order is
pending_verification, the automatic expiry job leaves it for the admin's
decision. A buyer can correct the payment details while verification is pending;
the correction is audited too.
See the hold rule, its boundary tests, and the payment submission decision.
4. Verify the statement and issue the tickets
The admin compares the submitted ID and amount with the bKash statement, then approves or rejects. The approval request carries the exact transaction ID the admin checked. Under the order's row lock, the service refuses approval if the buyer has changed that ID since the admin loaded the page.
Approval is one transaction in fulfilment.service.ts:
- Lock the order and require
pending_verification. - Confirm the verified transaction ID still matches.
- Transition to
paidand recordpayment.approved. - Convert held inventory to sold and create one ticket per attendee name.
- Transition to
issuedand recordtickets.issued.
When the transaction commits, the order is already issued. paid is an
intermediate step inside that transaction, preserved in the audit history.
Rejection instead records the decision and releases the hold in one transaction.
The fulfilment service is the sole issuance path. Review the changed-ID and collision tests, concurrent approval tests, and the fulfilment decision.
5. Deliver after the commit
After registration, the application queues a payment-instructions email. After approval, it queues the ticket email. The worker reads the order and renders the relevant message; ticket delivery includes the PDF and ticket links.
No email or HTTP request runs inside the database transaction. This actual order-service helper catches a failed after-commit hook:
async function afterCommit(hook: () => Promise<void>, what: string, orderId: string) {
try {
await hook();
} catch (err: unknown) {
logger.error({ orderId, err }, `orders.service: ${what} hook failed`);
}
}The queue producer gives accepted order-email jobs five attempts with exponential backoff. A send failure rejects the job so BullMQ can retry. A failure to enqueue is different: the service logs it and the committed order remains valid. This is not a transactional outbox that guarantees replay of every missing job. An admin can request a ticket-email resend after issuance.
Read the queue producer, email dispatcher, and after-commit and delivery failure tests together.
Keep the state contract close
Use the order-state reference when you need the exact statuses and failure behavior. Use the last-ticket walkthrough to inspect the race that the reservation query prevents.
Source revision: 94a6d5c