Workflow · Workflow

Acumatica Workflow — State Transitions and Conditions

How Acumatica workflow transitions work, how to write the conditions that gate them, and the patterns that keep your workflow logic in one place.

John Kihiu12 min read

State transitions look simple in the Workflow screen's diagram view — a box, an arrow, another box — until you have to debug why a document is stuck two states behind where the user swears they left it. This post is about the mechanics under that diagram: how transitions actually fire, how conditions get evaluated, and the ordering rules that decide which of several eligible transitions wins when more than one condition is technically true.

Anatomy of a transition

A transition is defined from a state (or a group of states, via AddGroupFrom<TState>) and specifies a target state, a trigger, and zero or more conditions:

C#
transitions.AddGroupFrom<APInvoice.status.Balanced>(g => g
    .Add(t => t
        .To<APInvoice.status.PendingApproval>()
        .IsTriggeredOn(a => a.Actions.Release)
        .When(APInvoice.curyDocBal.IsGreaterEqual(2000m)
              & APInvoice.approved.IsEqual(false)))
    .Add(t => t
        .To<APInvoice.status.Open>()
        .IsTriggeredOn(a => a.Actions.Release)
        .When(APInvoice.curyDocBal.IsLess(2000m))));

Two transitions share a trigger (Release) and a source state, but diverge on condition. The engine evaluates conditions top to bottom in declaration order and takes the first one whose .When() resolves true — it is not an exhaustive match, it is a first-match cascade, exactly like a chain of if / else if. This trips people up constantly: adding a new higher-priority rule at the bottom of the list silently never fires if an earlier, broader condition already matches everything.

Order is the whole bug, most of the time

If a transition "isn't firing," the condition is almost never wrong in isolation — it's shadowed by an earlier transition in the same group whose condition is too permissive. Read the group top to bottom before touching the condition you think is broken. The Workflow screen's diagram view numbers transitions in evaluation order; use it before reading code.

Conditions evaluate against the cache, not the database

A .When() clause reads the in-memory row as it stands at the moment the transition is being considered — after field updates from the current request have been applied to the cache, but before (or during, depending on where in the save pipeline you are) that row is persisted. This matters for two common mistakes: conditioning on a field that is computed in a FieldUpdated handler that hasn't run yet at the point the transition evaluates (order your handlers so calculated fields settle before the action that triggers the transition), and conditioning on aggregate data that lives in a child table — .When() cannot join, so if the rule is "any line has a negative quantity," compute that into a header-level unbound flag in code and condition on the flag.

Composing conditions

Conditions compose with & (and) and | (or), and negation via .IsNotEqual/.IsEqual(false) rather than a general Not() wrapper — plan your boolean logic in De Morgan-friendly terms up front, because retrofitting a NOT over a compound OR condition usually means restructuring the whole expression tree rather than bolting on a negation.

C#
.When(SOOrder.orderType.IsEqual("SO")
      & (SOOrder.customerLocationID.IsNotNull()
         | SOOrder.oneTimeAddress.IsEqual(true)))

Field states are not conditions — they're consequences

People conflate .When() (which decides whether a transition happens) with .WithFieldStates() (which decides what the screen looks like once you're in a state, independent of how you arrived). A field disabled via WithFieldStates on the PendingApproval state stays disabled regardless of which transition led there — that's usually what you want (a locked document is locked no matter the path), but if you need path-dependent field behavior you need distinguishable intermediate states, not a single shared one with divergent transition history.

A concrete debugging technique

When a transition silently fails to fire, I don't reach for breakpoints first — I open the Workflow screen for that screen ID, switch to the state the document is currently in, and read the outbound transition list with its conditions rendered in plain English. Nine times out of ten the condition that's actually matching is visible right there, and the fix is reordering or tightening a condition rather than debugging C#. Breakpoints in Configure are close to useless anyway, since that method runs once at graph initialization, not per save — the evaluation logic you actually want to step through lives deep in PX.Data.Workflow internals that are not worth your time.

Wrapping up

State transitions are declarative but not magic: they evaluate in strict declared order, they read the cache at evaluation time, and they can't reach across tables without help. Treat the transition list as an ordered cascade, keep aggregate logic pre-computed into flags, and use the Workflow screen's own diagram as your primary debugging tool before you open a decompiler.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.