Skip to content

Composing & extending ​

Using it as-is ​

ts
host.registerModule(invoicingModule);

Then emit the events it consumes — that's the whole integration. There is no wiring step between the work-order engine and this one, because there is no connection between them: one emits, the kernel journals, this one consumes.

To make a vertical's own domain billable, emit a commerce.order-placed-shaped event from your operation. You never import this engine to do it.

Extending it ​

Today, barely — and this is the honest gap in this engine.

There are no in-scope functions (surface), so the composition pattern the other engines offer isn't available here. You cannot wrap export in your own vocabulary, and you cannot export a basis and write to your own tables in one transaction, without re-implementing the operation.

What you can do:

You wantUse
billing from a new domainemit an event; add a consumer upstream — the commerce.order-placed path is the worked example
extra fields on an underlagyour own side table keyed by the underlag id — never a column upstream
to hide export from most roleskeep invoicing:export off the role; it's already a separate key
the engine off for a tenantrevoke the invoicing entitlement — but read the caveat in surface first

Configuration ​

There is none. ModuleRegistration has five fields and none is config; there is no createInvoicingModule({...}) factory and no config field on the manifest. An engine cannot be told anything at registration time.

Configuration is dynamic; composition is code.

The one behaviour you might reasonably want to configure — "only invoice-payment orders bill" — is hard-coded in the commerce.order-placed consumer:

ts
if (p.paymentMethod !== 'invoice') return;

A vertical that settles differently can't change that rule; it would emit a different event or carry a different paymentMethod. Whether that belongs in the engine at all is a fair question — it is a business decision living in shared machinery, which is exactly what the engine/vertical line is supposed to keep out.

Reaching the outside world ​

Nothing in this engine talks to anything external, and it never will: module code may not fetch, and connectors handle the outside world.

invoicing.underlag-exported is the seam. Its consumer is by design an accounting connector (Fortnox/Visma-class) that turns the frozen basis into a real invoice. That the event exists, carries Money, and fires exactly once per export is the whole contract.

The immutability invariant exists for this seam: a connector must be able to trust that what it read can't change underneath it, and that exporting twice is impossible.

So how would Stripe attach? ​

Not to this engine, and the answer depends on who's paying whom:

CaseBucketWhere it lives
A tenant's customer pays a card/Swish chargeconnectorthe integrations hub — a capability tenants use
Substrat charges its own tenantsadapterinfrastructure the kernel consumes, swappable behind a pure interface

Fortnox lands in both buckets depending on the same question: a connector when it's a tenant's bookkeeping, an adapter when it's the platform's own invoicing rail.

A connector is a fourth bucket — not kernel, not engine, not vertical. It lives in the integrations hub, and the hub itself is kernel-owned while the connectors in it are not. The test that decides it: effects on the outside world are connectors.

Connectors are not built yet

The connector interface, connection store, token refresh, webhook ingress, and per-tenant connection config are planned, not implemented — there is no connector code in the repo. The _substrat_outbox is real and transactional, but it drains only to in-process consumers; there is no external sink and drained_at is written nowhere.

Today, "export to Fortnox" in the demos is a file-write stub. This engine is pre-shaped for a seam that doesn't have a floor under it yet — which is deliberate sequencing, but don't read underlag-exported as something you can wire Stripe to this afternoon.

Note also what doesn't belong behind that seam: reskontra and avisering (ledger, dunning) stay out of the engine entirely. The basis goes out; the bookkeeping stays in the bookkeeping system.

The hard parts, hosted.