echo&aura developer docs
Tours

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

  1. Register and reserveOrder + hold + audit commit together
  2. Pay and submitManual transfer, then transaction ID
  3. Verify and issueAdmin decision + tickets commit together
  4. DeliverQueue and worker, after commit
The buyer transfers money outside the application. Registration and approval each commit their related writes atomically. Payment submission is a separate audited transaction. Email delivery follows each relevant 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 →

pricing rule.

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.

Quantity validation · illustrative call
const  = 2;
const valid = ();
const valid: boolean

The implementation below is extracted during the build:

order-rules.ts · actual source
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.

inventory.repository.ts · actual reservation
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:

  1. Lock the order and require pending_verification.
  2. Confirm the verified transaction ID still matches.
  3. Transition to paid and record payment.approved.
  4. Convert held inventory to sold and create one ticket per attendee name.
  5. Transition to issued and record tickets.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:

orders.service.ts · actual after-commit helper
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

On this page