Back to Journal
netorigoen

Wiring Finance + Logistics + Travelium — a shared state machine

5 shared order states, module-specific sub-states, order_events bus + outbox — connecting Finance + Logistics + Travelium from a 7th module's perspective.

The most complex interface point in Netorigo is the shared boundary between Finance, Logistics, and Travelium. After an order is placed, three modules have to work in coordination: it must be invoiced, it must be shipped, and if there's a travel product it must also be booked. Below we present the shared state machine that 18 months of production experience has shaped.

The five order states

The sales_orders.status enum used to have 14 states. After a retrospective we cut it down to five domain-level states, because the rest can be handled at module level:

  1. pending — the cart has gone through checkout, awaiting payment confirmation.
  2. confirmed — payment arrived, the order is "live".
  3. fulfilling — at least one module is actively working on it (logistics picking, finance invoicing, travelium booking).
  4. completed — every module is done, the customer received the product / service.
  5. cancelled — some module vetoed (refund initiated, or never fulfilled).

The pending → confirmed transition is a payment callback. confirmed → fulfilling is automatic, once the Finance module has issued an invoice. fulfilling → completed is an aggregate: every module-specific sub-state must be done.

The module-specific sub-states

Every module stores an order_module_state record in its own table, referencing the shared order_id. So:

  • Finance: invoice_state = (pending | issued | paid | refunded)
  • Logistics: shipment_state = (waiting | picked | packed | shipped | delivered | returned)
  • Travelium: booking_state = (pending | reserved | confirmed | travelled | cancelled)

An order is completed when every linked module-state is in a "terminal happy" state: invoice = paid, shipment = delivered (if there's a physical product), booking = travelled (if there's a travel product). A missing module row doesn't block — a purely digital order has no Logistics row at all.

The event bus as communication

Communication between modules is NOT direct API calls but an event bus (order_events Postgres outbox + NATS dispatch). An OrderConfirmedEvent, for example, is consumed by every module's listener; each decides whether to react:

  • Finance listener: issues an invoice, writes invoice_state = issued.
  • Logistics listener: if there's a physical SKU, creates a pick_list row, shipment_state = waiting.
  • Travelium listener: if there's a travel SKU, places an allotment reservation, booking_state = reserved.

Other modules (Catalog, Inventory) also hear the event but don't react (or only update metrics).

What a developer building an interface point needs to know

If you build a new integration with a 7th module (say "Subscription"), follow these rules:

  1. You also subscribe to the order_events bus. Never call the Sales module directly.
  2. You keep your own order_module_state row. If your module touches the order, you write a row, you update it on state change.
  3. You register with the fulfilling → completed aggregator. A config entry (modules_required_for_completion) adds your module — the order won't go to completed until you're done.
  4. You handle cancel cases. If Sales moves an order to cancelled, you must call your rollback logic so your module cleans up (e.g. terminate the subscription).
  5. You use idempotency keys on every mutation. The event bus is at-least-once, so if you receive the same event twice, react only once. The event id makes a fine idempotency key.

A concrete problem we solved

At the start, the trouble was that if Finance issued an invoice but Logistics hadn't shipped yet, the customer received two emails: one invoice email and one "shipment soon" email — at two different times. The current solution: notifications are not module-level but state-machine-level. OrderStateChangedEvent sends an email only on confirmed ("Thanks for the order, invoice attached, shipment soon") and on completed ("Arrived, want to review?"). Module-internal state changes don't trigger emails.

Takeaway

Wiring three modules (Finance + Logistics + Travelium) is not a "REST API in Finance, REST API in Logistics, integration glue in the middle" problem. It's a shared state machine + event bus problem. The shared state machine has 5 states, the sub-states are module-level, the event bus is the connector. If you understand this, you understand 80% of the Netorigo backend.