
Run ERP operations such as posting an invoice
Some changes in an ERP are not edits of a record. Posting an invoice validates it, gives it a number and writes the journal entries; confirming an order starts delivery. Only the ERP’s own operation can do that. An application declares such an operation by name, beside the record type it acts on, and the engine runs it for the people allowed to.
The operation runs under the ERP connection’s own service account. Application code never holds that credential: it asks the engine to run the declared action, and the engine decides whether it may.
The person must hold one of the declared roles in the organization that owns the application. A role held only in one subsidiary allows the action only in that subsidiary’s company. An action on a record reads the record first through the same checks as an ordinary read, so a record outside the person’s reach is answered as absent and nothing is sent.
The input is checked against a declared shape, and anything the declaration does not name is refused rather than forwarded. An action that changes the ERP requires an idempotency key, so a retried request replays its first outcome instead of posting twice. If the answer is lost after sending, the outcome is reported as unknown, never as “not done”. Every attempt, refused or executed, is recorded in the audit trail.
Example — Post a draft invoice
A finance manager of one subsidiary posts a draft customer invoice from the application. The ERP validates and numbers it, and the application shows the invoice as posted, read back from the ERP rather than assumed. The operation is enabled only while the invoice is a draft, and an invoice belonging to another subsidiary cannot be posted from that manager’s account.
For engineers
An action is declared on a read-through binding (httpApiBinding.actions). A record-targeted action belongs on the binding of the entity it acts on, because that binding supplies the key codec and the read gates the executor reads the record through. The following is the binding of Wonder Todos’ customerInvoices, with comments omitted. The action enums come from @wildo-ai/saas-models/external-data.
httpApiBinding: {
dialect: HttpApiTransportDialect.ODOO_JSONRPC,
system: 'odoo',
entityRef: 'account.move',
keyFields: [{ localField: 'odooInvoiceId', remoteField: 'id', codec: HttpApiKeyComponentCodec.INTEGER }],
fieldMappings: [{ localField: 'amountTotal', remoteField: 'amount_total' }],
subjectRights: { posture: SystemOfRecordSubjectRightsPosture.NO_SUBJECT_DATA },
actions: {
[CUSTOMER_INVOICES_POST_ACTION]: {
target: SystemOfRecordActionTarget.RECORD,
call: {
shape: SystemOfRecordActionCallShape.ODOO_EXECUTE_KW,
method: 'action_post',
inputPlacement: SystemOfRecordOdooActionInputPlacement.NONE,
},
input: z.object({}),
writes: true,
retrySafe: false,
roles: [CORE_ORG_ROLES.ORG_MANAGER],
operation: CustomerInvoices_Operations.POST_INVOICE,
},
},
},
CUSTOMER_INVOICES_POST_ACTION is the application’s name for the action, 'postInvoice'; the call is what Odoo understands. writes: true makes the Idempotency-Key mandatory and turns an interrupted attempt into an unknown outcome. retrySafe: false stops the transport from re-sending after a timeout: Odoo offers no idempotency for a method call. operation names the action’s HTTP door.
Give the action an HTTP door
The door is a custom operation of the same resource. Because the action names it, the factory holds it to the action’s rules rather than the row-write rules: it must borrow a mutation verb, require the key and run inline. From the same file:
[CustomerInvoices_Operations.POST_INVOICE]: {
variants: [{
variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
roles: [CORE_ORG_ROLES.ORG_MANAGER],
riskLevel: ResourceOperationRiskLevel.MEDIUM,
resourceOperationLike: CoreResourceOperation.UPDATE,
requestDto: z.object({}),
idempotencyKey: ResourceOperationIdempotencyKeyRequirement.REQUIRED,
enabledCondition: ({ currentObject }) => currentObject?.state === CustomerInvoices_State.DRAFT,
customServiceImplementationModes: [ResourceOperation_CustomServiceImplementationMode.OVERRIDE_ALL],
}],
},
The implementation (customer-invoices.post-invoice.operation.ts in the Wonder Todos backend) does nothing but hand the request to the executor and then read the invoice back through the resource’s own gates:
await actions.run({
system: ODOO_SYSTEM_OF_RECORD,
action: CUSTOMER_INVOICES_POST_ACTION,
targetResourceId: id,
input: {},
idempotencyKey: utils.requestIdempotencyKey,
serviceOptions: utils.serviceOptions,
}, executionContext);
utils.systemOfRecordActions is the only door to the executor. The systems-of-record registry that holds the credential is not reachable from application code, and the browser’s provider-operation route cannot reach an action.
What the executor checks, in order
The executor refuses to run inside a database transaction; checks that the action is declared on a binding of the named instance; refuses an anonymous, public or organization-less context; requires the owner organization and a declared role there; parses the input strictly; spends the key before anything reads the ERP; reads the record first (or, for a record-free action, places the call in the caller’s partition); sends one call; settles the key and writes one audit row. Every refusal before the call is recorded as system_of_record_action_refused, and every call as system_of_record_action_executed with its outcome.
A context with no user and no machine principal, such as a scheduled job, may run an action only when it declares admitsSystemInitiation: true, and it then acts organization-wide.
Call shapes and what each vendor offers
| Call shape | Wire form | Declared by |
|---|---|---|
ODOO_EXECUTE_KW | execute_kw(db, uid, key, model, method, positional, keyword); input as keyword arguments, positional, or none | the Odoo facet |
ODATA_BOUND_ACTION | POST <EntitySet>(<key>)/<Namespace.Action> with the input as JSON body | the generic OData v4 facet |
ODATA_UNBOUND_ACTION | POST /<ActionImport> with the input as JSON body | the generic OData v4 facet |
A REST JSON binding cannot declare actions. On Odoo, the methods whose purpose is to return rows (search_read, read, read_group and similar) are refused, because an action’s answer is not confined the way the read lane confines rows; write and unlink on the bound model are refused as record actions, because they are the row writes writableVerbs already governs. An action that acts on no record, declared against a partitioned instance, must state its partition stance: PARTITION_NEUTRAL with a written reason, or MEMBER_UNIT_PARTITION naming the parameter the engine fills from the caller’s unit.
An optional output schema parses the vendor’s answer; an answer it rejects after the ERP applied the action is reported as an unknown outcome.