Acumatica · Customization

Cross-Field DAC Validation in Acumatica

Single-field validation is easy: a FieldVerifying handler rejects a bad value before it lands. Cross-field validation - a rule that's only knowable once you have two or more.

John Kihiu12 min read

Single-field validation is easy: a FieldVerifying handler rejects a bad value before it lands. Cross-field validation - a rule that's only knowable once you have two or more fields' final values, like "override reason is required if status is Hold" or "ship date can't precede order date" - is where I see the most bugs in customizations written by developers who reach for FieldVerifying out of habit instead of thinking about which fields the rule actually depends on.

FieldVerifying only ever sees one field's new value in isolation

A FieldVerifying<SOOrder.shipDate> handler fires with the proposed new ship date, but at that moment you cannot assume the order date on the same row is in its final state - the user might edit ship date first and order date second, in either order, and FieldVerifying fires per field as each one changes, not once both are settled. Writing a cross-field check here means it either fires too early (rejecting a valid combination because the other field hasn't been entered yet) or fails to catch a bad combination at all if the fields are edited in the "wrong" order:

C#
// Unreliable: assumes OrderDate is already final when ShipDate changes.
// If the user sets ShipDate before OrderDate, e.Row.OrderDate may still
// be the default/blank value, and this check either false-fails or
// silently lets a bad combination slip through.
protected virtual void _(Events.FieldVerifying<SOOrder.shipDate> e)
{
    var row = (SOOrder)e.Row;
    if ((DateTime)e.NewValue < row.OrderDate)
        throw new PXSetPropertyException("Ship date cannot precede order date.");
}

RowPersisting sees every field's final value on the row, exactly once, right before the save

RowPersisting fires once per row, inside the save transaction, after every field on that row has whatever value the user actually intends to persist. This is the only point in the pipeline where "final state of the whole row" is a safe assumption, which makes it the correct home for any rule spanning more than one field:

C#
protected virtual void _(Events.RowPersisting<SOOrder> e)
{
    var row = (SOOrder)e.Row;
    if (row == null || e.Operation == PXDBOperation.Delete) return;

    if (row.ShipDate.HasValue && row.OrderDate.HasValue && row.ShipDate < row.OrderDate)
    {
        e.Cache.RaiseExceptionHandling<SOOrder.shipDate>(
            row, row.ShipDate,
            new PXSetPropertyException("Ship date cannot precede order date."));
        e.Cancel = true;
    }
}

Using RaiseExceptionHandling against the specific field, rather than a bare throw new PXException, matters for UX: it attaches the red error indicator to the actual offending field in the grid or form, instead of surfacing a generic save-failed message the user then has to hunt for the cause of. I default to this pattern for every cross-field rule now, purely because the alternative produces a support ticket asking "what's actually wrong with this order" that RaiseExceptionHandling would have answered on the screen directly.

Set e.Cancel = true, or the invalid row saves anyway

Raising the exception through RaiseExceptionHandling shows the red error indicator, but by itself does not stop the save - e.Cancel = true is what actually blocks the transaction from proceeding. I've seen a "validation" that visibly flagged a field in red and then let the save complete anyway, because the developer assumed the visual error was sufficient. It isn't; both lines are required.

Cross-row rules (comparing this row to sibling rows) need graph-level logic, not RowPersisting alone

Some cross-field rules aren't cross-field on one row, they're cross-row - "total of all line quantities can't exceed the header's approved quantity," for instance. RowPersisting only ever sees one row at a time, so a rule spanning multiple rows needs either a graph-level check (iterating Base.Transactions.Select() in the graph's own persisting override, or hooking the header DAC's RowPersisting and summing sibling detail rows from cache there) rather than living entirely inside a single line's event handler:

C#
// Cross-row check lives on the parent's RowPersisting, summing children
// still present in cache - including ones not yet flushed to SQL
protected virtual void _(Events.RowPersisting<SOOrder> e)
{
    var row = (SOOrder)e.Row;
    if (row == null) return;

    decimal totalQty = Base.Transactions.Select()
        .RowCast<SOLine>()
        .Where(l => l.OrderNbr == row.OrderNbr)
        .Sum(l => l.OrderQty ?? 0m);

    if (row.UsrApprovedQty.HasValue && totalQty > row.UsrApprovedQty)
    {
        e.Cache.RaiseExceptionHandling<SOOrder.usrApprovedQty>(
            row, row.UsrApprovedQty,
            new PXSetPropertyException("Line total exceeds approved quantity."));
        e.Cancel = true;
    }
}

Querying Base.Transactions.Select() rather than re-selecting from the database is deliberate: it sees uncommitted, in-cache changes the user just made in this same save, which a fresh BQL select against SQL would miss entirely since nothing has been written yet.

Wrapping up

Cross-field validation belongs in RowPersisting, never FieldVerifying, because RowPersisting is the only point where every field's final value on the row is a safe assumption. Attach errors to the specific offending field with RaiseExceptionHandling for a usable error message, and never forget e.Cancel = true - the visual error alone doesn't block the save. Cross-row rules need graph-level logic reading from cache, not SQL, so uncommitted sibling-row edits from the same save are actually visible to the check.

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.