Skip to content

Actions — business-process operations

An action is a named operation you invoke on a business object — the mechanism for driving a document or record through its business process. Completing an order, cancelling it, and spawning the resulting invoice are all actions. It is the third behavior in the framework’s model, alongside determinations (which derive data) and validators (which block invalid saves), and the only one that is invoked rather than run on every save.

The names come from OData: an Action is a side-effecting operation bound to an entity, a @Function is the read, and the two together are an operation.

An action is an ordinary NestJS provider extending Action.on(Schema, { name, label, description }), with two methods:

  • isAvailable(record) decides whether the action applies to this record. It is optional — the base returns true — and it is ordinary TypeScript, so the class may inject services and read other records, returning boolean or Promise<boolean>. It runs per record on every read that lists actions, so keep it cheap and prefer the record’s own state.
  • run(record, params) does the work, in the caller’s unit of work. If it throws, everything rolls back.

Three declarations shape those signatures. params may be omitted — the base pins it to {} — and the schema is strict, so run never sees a parameter the action did not declare; parameters are sparse, so an optional one the caller omitted reads as undefined and the action applies its own default. A hasMany inside params is a list of plain rows — the row’s fields and nothing else, not even an id, because an argument is not a record; createInvoice’s lines is one. A struct is its member object, or null. returns is how an action that spawns a record hands it back, declared and typed exactly like a function’s. And the factory names the binding: Action.on answers on the record with a permission scoped to that business object, while Action.root(config) declares a root operation at POST /api/actions/:namespace/:name, with a two-segment name and a zero-argument isAvailable.

Register the class in the plugin’s providers — the only registration, and the only step nothing checks for you. See Registration for the full class and the Params / Returns annotations.

The code stays on the server, the declaration does not

Section titled “The code stays on the server, the declaration does not”

An action is a NestJS provider, and its body never leaves the server: isAvailable is code, and code the client cannot run is code the client cannot get wrong.

Its declaration does cross. Name, label, description, subject, params and returns are part of meta, served from GET /api/meta and loaded into the client on construction. That declaration is load-bearing, not informational: the client refuses to run an action absent from it, and revives the Decimal and Temporal values in a result out of the declared returns.

What a static declaration cannot answer is whether the action applies right now, so the frontend asks rather than derives, on its own endpoint:

GET /api/business-object/sales/SalesOrder/{id}/actions
→ { "data": [{ "name": "complete", "label": "Complete" }] }

Separate from the detail read on purpose: isAvailable may inject services and read other records, and a read that never renders an action menu should not pay for it. The client asks when the menu opens — <ActionMenu> lives inside the dropdown content, which unmounts while closed — so nothing is evaluated until someone looks, and the list is never stale. It is not filtered by permission: an action the caller may not run is listed and refused on invocation (action:{action}).

Two form-level gates sit in front of all of that. A draft or unpersisted record shows no action menu at all — the whole menu belongs to the operational world. And every item is disabled while the form is saving or holds unsaved edits, because an action runs against the persisted record and would not see them.

An action that spawns a document hands back its id, and the surface showing the record decides what to do with it. useActions and onAction (from @onerp/react/actions, with <ActionMenu>) bind a handler to one action, typed against the action’s own class:

apps/web/src/resources/sales-invoices/sales-invoice-form.tsx
useActions([
onAction<CancelInvoice>("cancel", {
after: ({ cancellationInvoice }) => {
if (cancellationInvoice) {
navigate(recordPath("sales/SalesInvoice", cancellationInvoice));
}
},
}),
]);

onAction<A> checks the name against the action’s declared name, the result against its returns and the params against its params, through a type-only import — the class is server code. The after handler receives what the action returned and what it was sent, and returns nothing: navigating, refetching and opening a dialog are the surface’s business, and useActions only says when. The click pipeline runs confirm, dispatch, then the success toast before after fires.

The confirm dialog’s body is the action’s own description when it declares one, so write the description as what the user is about to agree to; without one, a generic “are you sure” stands in. An action that is safe on a single click skips the confirm with confirm: false, which cannot be combined with a dialog — the dialog is the confirmation.

A binding belongs to the surface that owns the record, not to the action menu, so a list view can register its own handlers for the same action. A surface that registers nothing gets nothing; running the action still works.

Paths come from @onerp/react/resource (recordPath, draftPath, listPath, createPath). Do not spell one by hand: a document saved as a draft has its own address, and /{resource}/{id} will not resolve one. The order’s createInvoice binding reads the activate param it sent to pick between the two.

Some actions need to collect something from the user before they run — a cancellation reason, a target warehouse. onAction’s dialog replaces the generic confirm with a component receiving ActionDialogProps<TheAction>, whose run(params) is the same policy-wrapped dispatch the generic confirm uses underneath (dispatch, success toast, failures to the error modal, then after), typed against the action’s declared params. It resolves to whether the action landed, so if (await run(params)) close() is the pattern, and a failed attempt leaves the dialog open for another. The modal shell and its title are framework-owned, so the component renders content only and must not render its own DialogTitle; it receives the action’s label for its own confirm button, and a binding whose content needs the room — a table — says wide: true.

The reference dialog is the line picker the sales order binds to createInvoice and createFulfillmentOrder (apps/web/src/resources/sales-documents/line-picker-dialog.tsx): one generic table — checkbox, line, open quantity, quantity to carry — fed by a thin adapter per action that reads the lines off the form context and says what each line has open, in which unit. The picker shows what the document’s lines carry themselves, nothing looked up from master data. Every open line starts selected at its open quantity, so one click carries everything, and a partial forward is a matter of unchecking or typing less. The submitted rows are the action’s lines parameter. The picker only defaults: what a line may carry is checked, if at all, on the document it lands in — not here, and not on the request.

An action writes through the ordinary manager methods; there is no privileged write path, because it does not need one. A business object’s lock seals its input fields — the ones a user may type — while a status like execution is access: "system", written by determinations and by the actions that drive the process. So bom.update with a system-field delta lands on a sealed document, and the same call with an input field does not.

Spawning a follow-up document (an order spawns an invoice, an invoice spawns its own cancellation — see Cancellations and corrections) is an action whose run creates a new record.

Declare what you spawned. Without a declared returns the new id exists for one instant inside run and is then unreachable to the caller. CreateInvoiceFromOrder declares returns: { invoice: field.referenceOne("sales/SalesInvoice", …) } and returns { invoice: invoice.id } (the full class is in Registration).

The run response carries it as data — { "data": { "invoice": "01K…" } }. The record itself does not ride along: a caller that needs the state the action left behind re-reads it, which the browser client does on its own after every action.

A spawned document is born active. Everything a cancellation, a correction or a fulfillment order needs was decided before the action ran — in its params, or on the document it came from — so run creates through the operational BusinessObjectManager and the caller gets a numbered, sealed record in one call. An agent or an integration never sees a draft, and a validation failure rolls the whole action back with the field named, instead of leaving a draft behind that no list can find.

The exception is a document a person still finishes. An invoice from an order may want a service date or a reworded line before it goes out, so createInvoice takes an activate param, true when omitted: the order form’s picker offers Create Invoice and Save as draft, and only the second passes activate: false. The action then creates through DraftWorkspace instead of BusinessObjectManager — one payload, one branch — and the id it returns is a draft’s — reachable only by the draft route the surface builds (see Lifecycle). The flag is an ordinary param: whether a spawning action offers it is that action’s decision, and only where a person has something to add.

There are two ways for run to fill the new record, and the choice is a domain decision:

  • Copy some fields, derive the rest — right when the new document is genuinely new. CreateInvoiceFromOrder copies each line’s product, quantity, price and discount, wires the new line’s orderLine back to the order row it came from (a row reference), and lets the invoice’s own determinations derive the rest against today’s prices and tax rules.
  • Copy the record whole — right when the new document must reflect what the old one said. copyRecord(record, schema, { omit }) (@onerp/meta/records) mirrors every field, system fields and other domains’ extension fields included, leaving the omitted paths absent for the new record’s determinations to fill. See Documents capture their data.

Every registered action is callable over HTTP:

POST /api/business-object/{namespace}/{name}/{id}/actions/{action}

The body carries the action’s params, always validated against its declaration. The response is { "data": … } holding whatever the action declared in returns — the one envelope every operation route answers with. The saved record is not in it. Which actions the record offers now is a separate read (GET …/{id}/actions).

Outcome Status
Action succeeded 200 OK
Record not found 409 Conflict (ActionNotAvailableError)
isAvailable rejected the record’s state 409 Conflict (ActionNotAvailableError)
Unknown action 403 Forbidden (deny-by-default)
Invalid params 400 Bad Request (core.validation_failed; availability guards first, so an unavailable record answers 409)
Domain rule violated inside run 422 Unprocessable Entity (AppException subclass, e.g. core.*/sales.*)

Deny-by-default for unknown actions is intentional: the permission check is action:{action}, so an action name not registered on the business object is treated as an unauthorized request, not a 404.

ActionService’s dispatch seam is the same lookup → load → guard → parse → run every caller crosses, HTTP or in-process: resolve the action from MetaService.current() (404 if it is not registered), load the record, call isAvailable (409 if the record is absent or its state rejects), parse the body against the declared params rejecting anything undeclared, then run and answer with whatever it returned. An HTTP caller never sees that 404 — the permission guard runs before the handler and turns an unregistered name into the 403 above. The availability check runs here even though the caller was handed a list: that list was a snapshot from an earlier request. Everything shares one unit of work, so if run throws, the whole thing rolls back.

An action’s writes flow through the same engine pipeline as a field edit, because they are field edits. The engine re-evaluates every determination whose inputs include a field the action wrote, within the same save.

describe_business_object returns the action catalog — name, label, description, params, and an allowed flag for whether the caller’s role holds action:<name> (actions the user may not invoke are still listed, so the agent can explain the process). Which actions apply to a particular record is a separate question the agent has no read for, so it learns by invoking and reading the refusal. It runs one via invoke_action, approval-gated like every write; a rejected isAvailable maps to core.action_not_available.

An action is a mechanism, not an event. Whatever it writes surfaces as an ordinary updated row carrying the field diff, including any determination cascade it triggered. An action that writes nothing to its own record emits nothing for it; business objects it spawns emit their own created events, linked by the shared per-UoW correlation_id.

See Audit for the row shape and the timeline UI, and Events for how the bus dispatches and attributes them.