Skip to content
AyoKoding

Intermediate

This intermediate tutorial extends the beginner PurchaseOrder typestate pattern to the Invoice lifecycle, introduces phantom types as a second dimension (verification state), and shows three-way match guard composition. The procurement-platform-be domain requires that invoices pass a quantity+price match against the PO and GRN before payment is approved.

Canonical sources: Ana Hoverbear — Pretty State Machine Patterns in Rust; Will Crichton — Type-Driven API Design in Rust; looplab/fsm (v1.0.3, Apache 2.0); Jim Blandy, Jason Orendorff, Leonora F. S. Tindall — Programming Rust, 3rd ed. (O'Reilly, 2024).

Invoice FSM (Examples 25–30)

Example 25: Invoice State Types

The Invoice lifecycle is parallel to but independent from the PurchaseOrder — it moves from Draft through Submitted and Approved to Paid, with a terminal Rejected state reachable from both Draft and Submitted. Rust models each lifecycle position as a distinct zero-size struct type, while Go uses string constants paired with looplab/fsm's runtime event table.

stateDiagram-v2
    [*] --> InvoiceDraft: create
    InvoiceDraft --> InvoiceSubmitted: submit
    InvoiceDraft --> InvoiceRejected: cancel
    InvoiceSubmitted --> InvoiceApproved: approve
    InvoiceSubmitted --> InvoiceRejected: reject
    InvoiceApproved --> InvoicePaid: pay
    InvoiceRejected --> [*]
    InvoicePaid --> [*]
// => Invoice state types mirror the PurchaseOrder typestate idiom from beginner examples
// => Each state is a distinct zero-size struct — no data, no runtime overhead
 
pub struct InvoiceDraft;
// => Zero-size type: PhantomData<InvoiceDraft> occupies 0 bytes
pub struct InvoiceSubmitted;
// => Submitted means the supplier has sent the invoice for review
pub struct InvoiceApproved;
// => Approved by an authorised finance reviewer
pub struct InvoicePaid;
// => Payment has been issued; terminal state
pub struct InvoiceRejected;
// => Rejected or cancelled; terminal state — no further transitions allowed
 
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use std::marker::PhantomData;
 
// => PhantomData<S> carries the state type at compile time without occupying memory
// => The type parameter S is the ONLY runtime-free enforcement mechanism
#[derive(Debug, Clone)]
pub struct Invoice<S> {
    // => Each field is real data; S is purely compile-time
    pub id: String,
    // => po_id links this invoice to the originating PurchaseOrder aggregate
    pub po_id: String,
    pub supplier_id: String,
    // => amount is the total invoice value in the supplier's currency
    pub amount: Decimal,
    pub created_at: DateTime<Utc>,
    // => _state: PhantomData<S> makes the compiler track which state struct is active
    // => Without this field the type parameter would be unconstrained (compile error)
    _state: PhantomData<S>,
}
// => Invoice lifecycle is independent from PurchaseOrder lifecycle
// => A PO can be in Received state while its Invoice is still in Draft

Key takeaway: Rust encodes the Invoice lifecycle entirely in the type system; Go encodes it in a runtime event table — both correctly prevent illegal transitions.


Example 26: Creating a Draft Invoice

The factory function establishes the entry point for the Invoice lifecycle. In Rust the return type Invoice<InvoiceDraft> is part of the function signature — it is impossible to construct an Invoice<InvoiceApproved> directly. In Go the looplab FSM is wired with all transitions at construction time, so the runtime guard is in place before any event fires.

stateDiagram-v2
    [*] --> InvoiceDraft: Invoice::create / CreateInvoice
// => impl Invoice<InvoiceDraft> scopes methods to the Draft state only
impl Invoice<InvoiceDraft> {
    // => Factory function: only way to produce an Invoice value
    // => Return type Invoice<InvoiceDraft> is a compile-time guarantee
    pub fn create(
        id: String,
        po_id: String,
        supplier_id: String,
        amount: Decimal,
    ) -> Invoice<InvoiceDraft> {
        // => Construct the struct; _state is PhantomData — zero memory
        Invoice {
            id,
            po_id,
            supplier_id,
            amount,
            // => created_at captured at construction for audit trail
            created_at: Utc::now(),
            // => PhantomData::<InvoiceDraft> is the only way to set S = InvoiceDraft
            _state: PhantomData,
        }
    }
    // => No other constructor exists — callers cannot skip the Draft entry state
}

Key takeaway: Both approaches make it impossible to produce a valid invoice in any state other than Draft — Rust via the type system, Go via FSM initialisation.


Example 27: Draft → Submitted

The submit transition represents the supplier formally presenting the invoice for approval. In Rust, submit(self) consumes the Draft invoice — the caller cannot hold a reference to the old Draft after calling submit. In Go, FSM.Event(ctx, "submit") returns an error if the current state is not "draft".

stateDiagram-v2
    InvoiceDraft --> InvoiceSubmitted: submit
// => Methods on Invoice<InvoiceDraft> are only callable when S = InvoiceDraft
impl Invoice<InvoiceDraft> {
    // => submit(self) CONSUMES the Draft invoice — moved, not borrowed
    // => After this call, the variable that held the Draft value is gone
    pub fn submit(self) -> Invoice<InvoiceSubmitted> {
        Invoice {
            // => Move each field into the new struct; no clone required
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            // => _state: PhantomData now encodes S = InvoiceSubmitted
            // => The compiler verifies this type change at the call site
            _state:      PhantomData,
        }
    }
}
// => Calling draft.submit() twice is a compile error: value used after move

Key takeaway: Rust's move semantics eliminate the "stale reference" class of bug; Go's runtime FSM guard catches invalid transitions at the point of the Event call.


Example 28: Submitted → Approved (with approver)

Approval records who authorised the invoice — a mandatory audit requirement in Procure-to-Pay. In Rust, the approver identity can be stored by enriching the struct or via a separate audit log; here the transition captures the name in a log callback. In Go, looplab's AfterEvent callback fires after the state change and can persist audit data.

stateDiagram-v2
    InvoiceSubmitted --> InvoiceApproved: approve
// => InvoiceApproved carries extra audit data compared to InvoiceSubmitted
// => approved_by is baked into the type — you cannot have an ApprovedInvoice without it
#[derive(Debug, Clone)]
pub struct Invoice<S> {
    pub id:          String,
    pub po_id:       String,
    pub supplier_id: String,
    pub amount:      Decimal,
    pub created_at:  DateTime<Utc>,
    // => approved_by is Some after approval, None before
    // => Optional avoids a separate ApprovedInvoice struct for this single field
    pub approved_by: Option<String>,
    _state:          PhantomData<S>,
}
 
impl Invoice<InvoiceSubmitted> {
    // => approve(self, approved_by) CONSUMES the Submitted invoice
    // => Returns Invoice<InvoiceApproved>; caller cannot reuse the Submitted value
    pub fn approve(self, approved_by: String) -> Invoice<InvoiceApproved> {
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            // => Audit field populated here — mandatory for compliance
            approved_by: Some(approved_by),
            _state:      PhantomData,
        }
    }
}

Key takeaway: Approval is both a state transition and an audit event — the approver identity must travel with the transition, not be set separately.


Example 29: Approved → Paid

Payment closes the invoice lifecycle with a bank transfer reference required for reconciliation. The PaymentReference newtype in Rust prevents accidentally passing any string where a payment reference is expected — a newtype wrapper costs nothing at runtime.

stateDiagram-v2
    InvoiceApproved --> InvoicePaid: pay
// => PaymentReference is a newtype over String
// => Newtypes prevent accidental mix-up of domain strings (invoice ID vs payment ref)
#[derive(Debug, Clone)]
pub struct PaymentReference(pub String);
// => The inner String is accessible as payment_ref.0 or via pub field
 
impl Invoice<InvoiceApproved> {
    // => pay(self, payment_ref) CONSUMES the Approved invoice
    // => Only Invoice<InvoiceApproved> has this method — Submitted or Draft cannot pay
    pub fn pay(self, payment_ref: PaymentReference) -> Invoice<InvoicePaid> {
        // => In a richer struct we would store payment_ref for reconciliation
        // => Here PhantomData transition is the key demonstration
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: self.approved_by,
            // => _state now encodes InvoicePaid — terminal, no further methods
            _state:      PhantomData,
        }
    }
}
// => Invoice<InvoicePaid> has no transition methods defined — it is terminal
// => Attempting approved_invoice.pay(...).pay(...) is a compile error

Key takeaway: Newtypes like PaymentReference encode domain intent at the type level — the compiler rejects passing an invoice ID where a payment reference is required.


Example 30: Rejection from Submitted (and Cancellation from Draft)

Two separate source states lead to the same InvoiceRejected terminal state — the reason string differs by context. In Rust, separate impl blocks for Invoice<InvoiceDraft> and Invoice<InvoiceSubmitted> each define the relevant terminal transition. In Go both paths are declared in the FSM event table.

stateDiagram-v2
    InvoiceDraft --> InvoiceRejected: cancel
    InvoiceSubmitted --> InvoiceRejected: reject
    InvoiceRejected --> [*]
// => Two distinct impl blocks enforce that only the correct source state can reject
// => The compiler rejects calling cancel() on an InvoiceSubmitted value
 
impl Invoice<InvoiceDraft> {
    // => cancel() on Draft: supplier withdraws before submission
    // => Reason string required for audit — "supplier error", "duplicate", etc.
    pub fn cancel(self, reason: String) -> Invoice<InvoiceRejected> {
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: None,
            // => _state encodes InvoiceRejected — no further transitions possible
            _state:      PhantomData,
        }
    }
}
 
impl Invoice<InvoiceSubmitted> {
    // => reject() on Submitted: finance reviewer rejects after review
    // => Same return type InvoiceRejected — both paths converge here
    pub fn reject(self, reason: String) -> Invoice<InvoiceRejected> {
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: None,
            _state:      PhantomData,
        }
    }
}
// => Invoice<InvoiceRejected> has no methods — terminal state enforced by absence of impl

Key takeaway: Converging two source states to one terminal state is natural in both the typestate model (separate impl blocks, same return type) and the event-table model (two events, same Dst).


Three-Way Match Guards (Examples 31–36)

Example 31: MatchGuard Trait / Interface

Three-way match guards are pluggable strategies that each validate one dimension of the PO / GRN / Invoice alignment before allowing the approve transition. Defining a common interface means new guard types can be added without modifying existing guards or the approval method.

classDiagram
    class MatchGuard {
        <<trait>>
        +check(po, grn, invoice) Result~MatchError~
    }
    class QuantityMatchGuard {
        +check(po, grn, invoice) Result~MatchError~
    }
    class PriceMatchGuard {
        -tolerance_pct: f64
        +check(po, grn, invoice) Result~MatchError~
    }
    class CompositeGuard {
        -guards: Vec~Box~MatchGuard~~
        +check(po, grn, invoice) Result~MatchError~
    }
    MatchGuard <|.. QuantityMatchGuard
    MatchGuard <|.. PriceMatchGuard
    MatchGuard <|.. CompositeGuard
// => MatchGuard is a trait — defines the contract for all guard implementations
// => Any struct implementing check() can be used wherever MatchGuard is required
pub trait MatchGuard {
    // => &self: guard holds configuration (tolerance_pct) but no mutable state
    // => po, grn, invoice are borrowed — guard does not take ownership
    fn check(
        &self,
        po:      &PurchaseOrder,
        grn:     &GoodsReceiptNote,
        // => Invoice<InvoiceSubmitted> at the type level: only submitted invoices need matching
        // => Passing an Invoice<InvoiceDraft> here is a compile error
        invoice: &Invoice<InvoiceSubmitted>,
    ) -> Result<(), MatchError>;
}
// => Pure function contract: no mutation, returns structured error on mismatch
// => Open/Closed principle: add new guard types without changing existing code

Key takeaway: A guard trait/interface decouples matching strategy from the transition logic — the approve method knows nothing about quantity tolerances or price rules.


Example 32: QuantityMatchGuard

The quantity guard iterates every PO line item, finds the matching GRN receipt, and checks that the received quantity is within a configurable tolerance percentage. A tolerance is necessary because physical goods may be received with minor discrepancies due to measurement or packing rounding.

classDiagram
    class QuantityMatchGuard {
        -tolerance_pct: f64
        +check(po, grn, invoice) Result~MatchError~
    }
    class MatchGuard {
        <<trait>>
    }
    MatchGuard <|.. QuantityMatchGuard
// => QuantityMatchGuard holds the acceptable tolerance as a fraction (0.05 = 5%)
pub struct QuantityMatchGuard {
    pub tolerance_pct: f64,
}
 
impl MatchGuard for QuantityMatchGuard {
    fn check(
        &self,
        po:      &PurchaseOrder,
        grn:     &GoodsReceiptNote,
        invoice: &Invoice<InvoiceSubmitted>,
    ) -> Result<(), MatchError> {
        // => Iterate every line item on the PO — each must be checked independently
        for line in &po.line_items {
            // => Find the corresponding GRN receipt line by line_item_id
            let received = grn
                .received_items
                .iter()
                .find(|r| r.line_item_id == line.id);
 
            // => If GRN has no entry for this line, treat received qty as zero
            let received_qty = received
                .map(|r| r.qty_received)
                .unwrap_or(0.0);
 
            let expected_qty = line.qty;
            // => Compute absolute deviation as fraction of expected quantity
            let deviation = (received_qty - expected_qty).abs() / expected_qty;
 
            // => If deviation exceeds tolerance, return structured mismatch error
            if deviation > self.tolerance_pct {
                return Err(MatchError::QuantityMismatch {
                    po_line_id: line.id.clone(),
                    expected:   expected_qty,
                    actual:     received_qty,
                });
            }
        }
        // => All lines passed — return unit Ok
        Ok(())
    }
}
// => Early return on first mismatch — caller sees the first failing line, not all failures

Key takeaway: A configurable tolerance percentage makes the guard realistic for physical procurement where minor quantity deviations are expected and acceptable.


Example 33: PriceMatchGuard

The price guard compares PO unit prices against the invoice's line-level amounts. Price mismatches often indicate renegotiated terms that require fresh approval, so the guard is kept separate from the quantity guard — they can be applied independently.

classDiagram
    class PriceMatchGuard {
        -tolerance_pct: f64
        +check(po, grn, invoice) Result~MatchError~
    }
    class MatchGuard {
        <<trait>>
    }
    MatchGuard <|.. PriceMatchGuard
// => PriceMatchGuard is structurally identical to QuantityMatchGuard but checks price
// => Keeping them separate allows applying only price matching in some workflows
pub struct PriceMatchGuard {
    pub tolerance_pct: f64,
}
 
impl MatchGuard for PriceMatchGuard {
    fn check(
        &self,
        po:      &PurchaseOrder,
        grn:     &GoodsReceiptNote,
        invoice: &Invoice<InvoiceSubmitted>,
    ) -> Result<(), MatchError> {
        // => grn is passed for interface compatibility but price guard ignores it
        // => Price is compared between PO line unit price and invoice line amount
        for line in &po.line_items {
            // => Find the matching invoice line by PO line ID
            let inv_line = invoice
                .line_items
                .iter()
                .find(|l| l.po_line_id == line.id);
 
            let inv_price = inv_line
                .map(|l| l.unit_price)
                .unwrap_or(0.0);
 
            let po_price = line.unit_price;
            // => Price deviation computed as fraction of PO price
            let deviation = (inv_price - po_price).abs() / po_price;
 
            if deviation > self.tolerance_pct {
                return Err(MatchError::PriceMismatch {
                    po_line_id: line.id.clone(),
                    po_price,
                    invoice_price: inv_price,
                });
            }
        }
        Ok(())
    }
}
// => Price tolerance is configurable per organisation policy (e.g. 2% for FMCG, 0% for finance)

Key takeaway: Separating price and quantity guards enables configuring match rules per procurement category — consumables may accept 5% quantity variance but zero price variance.


Example 34: CompositeGuard — All Guards Must Pass

The composite guard aggregates a list of guards and runs them in sequence. It is itself a MatchGuard, enabling recursive composition — a composite of composites is valid. The fail-fast variant returns on the first error; a collect-all variant returns all failures for display to the user.

graph TD
    A["CompositeGuard.check()"]:::blue --> B["QuantityMatchGuard.check()"]:::teal
    B -->|"Ok()"| C["PriceMatchGuard.check()"]:::orange
    C -->|"Ok()"| D["Ok — proceed to approve"]:::teal
    B -->|"Err(QuantityMismatch)"| E["Return Err immediately"]:::brown
    C -->|"Err(PriceMismatch)"| E
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef brown fill:#CA9161,stroke:#000000,color:#FFFFFF,stroke-width:2px
// => CompositeGuard holds an owned Vec of boxed trait objects
// => Box<dyn MatchGuard> enables heterogeneous guard types in the same Vec
pub struct CompositeGuard {
    guards: Vec<Box<dyn MatchGuard>>,
}
 
impl CompositeGuard {
    pub fn new() -> Self {
        CompositeGuard { guards: Vec::new() }
    }
 
    // => add() takes self by value and returns self — builder/fluent pattern
    // => allows CompositeGuard::new().add(qty_guard).add(price_guard)
    pub fn add(mut self, guard: impl MatchGuard + 'static) -> Self {
        // => Box::new allocates on the heap; dyn dispatch at runtime
        self.guards.push(Box::new(guard));
        self
    }
}
 
impl MatchGuard for CompositeGuard {
    fn check(
        &self,
        po:      &PurchaseOrder,
        grn:     &GoodsReceiptNote,
        invoice: &Invoice<InvoiceSubmitted>,
    ) -> Result<(), MatchError> {
        for g in &self.guards {
            // => ? operator: if g.check() returns Err, propagate immediately
            // => Fail-fast: first failure stops further guard evaluation
            g.check(po, grn, invoice)?;
        }
        // => All guards passed
        Ok(())
    }
}
// => CompositeGuard itself implements MatchGuard — composites can nest

Key takeaway: The composite pattern makes guard chains open for extension — add a new tolerance rule by writing a new guard and inserting it into the composite, with no changes to existing code.


Example 35: MatchError Types

Structured error types carry the context needed to display actionable messages to the supplier. Generic error strings force the UI layer to parse strings; typed variants allow the UI to render a table of mismatches with line-level detail.

classDiagram
    class MatchError {
        <<enum>>
        QuantityMismatch
        PriceMismatch
    }
    class QuantityMismatch {
        po_line_id: String
        expected: f64
        actual: f64
    }
    class PriceMismatch {
        po_line_id: String
        po_price: f64
        invoice_price: f64
    }
    MatchError *-- QuantityMismatch
    MatchError *-- PriceMismatch
// => thiserror::Error derives Display and std::error::Error automatically
// => #[error("...")] attribute defines the Display format per variant
#[derive(Debug, thiserror::Error)]
pub enum MatchError {
    // => QuantityMismatch carries exactly the data the supplier needs to correct the invoice
    #[error(
        "quantity mismatch on line {po_line_id}: \
         expected {expected:.2}, received {actual:.2}"
    )]
    QuantityMismatch {
        po_line_id: String,
        expected:   f64,
        // => actual is what the GRN recorded — independent of what the invoice says
        actual:     f64,
    },
 
    // => PriceMismatch distinguishes PO price from what the supplier charged
    #[error(
        "price mismatch on line {po_line_id}: \
         PO price {po_price:.4}, invoice price {invoice_price:.4}"
    )]
    PriceMismatch {
        po_line_id:    String,
        po_price:      f64,
        // => invoice_price is what the supplier charged — subject to renegotiation
        invoice_price: f64,
    },
}
// => Structured error variants enable pattern matching in tests and error handling

Key takeaway: Typed error variants let both the supplier portal and the finance dashboard display precise, actionable mismatch details without parsing error strings.


Example 36: Guard Execution Before State Transition

The approve transition is gated behind the guard: the guard runs first, and only if it passes does the state change occur. This atomic check-and-transition means there is no window in which an invoice can appear approved but unverified.

sequenceDiagram
    participant Caller
    participant Guard
    participant Invoice
 
    Caller->>Guard: check(po, grn, invoice)
    alt guard passes
        Guard-->>Caller: Ok(())
        Caller->>Invoice: approve(approved_by)
        Invoice-->>Caller: Invoice[InvoiceApproved]
    else guard fails
        Guard-->>Caller: Err(MatchError)
        Caller-->>Caller: return Err (no state change)
    end
// => approve_after_match combines guard check and state transition atomically
// => Returns Result: Ok carries the new InvoiceApproved, Err carries MatchError
impl Invoice<InvoiceSubmitted> {
    pub fn approve_after_match(
        self,
        guard:       &impl MatchGuard,
        po:          &PurchaseOrder,
        grn:         &GoodsReceiptNote,
        approved_by: String,
    ) -> Result<Invoice<InvoiceApproved>, MatchError> {
        // => guard.check() returns Err(MatchError) if any dimension mismatches
        // => ? propagates the error — self is NOT consumed if guard fails
        guard.check(po, grn, &self)?;
 
        // => Guard passed — consume self and return the new approved type
        Ok(Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: Some(approved_by),
            _state:      PhantomData,
        })
    }
}
// => If guard.check() fails, self is still valid — caller can log and retry or reject
// => Rust ownership: ? does not consume self when it short-circuits

Key takeaway: Running the guard inside the approve method — not before calling it — guarantees no approved invoice exists without passing all match checks.


Cross-Machine Coordination (Examples 37–42)

Example 37: POInvoiceCoordinator

The coordinator is an application service that holds references to the PO, Invoice, and GRN repositories and orchestrates cross-aggregate workflows. It contains no domain state of its own — its job is to fetch aggregates, run guards, and persist results. Dependencies are injected as interfaces/traits, enabling substitution for testing.

classDiagram
    class POInvoiceCoordinator {
        -po_repo: Arc~dyn PORepository~
        -invoice_repo: Arc~dyn InvoiceRepository~
        -grn_repo: Arc~dyn GRNRepository~
        -guard: Arc~dyn MatchGuard~
        +new(po_repo, invoice_repo, grn_repo, guard) Self
        +handle_grn_received(event) Result
    }
    class PORepository {
        <<trait>>
        +find_by_id(id) Option~PurchaseOrder~
    }
    class InvoiceRepository {
        <<trait>>
        +find_submitted_by_po(po_id) Vec~Invoice~
        +save_approved(invoice) void
        +mark_match_failed(id, err) void
    }
    class GRNRepository {
        <<trait>>
        +find_by_id(id) Option~GoodsReceiptNote~
    }
    POInvoiceCoordinator --> PORepository
    POInvoiceCoordinator --> InvoiceRepository
    POInvoiceCoordinator --> GRNRepository
    POInvoiceCoordinator --> MatchGuard
use std::sync::Arc;
 
// => Arc<dyn Trait + Send + Sync> is the idiomatic shared reference for async Rust
// => Arc = atomic reference count; Send + Sync required for multi-threaded runtimes (Tokio)
pub struct POInvoiceCoordinator {
    // => po_repo behind Arc<dyn> — injectable, testable, shareable across tasks
    po_repo:      Arc<dyn PORepository + Send + Sync>,
    invoice_repo: Arc<dyn InvoiceRepository + Send + Sync>,
    grn_repo:     Arc<dyn GRNRepository + Send + Sync>,
    // => guard is shared — same guard instance for all invocations, no state
    guard:        Arc<dyn MatchGuard + Send + Sync>,
}
 
impl POInvoiceCoordinator {
    // => Constructor accepts owned Arc values — caller controls lifetime
    pub fn new(
        po_repo:      Arc<dyn PORepository + Send + Sync>,
        invoice_repo: Arc<dyn InvoiceRepository + Send + Sync>,
        grn_repo:     Arc<dyn GRNRepository + Send + Sync>,
        guard:        Arc<dyn MatchGuard + Send + Sync>,
    ) -> Self {
        POInvoiceCoordinator { po_repo, invoice_repo, grn_repo, guard }
    }
}
// => Coordinator is application service — no domain logic, only orchestration
// => Depends on abstractions (traits), not concrete repositories

Key takeaway: The coordinator pattern separates orchestration from domain logic — each repository and guard is independently testable and replaceable.


Example 38: GRN Received Event Triggers Invoice Check

When a Goods Receipt Note arrives, the coordinator fetches all submitted invoices for that PO and attempts to approve each one. Invoices that pass the guard transition to Approved; invoices that fail are explicitly marked as having failed the match — they are not silently ignored.

sequenceDiagram
    participant Event
    participant Coordinator
    participant PORepo
    participant GRNRepo
    participant InvoiceRepo
    participant Guard
 
    Event->>Coordinator: GRNReceived{po_id, grn_id}
    Coordinator->>PORepo: find_by_id(po_id)
    PORepo-->>Coordinator: PurchaseOrder
    Coordinator->>GRNRepo: find_by_id(grn_id)
    GRNRepo-->>Coordinator: GoodsReceiptNote
    Coordinator->>InvoiceRepo: find_submitted_by_po(po_id)
    InvoiceRepo-->>Coordinator: Vec[Invoice]
    loop for each invoice
        Coordinator->>Guard: check(po, grn, invoice)
        alt passes
            Guard-->>Coordinator: Ok
            Coordinator->>InvoiceRepo: save_approved(invoice)
        else fails
            Guard-->>Coordinator: Err(MatchError)
            Coordinator->>InvoiceRepo: mark_match_failed(id, err)
        end
    end
// => GRNReceived is a domain event published when a shipment is confirmed received
#[derive(Debug)]
pub struct GRNReceived {
    pub po_id:  String,
    pub grn_id: String,
}
 
impl POInvoiceCoordinator {
    // => async fn — repository calls are I/O and must not block the executor
    pub async fn handle_grn_received(
        &self,
        event: &GRNReceived,
    ) -> Result<(), CoordinatorError> {
        // => Fetch PO — required to run the guard
        let po = self.po_repo
            .find_by_id(&event.po_id).await?
            .ok_or(CoordinatorError::PONotFound)?;
 
        // => Fetch GRN — the receipt record that triggered this event
        let grn = self.grn_repo
            .find_by_id(&event.grn_id).await?
            .ok_or(CoordinatorError::GRNNotFound)?;
 
        // => Find all invoices in Submitted state for this PO
        // => Multiple invoices from the same supplier can exist for one PO
        let invoices = self.invoice_repo
            .find_submitted_by_po(&event.po_id).await?;
 
        for inv in invoices {
            match inv.approve_after_match(self.guard.as_ref(), &po, &grn, "system".into()) {
                Ok(approved) => {
                    // => Persist the new InvoiceApproved value
                    self.invoice_repo.save_approved(approved).await?;
                }
                Err(e) => {
                    // => Mark the invoice as failed — supplier must correct and resubmit
                    // => inv is still in InvoiceSubmitted state; it was not consumed
                    self.invoice_repo.mark_match_failed(inv.id.clone(), e).await?;
                }
            }
        }
        Ok(())
    }
}
// => Event-driven coordination avoids polling — triggered by domain event, not scheduler

Key takeaway: Processing every submitted invoice independently on each GRN event makes the coordinator resilient — one invoice's match failure does not prevent others from being approved.


Example 39: Dual-FSM Coordination State

The coordinator tracks its own state separate from both the PO and Invoice aggregates. This coordination state captures whether a GRN has been received and whether matching has completed, enabling crash recovery and human review of stuck records.

stateDiagram-v2
    [*] --> WaitingForGRN: create coordination record
    WaitingForGRN --> MatchPending: grn_received
    MatchPending --> MatchComplete: match_passed
    MatchPending --> MatchFailed: match_failed
    MatchFailed --> [*]: human review required
    MatchComplete --> [*]
// => CoordinationState is independent of both PO and Invoice state machines
// => It models the lifecycle of the matching process itself
#[derive(Debug, Clone)]
pub enum CoordinationState {
    // => WaitingForGRN: PO received, invoice submitted, GRN not yet confirmed
    WaitingForGRN,
    // => MatchPending: GRN received, matching in progress or queued
    MatchPending { grn_id: String },
    // => MatchComplete: all submitted invoices approved successfully
    MatchComplete { approved_invoice_ids: Vec<String> },
    // => MatchFailed: one or more invoices failed the guard — human review needed
    MatchFailed { errors: Vec<String> },
}
 
// => MatchCoordinationRecord persists coordination state to the database
#[derive(Debug, Clone)]
pub struct MatchCoordinationRecord {
    pub po_id:      String,
    // => state enum stored as JSON or individual columns in DB
    pub state:      CoordinationState,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}
// => Separate persistence record for coordination — not mutating PO or Invoice tables
// => Enables querying "which POs are stuck in MatchFailed?" without joining aggregates

Key takeaway: A dedicated coordination record decouples the matching process state from both the PO and Invoice lifecycles — it is the source of truth for the matching workflow.


Example 40: Channel-Based Event Dispatch (Go)

Go channels provide a lightweight in-process event bus. A coordinator registers event handlers, and a background goroutine drains the channel and routes events to the correct handler. This pattern keeps the sender synchronous while processing asynchronous.

sequenceDiagram
    participant Sender
    participant Channel
    participant Dispatcher
    participant Handler
 
    Sender->>Channel: ch <- GRNReceivedEvent
    Note over Channel: buffered, non-blocking send
    Dispatcher->>Channel: drain loop (goroutine)
    Channel-->>Dispatcher: GRNReceivedEvent
    Dispatcher->>Handler: HandleGRNReceived(event)
    Handler-->>Dispatcher: nil / error
// => Rust equivalent uses Tokio's mpsc channel for async event dispatch
use tokio::sync::mpsc;
 
// => DomainEvent is a sum type for all publishable events
#[derive(Debug)]
pub enum DomainEvent {
    GRNReceived(GRNReceived),
    // => Additional variants added as domain grows
}
 
pub struct EventBus {
    // => tx is the sender half — cloned per producer
    tx: mpsc::Sender<DomainEvent>,
}
 
impl EventBus {
    // => Spawn a background task that drains the receiver and dispatches events
    pub fn new(coordinator: Arc<POInvoiceCoordinator>) -> Self {
        let (tx, mut rx) = mpsc::channel::<DomainEvent>(100);
        tokio::spawn(async move {
            while let Some(event) = rx.recv().await {
                match event {
                    DomainEvent::GRNReceived(e) => {
                        // => Each event processed independently; errors logged, not propagated
                        if let Err(err) = coordinator.handle_grn_received(&e).await {
                            eprintln!("coordinator error: {err}");
                        }
                    }
                }
            }
        });
        EventBus { tx }
    }
 
    // => publish is non-blocking if channel buffer is not full
    pub async fn publish(&self, event: DomainEvent) {
        let _ = self.tx.send(event).await;
    }
}
// => mpsc::channel(100) — 100-element buffer; back-pressure if producer outpaces consumer

Key takeaway: A buffered channel decouples event producers from consumers — the sender returns immediately and the goroutine processes events at its own pace.


Example 41: Rollback on Match Failure

When a guard check fails, the invoice must remain in its current state — not silently discarded, not moved to a different state. The coordinator explicitly marks the invoice as having failed the match, preserving the audit trail and enabling the supplier to correct and resubmit.

sequenceDiagram
    participant Coordinator
    participant Guard
    participant Invoice
    participant Repo
    participant Supplier
 
    Coordinator->>Guard: check(po, grn, invoice)
    Guard-->>Coordinator: Err(QuantityMismatch)
    Note over Invoice: state remains Submitted
    Coordinator->>Repo: mark_match_failed(invoice_id, QuantityMismatch)
    Repo-->>Coordinator: Ok
    Coordinator->>Supplier: notify_match_failure(invoice_id, reason)
// => When approve_after_match returns Err, the invoice is still in InvoiceSubmitted
// => Rust ownership: ? inside approve_after_match returns before consuming self on error
// => The Err branch receives: inv (still Invoice<InvoiceSubmitted>), e (MatchError)
 
impl POInvoiceCoordinator {
    pub async fn process_single_invoice(
        &self,
        inv: Invoice<InvoiceSubmitted>,
        po:  &PurchaseOrder,
        grn: &GoodsReceiptNote,
    ) -> Result<(), CoordinatorError> {
        match inv.approve_after_match(self.guard.as_ref(), po, grn, "system".into()) {
            Ok(approved) => {
                // => Invoice consumed, new Approved value persisted
                self.invoice_repo.save_approved(approved).await?;
            }
            Err(match_err) => {
                // => inv was NOT consumed — guard returned Err before consuming self
                // => Record failure with full MatchError detail for supplier notification
                let reason = match_err.to_string();
                self.invoice_repo
                    .mark_match_failed(inv.id.clone(), match_err).await?;
                // => Notify supplier so they can correct the invoice
                // => PO stays in its current state — match failure does not cancel the PO
                self.notify_supplier_match_failed(&inv.supplier_id, &inv.id, reason).await?;
            }
        }
        Ok(())
    }
}
// => Failed invoice remains in Submitted state — supplier corrects and resubmits
// => At-least-once processing: idempotent mark_match_failed handles duplicate events

Key takeaway: Explicit failure marking preserves the audit trail and gives suppliers actionable feedback — silent discard would leave the invoice in an unknown state.


Example 42: Coordinator as a State Machine

The coordinator's own lifecycle — waiting for GRN, matching, complete, or failed — is itself a state machine. Using looplab/fsm for the coordination record makes the transitions observable and auditable.

stateDiagram-v2
    [*] --> waiting_grn: create
    waiting_grn --> match_pending: grn_received
    match_pending --> match_complete: match_passed
    match_pending --> match_failed: match_failed
    match_complete --> [*]
    match_failed --> [*]: human_resolved
// => Rust: manual state machine for the coordination record using match
// => petgraph or a state-machine crate could be used; manual match is clear for 4 states
impl MatchCoordinationRecord {
    // => Transition WaitingForGRN → MatchPending on GRN receipt
    pub fn grn_received(self, grn_id: String) -> Result<Self, String> {
        match self.state {
            CoordinationState::WaitingForGRN => Ok(Self {
                state:      CoordinationState::MatchPending { grn_id },
                updated_at: Utc::now(),
                ..self
            }),
            // => Any other state is an invalid transition — return descriptive error
            other => Err(format!("cannot apply grn_received in state {:?}", other)),
        }
    }
 
    // => Transition MatchPending → MatchComplete when all invoices approved
    pub fn match_passed(self, approved_ids: Vec<String>) -> Result<Self, String> {
        match self.state {
            CoordinationState::MatchPending { .. } => Ok(Self {
                state:      CoordinationState::MatchComplete { approved_invoice_ids: approved_ids },
                updated_at: Utc::now(),
                ..self
            }),
            other => Err(format!("cannot apply match_passed in state {:?}", other)),
        }
    }
}
// => Struct update syntax `..self` copies remaining fields without naming each one

Key takeaway: Treating the coordinator's lifecycle as an explicit state machine makes stuck records observable — a "match_failed" coordination record is a work item for the AP team.


Phantom Types for Verification State (Examples 43–49)

Example 43: Two-Dimensional Phantom Types — State + Verification

A second phantom type parameter encodes whether an invoice has passed the three-way match, independently of its lifecycle state. This two-dimensional approach lets the type system enforce that only verified invoices can be approved — without any runtime boolean check in the approve method.

classDiagram
    class Invoice~S_V~ {
        +id: String
        +po_id: String
        +amount: Decimal
        -_state: PhantomData~S~
        -_verified: PhantomData~V~
    }
    class Unverified {
        <<marker>>
    }
    class Verified {
        <<marker>>
    }
    Invoice~S_V~ ..> Unverified : V=Unverified
    Invoice~S_V~ ..> Verified : V=Verified
// => Two marker types encode verification status at compile time
pub struct Unverified;
// => Verified: the invoice has passed all three-way match guards
pub struct Verified;
 
// => Invoice now has TWO phantom type parameters: S for state, V for verification
// => Both are zero-size — no runtime memory cost
#[derive(Debug, Clone)]
pub struct Invoice<S, V> {
    pub id:          String,
    pub po_id:       String,
    pub supplier_id: String,
    pub amount:      Decimal,
    pub created_at:  DateTime<Utc>,
    pub approved_by: Option<String>,
    // => _state tracks lifecycle (Draft, Submitted, Approved, ...)
    _state:          PhantomData<S>,
    // => _verified tracks match status (Unverified, Verified) independently
    // => These two dimensions are orthogonal — state can change without changing verification
    _verified:       PhantomData<V>,
}
// => All newly created invoices start as Invoice<InvoiceDraft, Unverified>
// => The compiler enforces that V = Verified before approve() can be called

Key takeaway: Two phantom type parameters create a two-dimensional type-safe grid — the compiler tracks both lifecycle position and verification status simultaneously.


Example 44: Unverified → Verified Transition

The verify_match method runs the guard and, if it passes, returns an invoice with the Verified phantom type. Only the second type parameter changes — the state remains InvoiceSubmitted. This is a pure type-level transition with no data mutation.

stateDiagram-v2
    state "Invoice[Submitted, Unverified]" as SU
    state "Invoice[Submitted, Verified]" as SV
    SU --> SV: verify_match (guard passes)
    SU --> SU: verify_match (guard fails — no transition)
// => verify_match is defined on Invoice<InvoiceSubmitted, Unverified> specifically
// => It cannot be called on Draft or Approved invoices — wrong S parameter
impl Invoice<InvoiceSubmitted, Unverified> {
    // => verify_match consumes self and returns either Verified or MatchError
    // => The state parameter S remains InvoiceSubmitted; only V changes
    pub fn verify_match(
        self,
        guard: &impl MatchGuard,
        po:    &PurchaseOrder,
        grn:   &GoodsReceiptNote,
    ) -> Result<Invoice<InvoiceSubmitted, Verified>, MatchError> {
        // => Run the guard — if it fails, self is NOT consumed (? returns before move)
        guard.check(po, grn, &self)?;
 
        // => Guard passed: reconstruct with V = Verified
        Ok(Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: self.approved_by,
            _state:      PhantomData,   // S = InvoiceSubmitted, unchanged
            // => PhantomData::<Verified> — this is the only field that semantically changes
            _verified:   PhantomData,   // V = Verified, upgraded from Unverified
        })
    }
}
// => After verify_match, the caller holds Invoice<InvoiceSubmitted, Verified>
// => The Unverified invoice is gone — compile error if caller tries to use it again

Key takeaway: Separating verification from approval into two distinct type-level transitions makes the sequence verify_match → approve explicit and compiler-enforced in Rust.


Example 45: Only Verified Invoices Can Be Approved

The approve method exists only on Invoice<InvoiceSubmitted, Verified>. There is no approve on Invoice<InvoiceSubmitted, Unverified>. In Rust, attempting to call approve on an unverified invoice is a compile error — not a runtime panic or a returned error, but a failure to build.

classDiagram
    class `Invoice[Submitted, Verified]` {
        +approve(approved_by) Invoice[Approved, Verified]
    }
    class `Invoice[Submitted, Unverified]` {
        +verify_match(guard, po, grn) Result
        %% no approve method
    }
// => approve is defined ONLY on Invoice<InvoiceSubmitted, Verified>
// => Invoice<InvoiceSubmitted, Unverified> has NO approve method — it simply does not exist
impl Invoice<InvoiceSubmitted, Verified> {
    // => approve(self, approved_by): consumes Verified Submitted, returns Verified Approved
    // => V = Verified is preserved through the transition — the approval carries verification
    pub fn approve(self, approved_by: String) -> Invoice<InvoiceApproved, Verified> {
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            // => approved_by recorded — S changes from Submitted to Approved
            approved_by: Some(approved_by),
            _state:      PhantomData,   // S = InvoiceApproved
            _verified:   PhantomData,   // V = Verified, unchanged
        }
    }
}
 
// => This function WILL NOT COMPILE — unverified.approve() does not exist
// fn bad_example(unverified: Invoice<InvoiceSubmitted, Unverified>) {
//     let _ = unverified.approve("mgr".into()); // => compile error: no method named `approve`
// }
// => The compile error is the guarantee — no runtime check needed in approve()

Key takeaway: The absence of a method is a stronger guarantee than a method that returns an error — the compiler rejects the call before the program exists, let alone runs.


Example 46: Only Approved+Verified Invoices Can Be Paid

Payment requires both the Approved lifecycle state and the Verified match status. In Rust these two requirements are expressed in the single type Invoice<InvoiceApproved, Verified> — no AND condition in code, just the type signature.

stateDiagram-v2
    state "Invoice[Draft, Unverified]" as DU
    state "Invoice[Submitted, Unverified]" as SU
    state "Invoice[Submitted, Verified]" as SV
    state "Invoice[Approved, Verified]" as AV
    state "Invoice[Paid, Verified]" as PV
 
    DU --> SU: submit
    SU --> SV: verify_match
    SV --> AV: approve
    AV --> PV: pay
// => pay is defined ONLY on Invoice<InvoiceApproved, Verified>
// => Neither Invoice<InvoiceApproved, Unverified> nor Invoice<InvoiceSubmitted, Verified> can pay
impl Invoice<InvoiceApproved, Verified> {
    pub fn pay(self, payment_ref: PaymentReference) -> Invoice<InvoicePaid, Verified> {
        // => Both S = Approved AND V = Verified are required — expressed as a single type
        // => No if-statement needed; the compiler already enforces the conjunction
        Invoice {
            id:          self.id,
            po_id:       self.po_id,
            supplier_id: self.supplier_id,
            amount:      self.amount,
            created_at:  self.created_at,
            approved_by: self.approved_by,
            // => InvoicePaid is the terminal lifecycle state
            _state:      PhantomData,   // S = InvoicePaid
            // => Verified status preserved — payment record carries verification evidence
            _verified:   PhantomData,   // V = Verified
        }
    }
}
// => The full happy path type chain: Invoice<Draft,U> → <Submitted,U> → <Submitted,V> → <Approved,V> → <Paid,V>
// => Each arrow is a consuming method call; each step is a compile-time verified transition

Key takeaway: Two-dimensional phantom types express conjunctive preconditions as a single type — the compiler checks both dimensions simultaneously with no runtime overhead.


Example 47: Guard Chain as Composed Trait Objects

A builder API for constructing guard chains makes the composition readable and discoverable. The fluent add() method returns self, enabling chained construction. The resulting GuardChain is itself a MatchGuard, so it can be stored behind Arc<dyn MatchGuard> and injected into the coordinator.

graph TD
    A["GuardChain::new()"]:::blue
    A -->|".add(QuantityMatchGuard)"| B["GuardChain [qty]"]:::teal
    B -->|".add(PriceMatchGuard)"| C["GuardChain [qty, price]"]:::orange
    C -->|"dyn MatchGuard"| D["POInvoiceCoordinator.guard"]:::purple
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
// => GuardChain is the builder version of CompositeGuard with a fluent API
pub struct GuardChain {
    guards: Vec<Box<dyn MatchGuard + Send + Sync>>,
}
 
impl GuardChain {
    pub fn new() -> Self {
        GuardChain { guards: Vec::new() }
    }
 
    // => add() takes self by value — consuming builder pattern
    // => Returns Self so calls can be chained: GuardChain::new().add(a).add(b)
    pub fn add(mut self, guard: impl MatchGuard + Send + Sync + 'static) -> Self {
        // => Box::new + dyn enables heterogeneous guards in the same Vec
        // => 'static lifetime bound required for Arc<dyn ...> storage
        self.guards.push(Box::new(guard));
        self
    }
}
 
// => GuardChain implements MatchGuard — it can be used wherever MatchGuard is expected
impl MatchGuard for GuardChain {
    fn check(
        &self,
        po:      &PurchaseOrder,
        grn:     &GoodsReceiptNote,
        invoice: &Invoice<InvoiceSubmitted, Unverified>,
    ) -> Result<(), MatchError> {
        for g in &self.guards {
            // => ? short-circuits on first failure — fail-fast semantics
            g.check(po, grn, invoice)?;
        }
        Ok(())
    }
}
// => Usage: Arc::new(GuardChain::new().add(QuantityMatchGuard{tolerance_pct: 0.05}).add(PriceMatchGuard{tolerance_pct: 0.02}))

Key takeaway: A fluent builder for guard chains makes the composition visible at the construction site — readers can see exactly which guards are active without reading the coordinator implementation.


Example 48: Testing FSM Transitions

Tests for the two-dimensional phantom type approach have a unique property: attempting to approve an unverified invoice is a compile error, so the test for that case is a // => compile error comment rather than an assertion. Rust tests validate types at build time; Go tests validate behaviour at runtime.

graph TD
    A["Test: draft_can_submit"]:::blue --> B["Invoice::create → .submit()"]:::teal
    C["Test: submitted_can_verify_then_approve"]:::blue --> D["create → submit → verify_match → approve"]:::teal
    E["Test: unverified_cannot_approve"]:::blue --> F["compile error in Rust / ErrNotVerified in Go"]:::orange
 
    classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
#[cfg(test)]
mod tests {
    use super::*;
 
    // => Test 1: draft invoice can be submitted
    #[test]
    fn draft_invoice_can_submit() {
        // => Invoice::create returns Invoice<InvoiceDraft, Unverified>
        let inv = Invoice::<InvoiceDraft, Unverified>::create(
            "inv-001".into(), "po-001".into(), "sup-001".into(), Decimal::from(1000),
        );
        // => .submit() returns Invoice<InvoiceSubmitted, Unverified> — compile-time verified
        let submitted = inv.submit();
        assert_eq!(submitted.id, "inv-001");
        // => submitted.amount should equal the original creation amount
        assert_eq!(submitted.amount, Decimal::from(1000));
    }
 
    // => Test 2: submitted invoice verified by always-pass guard can be approved
    #[test]
    fn verified_invoice_can_be_approved() {
        struct AlwaysPassGuard;
        impl MatchGuard for AlwaysPassGuard {
            fn check(&self, _po: &PurchaseOrder, _grn: &GoodsReceiptNote, _inv: &Invoice<InvoiceSubmitted, Unverified>) -> Result<(), MatchError> {
                // => Always returns Ok — test double for guard
                Ok(())
            }
        }
        let inv = Invoice::<InvoiceDraft, Unverified>::create("inv-002".into(), "po-001".into(), "sup-001".into(), Decimal::from(500));
        let submitted = inv.submit();
        // => verify_match transitions V from Unverified to Verified
        let verified = submitted.verify_match(&AlwaysPassGuard, &mock_po(), &mock_grn()).unwrap();
        // => approve transitions S from Submitted to Approved; V stays Verified
        let approved = verified.approve("finance-mgr".into());
        assert_eq!(approved.approved_by, Some("finance-mgr".into()));
    }
 
    // => Test 3: this code does NOT compile — demonstrating the compile-time guarantee
    // fn unverified_cannot_approve() {
    //     let inv = Invoice::<InvoiceDraft, Unverified>::create(...);
    //     let submitted = inv.submit();
    //     // => compile error: no method named `approve` found for Invoice<InvoiceSubmitted, Unverified>
    //     let _ = submitted.approve("mgr".into());
    // }
}

Key takeaway: The compile-error test comment in Rust is not documentation — it is a statement that the code will be rejected by the type checker, which is itself a form of test.


Example 49: Integration — Full Invoice Lifecycle

The full happy path exercises every transition: create → submit → verify_match → approve → pay. An integration test uses in-memory repositories and an always-pass guard to validate the full orchestration path through the coordinator.

sequenceDiagram
    participant Test
    participant Factory
    participant Invoice
    participant Guard
    participant Coordinator
    participant InvoiceRepo
 
    Test->>Factory: create(id, po_id, supplier_id, amount)
    Factory-->>Invoice: Invoice[Draft, Unverified]
    Test->>Invoice: .submit()
    Invoice-->>Invoice: Invoice[Submitted, Unverified]
    Test->>Guard: AlwaysPassGuard
    Test->>Invoice: .verify_match(guard, po, grn)
    Invoice-->>Invoice: Invoice[Submitted, Verified]
    Test->>Invoice: .approve("finance-mgr")
    Invoice-->>Invoice: Invoice[Approved, Verified]
    Test->>Invoice: .pay(payment_ref)
    Invoice-->>Invoice: Invoice[Paid, Verified]
    Test->>InvoiceRepo: assert saved as Paid
#[cfg(test)]
mod integration {
    use super::*;
 
    // => Full happy-path integration test: every state transition in sequence
    #[test]
    fn full_invoice_lifecycle_happy_path() {
        // => Step 1: Create — Invoice<InvoiceDraft, Unverified>
        let draft = Invoice::<InvoiceDraft, Unverified>::create(
            "inv-100".into(),
            "po-001".into(),
            "sup-001".into(),
            Decimal::from(5000),
        );
        assert_eq!(draft.id, "inv-100");
 
        // => Step 2: Submit — Invoice<InvoiceSubmitted, Unverified>
        let submitted = draft.submit();
        // => draft is consumed; using `draft` here would be a compile error
 
        // => Step 3: Verify — Invoice<InvoiceSubmitted, Verified>
        // => AlwaysPassGuard: test double that unconditionally returns Ok
        let verified = submitted
            .verify_match(&AlwaysPassGuard, &mock_po(), &mock_grn())
            .expect("guard should pass for mock data");
 
        // => Step 4: Approve — Invoice<InvoiceApproved, Verified>
        let approved = verified.approve("finance-director".into());
        assert_eq!(approved.approved_by, Some("finance-director".into()));
 
        // => Step 5: Pay — Invoice<InvoicePaid, Verified> — terminal state
        let paid = approved.pay(PaymentReference("TXN-20260524-001".into()));
        assert_eq!(paid.id, "inv-100");
        assert_eq!(paid.amount, Decimal::from(5000));
        // => No further methods on Invoice<InvoicePaid, Verified> — terminal enforced by absence
    }
}
// => Property-based testing (proptest crate) could fuzz guard inputs across this path
// => Each state variable (draft, submitted, verified, approved, paid) is a distinct type

Key takeaway: The full lifecycle integration test is the executable specification of the Invoice FSM — it verifies every transition and the coordinator's orchestration in a single readable sequence.

Last updated May 23, 2026

Command Palette

Search for a command to run...