Skip to content
Last updated: Sep 25, 2026

Journal — Posting, Validation & Sourced JVs ​

OwnerClassificationVersionEffectiveNext reviewStatus
Platform EngineeringInternal1.02026-09-152026-12-15Draft

Purpose. Operating guide for the journal module's write path — how a journal voucher (JV) is created, validated, routed, and posted through the one canonical spine (processjournal), how governed callers (Finni, close-step automations) submit JVs without ever writing a table directly, and how a JV links back to the source record that produced it. It is honest about where the pipeline is fully wired (✅), where it depends on per-tenant configuration that may be absent (⚠️), and what is still roadmap (◯). Evidence paths are relative to blitz/src.


1. The one intake spine ​

Every JV — email/xlsx upload, the manual Create screen, Finni, or a close-step automation — funnels through the same intake and the same processing workflow. There is no forked path, no "quick insert," no direct write to journalvoucherheader.

documentreceipt  →  extractedjournalvoucher (+ extractedjvlineitems)  →  processjournal
   (no file)          (staging, all-string fields)                        (the spine)
Entry pointWhoHow it reaches the spineEvidence
Manual Create / edit formPreparer (UI)POST /jvprocessingdata/worklist/manual → createManualJv; on submit=true → submitManualJvpackages/finance/journal/journala/controllers/journal/v1/jvprocessingdata.worklist.controller.ts
Finni ("submit it")Agent, governedsubmitJournalVoucher tool → submitManualJvViaApi → the same /manual endpointpackages/finance/financeagents/src/agents/finni-agent/journal-tools.ts, journal-client.ts
Email / xlsxIngestionextract razor → extractedjournalvoucher → processjournalsee Email & Document Ingestion
Module automationclosehub / recon / invoicecreatesourcedjournal → processjournalpackages/finance/journal/journalwf/src/workflows/createsourcedjournal/workflow.ts

createsourcedjournal is the shared journal-creation primitive: it materialises the documentreceipt (no file, sourcetype "Manual"), numbers the document via the platform receiptprefixes convention (JV-<padded id>; see Document Numbering), derives the header summary from balanced Dr/Cr lines, and then runs processjournal. Before it existed, postcutoffaccrual and postreconadjustment each hand-rolled ~70 lines of that intake.


2. processjournal — the lifecycle (create → validate → route → post) ​

processjournal(tenantId, documentreceiptid, stages?) runs staged, idempotent processing. It is guarded: a second run for the same extractedjvid reuses the existing jvprocessingdata instead of double-inserting. Evidence: packages/finance/journal/journalwf/src/workflows/processjournal/workflow.ts.

StageWhat it doesNotes
1. InsertPromotes extractedjournalvoucher (+ lines) → journalvoucherheader + journalvoucherlineitems + jvprocessingdata✅ Stamps the source triple (§4) onto the header
2. Idempotency guardSkips re-insert if a jvprocessingdata already exists for this extractedjvid✅
3. BPD routingRuns the journal_businessprocess ruleset (FirstPassed); falls back to applicationsettings.defaultjournalprocessid✅ ⚠️ Depends on a compiled routing ruleset existing
4. TransformChild runjournaltransformations — account/entity/cost-centre/posting-date/doc-type resolutionSkipped if no BPD matched
5. ValidateChild runjournalvalidations — writes journalvalidationchecks (§3)Skipped if no BPD matched
6. AssignAssignDocumentActivity → sets assignedtoid / assignedteamidownership only, not the lifecycle action
6.5. IntelligenceInline materiality tier (finbase.materialityconfig, applicationcode "JOURNAL") + series-deviation anomaly✅ writes materialitytier / anomalyreasons on the header
7. ApprovalApproveDocumentActivity → auto-clear (immaterial) or route (create approval chain)✅ persists approvalrequest rows

JV status model ​

status, jvstatus, and action move together (a single source-of-truth map). action is the next expected step; terminal states carry null. Evidence: packages/finance/journal/journala/services/journal/jvprocessingdata.worklist.service.ts (STATUS_NEXT_ACTION).

StatusNext actionMeaning
Draft / New / In ProgressSubmitNot yet in the pipeline (Draft = local only)
Pending ApprovalApproveRouted to an approver (currentapproverid)
RejectedResubmitReturned
ApprovedPostCleared approval; awaiting posting
Posted— (terminal)Booked to the GL
Not a JV Document / Discarded— (terminal)

There is deliberately no "Validated" status — validation is a gate, not a state.

⚠️ Post-to-ERP is a status transition, not (yet) a workflow. Approved → Posted is modelled as an action, and journalvoucherheader.erpdocumentnumber exists for the ERP-assigned number, but the journal package does not contain a dedicated post-to-ERP / connector workflow that flips the status and stamps erpdocumentnumber. Treat the actual GL-posting integration as ◯ roadmap; this doc describes the pipeline up to and including the Approved state and the intended Post action.


3. Validation — defined, running when wired ⚠️ ​

runjournalvalidations does execute and does write journal.journalvalidationchecks (one row per rule, with pass/fail, dashboardstatus, exceptionmessage, enriched additionalinfo). This is real, not a stub. Evidence: packages/finance/journal/journalwf/src/workflows/runjournalvalidations/workflow.ts.

Rule (servicename switch case)Mechanism
Journal Header ValidationsRuns a compiled ruleset via jvvalidationruleset (Process Name param)
Journal Mandatory Field CheckSame ruleset mechanism
Journal Line ValidationDuckDB export → customruleprocessor script → import
Master Data LookupDuckDB export (accounts / cost / profit centres) → script
Duplicate Journal CheckIn-workflow fingerprint (posting date + currency + totals + entity/accounts)
Debit/Credit Balance CheckIn-workflow, against line items (≥2 lines, non-zero, nets to zero)

⚠️ The honest gap — "rules defined ≠ rules running." The two ruleset-driven cases (Header, Mandatory) only produce checks when the tenant's BPD has the wiring in place: finbase.validationrule rows for that businessprocessdefinitionid, matching journal.jvvalidationruleset records, and a compiled sys.ruleset + sys.rule set. If any link is missing, the case logs and skips, and journalvalidationchecks stays empty at runtime for those rules — the classic "validation rules exist in setup but the checks table is empty." This is a configuration/seeding gap, not missing code. The in-workflow checks (Duplicate, Balance) always run regardless. Verification: after a JV runs, confirm rows exist in journal.journalvalidationchecks for that jvprocessingdataid; an empty result for the ruleset rules means the BPD/ruleset wiring (and the engine rebuild, POST .../jvvalidationrulesets/rebuild) has not been applied.

Additional known gaps (⚠️): the validation score bar normalises against a fixed max (misleading across rulesets), and failed sub-rules can surface raw jvr_* codes rather than friendly names. journal.journaledithistory exists but nothing writes it yet (◯).


A JV carries a provenance triple on journalvoucherheader so it can be traced back to whatever produced it (a close task, a payment, an invoice accrual, a recon variance). Evidence: packages/finance/journal/journald/prisma/models/journal.prisma (model journalvoucherheader).

FieldPurposeExample
sourcemoduleWhich module created the JVclosehub, cash_app, invoice
sourcereferencetypeWhat the source record istask, clearing_transaction, payment_refund
sourcereferenceidThe source record's idthe closehub task id

createsourcedjournal stamps the triple on the intake; processjournal propagates it to the promoted header. The journal "What to Post" cockpit reads sourcemodule / sourcereferenceid to show, e.g., the close task that requires each due JV.

The two shipped automation generators ​

Both are close-step workflows that read a source fact, build a balanced JV, and delegate to createsourcedjournal — they never write journal tables directly.

WorkflowBound close stepReadsGovernance
postcutoffaccrual"Post AP Accrual" (AP Cutoff task)the prior step's accrual proposal (computeapaccrual)assignApprove: true → routes for approval (immaterial auto-clears)
postreconadjustment"Post Adjustment JE" (recon/sub-ledger task)the recon variance for the resolved control periodSummaryIdassignApprove: false → lands as a preparer Draft for review + confirm

Evidence: postcutoffaccrual/workflow.ts, postreconadjustment/workflow.ts. Both stamp source: { module: "closehub", referencetype: "task", referenceid: taskId }, so the JV links back to the exact close task. The recon true-up carries a data-derived reason (account, period, variance direction, magnitude, recommended offset treatment) and is advisory — Finni proposes, a human confirms. See Connected Close — GL Stitching for the recon side.

⚠️ Single-childInput dispatch convention for close steps ​

The close orchestrator (runTaskActivitySequence) dispatches a step workflow with one input object, i.e. executeChild(ref, { args: [childInput] }) — not a (tenantId, input) positional pair. A workflow that declares a two-arg positional signature will silently receive an empty tenantId and skip. Both shipped generators therefore accept a single object and defensively merge a stray second arg:

ts
export async function postreconadjustment(a: Input | string, b?: Input) {
  const input = { ...(typeof a === "object" ? a : {}), ...(b ?? {}) };
  const tenantId = input.tenantId ?? (typeof a === "string" ? a : "");
  // ...
}

Any new close-step journal workflow must follow this convention.


5. Governed execution — never a direct table write ​

The invariant: no external actor writes journalvoucherheader directly. Finni is the worked example. When a user confirms a drafted JV, Finni's submitJournalVoucher tool calls submitManualJvViaApi, which POSTs to the same /jvprocessingdata/worklist/manual endpoint the Create screen uses (submit: true), which hands the JV to processjournal. Evidence: packages/finance/financeagents/src/agents/finni-agent/journal-tools.ts (journalSubmitJv), journal-client.ts (submitManualJvViaApi).

GuardrailWhereEffect
Balance check before submitFinni tool (Dr = Cr)Refuses to submit an unbalanced JV
RBAC permission/manual endpoint (journal:voucher:manage)Finni surfaces a permission error, offers to draft for someone who can
One spinesubmit=true → processjournalFinni-born JVs get the same validation + routing as any other
Advise, not auto-posttool description + assignApproveThe tool submits for approval; it does not post/approve

The same principle governs the automations in §4: they produce a JV through createsourcedjournal, so the entry is validated and routed like any other — the automation proposes, the pipeline (and a human, where material) commits.


6. Verify ​

QuestionCheck
Did the JV promote?journal.journalvoucherheader + journal.jvprocessingdata rows exist for the receipt
Did it route?jvprocessingdata.businessprocessid set; status = Pending Approval or Approved
Did validation run?rows in journal.journalvalidationchecks for the jvprocessingdataid — empty (for ruleset rules) = wiring gap, §3
Is it linked to its source?journalvoucherheader.sourcemodule / sourcereferencetype / sourcereferenceid populated
Intelligence assessed?journalvoucherheader.materialitytier / intelligenceassessedat set

Revision history ​

VersionDateAuthorChange
1.02026-09-15Platform EngineeringInitial page — the one intake spine, processjournal lifecycle + status model, validation (wired-vs-empty gap), sourced/automation JVs + the JV↔source triple, single-childInput dispatch convention, and Finni as the governed-execution example.

Finaisse Internal — Confidential. Access-restricted; not for external distribution.