Adhish P. Acharya

August 3, 2026

The Hidden Assumption That Almost Shipped: Passenger Count Isn't Voucher Count

backenddata-modelingnestjspostgresqlcase-study

Context

A tour operations team was managing meal coordination for group trips almost entirely by hand: cross-referencing bookings, counting passengers per age category, and messaging caterers over chat with headcounts. The ask was simple on paper: give them a button that exports the data they need into an Excel sheet, grouped by vehicle, ready to hand off.

The interesting part wasn't the export mechanics. It was a wrong assumption baked into my first design.

The Wrong Model

My first pass treated "meal voucher count" as a direct function of passenger count: count how many passengers are on a booking, that's how many vouchers they need. It's a reasonable default. Most CRUD features really are that simple.

Except this one wasn't. Talking to the team surfaced three cases that break a strict 1:1 mapping:

  • Extra vouchers. A group can request more vouchers than passengers on the booking: an add-on booked for someone outside the party count.
  • Excluded categories. Some age tiers (very young children, for example) are deliberately excluded from meals even though they're on the passenger manifest.
  • Complimentary lines. Some vouchers are issued at zero cost as a courtesy, but they still need to appear on the export. They're real vouchers the caterer needs to account for, even though no money changed hands for them.

None of these are exotic. They're routine, and a passenger-count-driven export would have quietly produced the wrong number in all three cases. That's the kind of bug that doesn't throw an error, it just slowly erodes trust in the export until someone stops using it.

The Fix

The correction was to stop deriving voucher counts from passenger rows at all. Instead, eligibility and quantity both come from the same source: the actual line items attached to the booking.

Conceptually:

booking
  -> line items (type = addon)
    -> addon variant
      -> addon (has an "effect" tag, e.g. "meals")

A booking qualifies for the export if it has at least one active line item whose addon carries the meals effect tag. And the voucher count for that booking isn't passengers.length. It's the sum of quantity across those specific line items:

function buildVoucherTotal(mealLineItems: { quantity: number }[]): number {
  return mealLineItems.reduce((sum, item) => sum + item.quantity, 0);
}

This single change made passenger count irrelevant to the voucher math, which is exactly the point. The two facts are related in the domain (you generally book meals for passengers) but they are not the same fact, and modeling them as if they were the same fact is where the bug would have lived.

A secondary win came for free: because eligibility is derived by walking real relations at query time rather than checked against a stored "meal opted in" boolean, there's no risk of that flag drifting out of sync with the actual booking state. The trade-off is a slightly heavier query, an acceptable cost for a low-traffic, on-demand export endpoint, though it's a trade-off I'd revisit if this logic needed to run in a hot path.

What I Learned

The failure mode here wasn't a bug in the traditional sense. The code I wrote first would have worked, produced numbers, and looked done. The risk was entirely in the modeling: assuming a simple relationship between two numbers that happen to usually move together, without asking whether they're always equal.

The actual fix took less time than writing this post. Finding it took a five-minute conversation with the person who deals with the exceptions daily. That's the real takeaway: when a business rule sounds like arithmetic, that's the moment to go find the exceptions before writing the query, not after.

What's Next

The formatting layer built for this export (sheet generation, merged cells, styling helpers) was written as a dependency-free, reusable module rather than something coupled to this one feature. The next export in the pipeline needs only the domain-specific grouping logic, not another pass at spreadsheet plumbing. That's a good candidate for its own write-up.