Journal Entries — balanced manual/auto GL postings
The whole journal module reduces to one idea:
A
JournalEntryis a header that groups N balancedAccountTransactionlegs. The header carries no money of its own — every debit and credit is a separate leg in the ledger (finance_account_transactions), linked back to the header byrefIdand grouped together by a single sharedrelationId. The header only validates the legs (Σdebit == Σcredit), denormalises totals for fast list rendering, and drives the SAVED ↔︎ POSTED lifecycle. Delete the header → its legs are deleted → balances self-correct.
Source: BE src/modules/finance/journal · Admin src/modules/finance/journal
See _overview.md for the double-entry model, transaction.md for the leg itself, and contra.md for the two-leg sibling.
1. Purpose & scope
The journal module is the manual entry point into the GL ledger and the canonical example of the "header + balancing legs" pattern that every other document type imitates. It is responsible for:
- Creating/editing/deleting a balanced set of debit/credit legs from an admin grid (or XLSX import).
- Enforcing the balance invariant (Σdebit == Σcredit) before writing.
- Carrying the posting lifecycle:
SAVED(draft, freely editable) ↔︎POSTED(final, guarded). - Denormalising
totalDebit/totalCreditonto the header for list views.
It does NOT:
- Own the ledger leg itself — that is
AccountTransaction(see transaction.md); the journal service only orchestratesAccountTransactionService.create/update/deleteMany. - Decide debit-vs-credit for you — for manual entries the user supplies
debit/creditdirectly. (Auto-generated journals from other subsystems, e.g.ASSET_DEPRECIATION, supply legs already resolved viagetTransactionType; see transaction.md §getTransactionType.) - Generate tax legs — manual journal legs carry
taxId/taxIdsand the shared transaction writer spawns theTaxEntrylegs (see transaction.md). Journal totals exclude them.
2. Data model
Collection finance_journal_entries — the journal header
Source: journal/journal.schema.ts.
| field | type | required | description |
|---|---|---|---|
_id, ref, companyId, branchId, documentCode, createdAt/By, updatedAt/By, canView/canDelete/... |
— | — | from BaseSchema. ref is generated JN… (see §below). |
ref |
string | yes | document number, generated { prefix: "JN" } in journal.repository.ts → create(). |
type |
JournalEntryTypes |
yes | classifies the entry; default GENERAL. Manual entries are GENERAL; the rest are stamped by the subsystem that auto-creates the journal. |
currencyId |
string | no | the journal's display currency (a masters doc). |
currency |
string | no | legacy text mirror of currency. |
currencyRate |
string | no | legacy text mirror of rate. |
description |
string | no | free-text memo. |
documentDate |
number (unix ms) | no | floored to UTC-day (DateUtils.startOfDateOnly); drives period locking and date-range reports. Defaults to today if omitted. |
totalDebit |
number | no | denormalised Σ of DEBIT leg amounts × exchangeRate (excludes TaxEntry legs). Recomputed by computeAndStoreTotals. |
totalCredit |
number | no | denormalised Σ of CREDIT leg amounts × exchangeRate (excludes TaxEntry). |
status |
AccountTransactionStatus |
— | default SAVED. The shared posting enum (not a journal-specific one). |
transactions |
AccountTransaction[] |
— | not a stored array — populated by $lookup on refId at read time. The DB column is written as [] on create. |
// journal/journal.schema.ts
export enum JournalEntryTypes {
BANK = "BANK",
CASH = "CASH",
GENERAL = "GENERAL", // default — manual entries
PURCHASE = "PURCHASE",
SALES = "SALES",
ORDER_FIXING = "ORDER_FIXING",
ASSET_DISPOSAL = "ASSET_DISPOSAL",
ASSET_DEPRECIATION = "ASSET_DEPRECIATION",
ORDER_RETURN = "ORDER_RETURN",
MFG_WIP = "MFG_WIP",
MFG_COMPLETION = "MFG_COMPLETION",
MFG_VARIANCE = "MFG_VARIANCE",
MFG_SCRAP = "MFG_SCRAP"
}
@ApSchema({ collection: `finance_journal_entries`, timestamps: true })
export class JournalEntry extends BaseSchema {
@Prop({ required: true }) ref: string;
@Prop({}) currencyId: string;
@Prop({ required: true, default: JournalEntryTypes.GENERAL }) type: JournalEntryTypes;
@Prop({}) description: string;
@Prop({}) documentDate: number;
@Prop({}) totalDebit: number;
@Prop({}) totalCredit: number;
@Prop({ type: String, enum: AccountTransactionStatus, default: AccountTransactionStatus.SAVED })
status: string;
transactions: AccountTransaction[]; // virtual — joined by refId at read time
}Soft delete: JournalEntrySchema.plugin(SoftDelete, { deletedAt, deletedBy }) — deleted headers drop out of every aggregation. Index: { companyId: 1, documentDate: -1, createdAt: -1 } (matches the auto-injected company filter + default list sort). Tenant scoping: companyId is auto-injected by AbstractBaseRepository into every query.
statuslives on both header and legs. The headerJournalEntry.statusand everyAccountTransaction.statusfor thatrefIdare kept in lockstep (post/save updates both).
Status enum (shared with the ledger)
// transaction/transaction.schema.ts
export enum AccountTransactionStatus { SAVED = "SAVED", POSTED = "POSTED" }The legs
Each line in the entry becomes one AccountTransaction row stamped kind = JournalEntry. The full leg schema (all fields, indexes, tax breakdown) is documented in transaction.md. The journal-relevant fields it sets per leg: accountId, type (DEBIT/CREDIT), amount (stored in account currency — see §4), refId (= header _id), relationId (one shared id for the whole entry), kind = JournalEntry, status (mirrors header), documentDate (floored to UTC day), exchangeRate, plus the analysis dimensions costCenterId, classId, analysisCodeId, departmentId, payeeId, remark, and optional taxId/taxIds/taxInclusive.
3. API surface
All resolvers are @ApGqlAuthorize(); mutations carry @AuditMeta({ module: 'journal', collection: 'finance_journal_entries', ... }). Source: journal/journal.resolver.ts.
| Operation | Type | Input | Returns | Guards / Permission |
|---|---|---|---|---|
journalEntryPage |
Query | JournalEntryPageInput |
JournalEntryPageResult |
— |
findOneJournalEntry |
Query | JournalEntryQueryInput |
JournalEntry |
— |
journalEntrySummary |
Query | JournalEntryQueryInput |
JournalEntrySummary { totalRecords, totalCredits, totalDebits } |
— |
createJournalEntry |
Mutation | journalEntry: CreateJournalEntryInput |
JournalEntry |
audit CREATE |
updateJournalEntry |
Mutation | _id, journalEntry: UpdateJournalEntryInput |
JournalEntry |
@GuardPostedEntry + audit UPDATE |
deleteJournalEntry |
Mutation | _id |
Boolean |
audit DELETE |
deleteManyJournalEntry |
Mutation | input: DeleteManyJournalEntryInput { ids } |
Boolean |
audit DELETE |
postJournalEntry |
Mutation | id |
Boolean |
@GuardLockedPeriod + audit STATUS_CHANGE |
postManyJournalEntry |
Mutation | ids: [String] |
Boolean |
@GuardLockedPeriod(idArg:"ids", isArray) + audit STATUS_CHANGE |
saveManyJournalEntry |
Mutation | ids: [String] |
Boolean |
audit STATUS_CHANGE |
importJournal |
Mutation | import: JournalImportInput { file } |
[JournalImport] (parsed preview) |
audit CREATE |
confirmJournalImport |
Mutation | import: ConfirmJournalImportInput |
Boolean |
audit CREATE |
There is no saveJournalEntry/single-save mutation; the admin's saveJournalEntry() simply routes to create or update. saveManyJournalEntry(ids) is the bulk SAVED transition.
canPost is a @ResolveField returning status !== POSTED. currency is a @ResolveField that uses the joined currency if page() already attached it, else looks it up by currencyId.
GraphQL types (generated, src/schema.gql)
type JournalEntry {
_id: String branchId: String! ref: String! documentDate: Float
type: JournalEntryTypes currencyId: String description: String
totalDebit: Float! totalCredit: Float!
transactions: [AccountTransaction!]!
status: AccountTransactionStatus! currency: Master canPost: Boolean
}
input CreateJournalEntryInput {
type: JournalEntryTypes description: String! documentDate: Float
currencyId: String transactions: [JournalEntryTransactionInput!]!
status: AccountTransactionStatus
}
input UpdateJournalEntryInput { ...same, all nullable... }
input JournalEntryTransactionInput { # extends CreateAccountTransactionInput
_id: String ref: String debit: Float credit: Float
departmentId: String type: AccountTransactionTypes
# + accountId, amount, exchangeRate, remark, payeeId,
# costCenterId, classId, analysisCodeId, taxId, taxIds, taxInclusive, documentDate
}
input ConfirmImportJournal {
ref: String accountId: String! costCenterId/classId/analysisCodeId/departmentId: String
remark: String documentDate: Float amount: Float! exchangeRate: Float = 1
}A journal line carries
debitorcredit(mutually exclusive). The service derives the ledger leg'stypefrom which one is non-zero:type = trans.debit ? DEBIT : CREDIT.
REST
GET /api/journal-entry/download?downloadType=xlsx (journal.controller.ts, @ApiAuthorize) — flattens each entry's legs into rows (Ref, Date, Type, Status, Account Name/Number, Debit, Credit, Exchange Rate, Memo, Cost Center, Class, Analysis Code, Department) and streams an XLSX.
4. Business rules & calculations
4.1 The balance rule (validateEntry)
Before any write (create and update), JournalEntryService.validateEntry runs:
// journal/journal.service.ts
private validateEntry(model): void {
const totalDebit = model.transactions.reduce((a, c) =>
a + (c.debit || (c.type === DEBIT ? c.amount || 0 : 0)), 0);
const totalCredit = model.transactions.reduce((a, c) =>
a + (c.credit || (c.type === CREDIT ? c.amount || 0 : 0)), 0);
if (totalDebit === 0 && totalCredit === 0)
throw new Error("At least one transaction must have a debit or credit");
if (Math.abs(totalDebit - totalCredit) > 0.001) // 1-millicent tolerance
throw new Error("Total debit and credit must be equal");
}So the two invariants are: not all-zero, and |Σdebit − Σcredit| < 0.001. (The admin's Formik schema mirrors this client-side and additionally requires min(2) lines + currency.)
4.2 How a line becomes a leg (addTransaction)
The single most important detail for a rebuild — amount storage convention and the shared relationId:
// journal/journal.service.ts → addTransaction
const relationId = this.getObjectId(); // ONE relationId for the WHOLE entry
for await (const trans of transactions) {
const amount = (trans.debit || trans.credit || trans.amount || 0) / (trans.exchangeRate || 1);
await this.transacSvc.create({
...trans,
ref: trans.ref || entry.ref,
refId: entry._id, // leg → header
status: entry.status,
relationId, // shared across all legs of this entry
accountId: trans.accountId,
amount, // ← stored in ACCOUNT currency (÷ rate)
kind: trans.kind || AccountTransactionKind.JournalEntry,
documentDate: DateUtils.startOfDateOnly(trans.documentDate || entry.documentDate || entry.createdAt),
type: trans.debit ? AccountTransactionTypes.DEBIT : AccountTransactionTypes.CREDIT,
});
}Key facts a rebuild must preserve:
- Which leg is debit vs credit: purely
trans.debit ? DEBIT : CREDIT. The user's grid decides. A line with a non-zerodebitbecomes a DEBIT leg; otherwise a CREDIT leg. - Amount is stored in account currency:
amount = entered / exchangeRate. The balance/total code re-multiplies by rate to get base currency. This is why the system-wide balance check only multiplies journal-kind legs by rate (other kinds are already base currency — double-converting would break them). See transaction.md and _overview.md §1.3. - All legs of one entry share one
relationId(the whole entry is one balanced group). Contrast with contra.md, where each line gets its own pair + relationId. documentDatefloored to UTC day so date-only rendering, month-sliced reports, and fiscal locks line up. Never store a raw instant.- Legs are stamped
kind = JournalEntry(unless the auto-journal supplied another kind).
4.3 Create flow (addEntry)
// journal/journal.service.ts → addEntry
this.validateEntry(model); // 1. balance check (throws if off)
await this.withRetryTransaction("add_journal_entry", async () => {
const entry = await this.entryRepo.create({ // 2a. header (ref="JN…", transactions:[])
type, ref, transactions: [], status, branchId, companyId, currencyId, createdBy, description,
documentDate: model.documentDate || DateUtils.startOfDateOnly(DateUtils.now()), // default today (UTC-day)
});
await this.addTransaction(entry, model.transactions); // 2b. write N balanced legs
await this.entryRepo.computeAndStoreTotals(createdId); // 2c. denormalise totalDebit/totalCredit
await this.accountSvc.validateBalanced("(Add Journal Entry)"); // 2d. whole-book balance assertion
});
return await this.findById(createdId);computeAndStoreTotals re-aggregates the legs (excluding TaxEntry) and $sets totalDebit / totalCredit on the header, each as Σ(amount × ifNull(exchangeRate, 1)) filtered by leg type.
accountSvc.validateBalanced asserts the entire company's books still balance after the write (tolerance 0.01); if not, the transaction rolls back. See transaction.md and _overview.md §1.3.
4.4 Update flow
update is a careful diff (source: journal.service.ts → update):
validateEntry(model)(balance re-checked).- Read the current entry before opening the write transaction (snapshot avoids holding the session through expensive nested
$lookups). - Reuse the existing
relationId(from any input line that already has_id), else mint a new one. - Deletes: any leg present in DB but absent from the input is bulk-deleted by
_id(transacSvc.deleteMany({ _id: { $in } })) — avoids the per-row relation walk indelete(). - Upserts: for each input line, recompute
amount = entered / exchangeRate, re-derivetypefromdebit, thentransacSvc.update(_id, …)if it has an_id, elsecreate(…). entryRepo.update(id, model)→computeAndStoreTotals→validateBalanced("(Update Journal Entry)").
4.5 Delete flow
// journal/journal.service.ts → delete
await this.withRetryTransaction("delete_journal_entry", async () => {
const entry = await this.findById(id);
if (!entry) throw new Error("Journal entry not found");
await this.transacSvc.deleteMany({ refId: entry._id }); // remove ALL legs of this header
await this.entryRepo.delete(id); // soft-delete the header
await this.accountSvc.validateBalanced("(Delete Journal Entry)");
});Because balances are aggregations, deleting the legs makes on-account balances self-correct. The book balance is re-asserted after the delete.
4.6 Reversal / void
There is no explicit reversal mutation (no "reverse this entry creating a mirror entry"). The correction model is direct:
- While SAVED: edit or delete freely (
update/delete). - To unwind a POSTED entry: either delete it (legs removed, balances self-correct — requires the delete to pass through; a posted entry can be deleted but a posted transaction cannot, see transaction.md
canDelete), or post a new opposite journal entry. Editing a POSTED entry requires theedit-postedaction (@GuardPostedEntry).
A "void" in zerp is functionally a delete (soft-delete the header + its legs) — there is no tombstone status. The audit trail (
@AuditMetaDELETE snapshot) records what was removed.
4.7 Status / state machine
createJournalEntry (status = SAVED | POSTED from input, default SAVED)
│
▼
┌────────┐ postJournalEntry / postManyJournalEntry
│ SAVED │ ───────────────────────────────────────────▶ ┌────────┐
│(draft) │ │ POSTED │
└────────┘ ◀──────────────────────────────────────────── │(final) │
│ saveJournalEntry* / saveManyJournalEntry└────────┘
│
edit/delete freely edit a POSTED entry → needs `edit-posted` (@GuardPostedEntry)
post into a LOCKED period → needs `post-to-locked-period` (@GuardLockedPeriod)
Both transitions update header and legs:
// post: header + every leg with this refId → POSTED (inside withRetryTransaction)
await this.transacSvc.updateMany({ refId: toObjectId(id) }, { status: POSTED });
await this.entryRepo.update(id, { status: POSTED });
// save: identical but → SAVEDpostJournalEntries/saveManyJournalEntries just loop the single-id version.
4.8 Transactionality
Every multi-leg write (addEntry, update, delete, postJournalEntry, saveJournalEntry) runs inside withRetryTransaction(name, …) — a single Mongo session with WriteConflict/Transient retry + exponential backoff. Header write, all leg writes, total denormalisation, and validateBalanced commit atomically; if the book doesn't balance, the whole thing rolls back. (Honoured only when mongdb_transaction_enabled === "true".) setSession propagates the session to transacSvc and accountSvc so nested calls join the open session rather than starting a new one.
confirmImportdeliberately does NOT wrapaddEntryin another transaction —addEntrymanages its own session + post-commit sessionless writes (computeAndStoreTotals,validateBalanced); nesting would deadlock the post-commit writes against the outer open session.
5. Permissions
Source: permission/permission.enum.ts, guards in finance/guards/.
| Module enum | value | covers |
|---|---|---|
ApModules.JOURNAL_ENTRIES |
journal-entries |
journal entries (default subject for both finance guards) |
Action enum (RoleActions) |
value | grants |
|---|---|---|
EDIT_POSTED |
edit-posted |
modify a POSTED entry — bypasses @GuardPostedEntry |
POST_LOCKED_PERIOD |
post-to-locked-period |
post into a locked fiscal period — bypasses @GuardLockedPeriod |
@GuardPostedEntry({ repositoryToken: JournalEntryRepository })onupdateJournalEntry: loads the entry by_idarg, blocks the mutation ifstatus === POSTEDunless the user holdsedit-postedonjournal-entries(CASL). Options defaultidArg='_id',action=EDIT_POSTED,module=JOURNAL_ENTRIES.@GuardLockedPeriod({ repositoryToken, module: JOURNAL_ENTRIES })onpostJournalEntry/postManyJournalEntry(the latter withidArg:"ids", isArray:true): blocks posting an entry whose legs fall in a LOCKED/CLOSED fiscal period unless the user holdspost-to-locked-period.- Additionally, the central chokepoint
AccountTransactionService.createcallsfiscalPeriodSvc.validateTransactionDate(documentDate, companyId)on every leg write — so even creating/editing a SAVED entry dated into a locked period is rejected. - Admin UI gates the Add button on
JOURNAL_ENTRIES.CREATEand Import onJOURNAL_ENTRIES.IMPORT_JOURNALS(ApAccessGuard).
Full RBAC model: ../../platform/permissions-access.md. Locked periods: ../../platform/workflow-approval-engine.md.
6. Flows
6.1 Create a balanced journal entry (happy path)
- Admin opens Journal Entry → Add Journal Entry →
CreateJournalEntrymodal (a Formik form with a debit/credit grid;journal/components/create.tsx). - User picks a date + currency, adds ≥ 2 lines, each with an account and a value in the Debit or Credit column. Formik blocks submit unless
|Σdebit − Σcredit| < 0.001. - Submit maps lines →
transactions[]({ accountId, debit, credit, amount, type, exchangeRate, remark, payeeId, costCenterId, classId, analysisCodeId, departmentId, taxIds, taxInclusive }) and callscreateJournalEntry/updateJournalEntry. - Resolver
createJournalEntry→JournalEntryService.addEntry:validateEntry→ (txn) create header (JN…) →addTransactionwrites one DEBIT/CREDIT leg per line (sharedrelationId,amount = entered/rate,kind = JournalEntry) →computeAndStoreTotals→validateBalanced. - Side effects: N rows in
finance_account_transactions(allrefId = header._id); any line carryingtaxId/taxIdsadditionally spawnsTaxEntrylegs (via the shared writer); audit CREATE snapshot.
6.2 Post / save (status transition)
- Admin selects rows → Post (or Save).
postManyJournalEntry(ids)/saveManyJournalEntry(ids). @GuardLockedPeriodchecks each entry's period (post only); resolver loopspostJournalEntry(id).- Service (txn):
transacSvc.updateMany({ refId }, { status })+entryRepo.update(id, { status }). - List + detail refetch;
canPostflips.
6.3 XLSX import
- Admin uploads a sheet (Import →
journals/import).importJournal(file)→JournalEntryService.importparses rows (flexible column names: Account/AccountNumber, Debit/Credit/Amount, Date, ExchangeRate, Memo, CostCenter/Class/AnalysisCode/Department), resolves each account/master, and returns a preview ([JournalImport]). Convention: Debit = positive amount, Credit = negative (sign carries direction). - User reviews the parsed preview, then
confirmJournalImport(ConfirmJournalImportInput)→confirmImport: validates eachdocumentDateagainst fiscal periods, then callsaddEntryonce, mapping each row to a line withtype = amount < 0 ? CREDIT : DEBITand[type.toLowerCase()] = Math.abs(amount). The same balance rule applies — an unbalanced import sheet throws.
6.4 Unhappy paths
- Unbalanced / all-zero lines:
validateEntrythrows (Total debit and credit must be equal/At least one transaction must have a debit or credit) before any DB write. - Whole-book imbalance after write:
validateBalancedthrowsAccount is not balanced→ txn rolls back. - Edit a POSTED entry without
edit-posted:@GuardPostedEntryrejects. - Post into a locked period without
post-to-locked-period:@GuardLockedPeriodrejects; and any leg dated into a locked period is rejected byvalidateTransactionDateregardless of status.
7. Admin UI
Source: zerp-admin/src/modules/finance/journal/.
- Page:
JournalEntryPage(page.tsx) — header withApDurationPicker(date range + XLSX/PDF download viaApDownloadButton2hittingjournal-entry/download), Add Journal Entry button (permission-gated), Import button. Filter bar: search, status (AccountTransactionStatus), cost-center / class / analysis-code / department master filters. Body rendersJournalEntryTable. - State:
context.tsx(useJournalEntryState) is the only consumer ofuseJournalEntryQuery(). Methods:journalEntryPage,findOneJournalEntry,getJournalSummary,createJournalEntry/updateJournalEntry(both viasaveJournalEntry(_id, payload)— create if no_id),deleteJournalEntry,deleteManyJournalEntry,postJournalEntry,postManyJournalEntry,saveManyJournalEntry,importJournal,confirmJournalImport. After every mutation it refetches the page + summary. - Create/edit form:
components/create.tsx— Formik (FormSchema) with a debit/credit grid (ApTable+ApAddRowButton), per-line account/payee/tax/cost-center/class/analysis-code/department selects,ApRateInputfor exchange rate, and a live debit/credit balance test that blocks submit. Inline account/customer/tax creation via theAp*Selectioncomponents. - Other components:
table.tsx,detail.tsx/detail-modal.tsx,summary.tsx/transactionSummary.tsx,import.tsx/confirmImport.tsx,export.tsx,journalTemplate.tsx.
8. Dependencies & integrations
- Calls into transaction (
AccountTransactionService.create/update/updateMany/ deleteMany) — the shared ledger writer;account(validateBalanced,findByIdfor the balance check);master(currency/cost-center/class/analysis-code/department lookups on import);exchange(rate conversion intotalDrAnCr);company(base currency);fiscal(validateTransactionDateon import). - Called by: other subsystems create journal entries programmatically (asset depreciation/disposal →
ASSET_DEPRECIATION/ASSET_DISPOSAL; manufacturing →MFG_*; order fixing →ORDER_FIXING), supplying pre-resolved legs. Thetypeenum is the discriminator for these auto-journals. - No cron/jobs/events owned by this module. XLSX parse/build via
zync-nest-library(XlsxUtils) andXlsxColumnUtils.
9. Gotchas & project-specific rules
transactionsis virtual. The header storestransactions: []; the real legs are joined by$lookuponrefId(excludingTaxEntry,$limit2000) at read time. Don't expect an embedded array.- Amount is stored ÷ exchangeRate (account currency). Totals/balances re-multiply by rate. Storing the entered (base) amount would double-count under FX. See §4.2.
- One
relationIdper entry, not per line — unlike contra. Edits reuse it. - Two balance tolerances: the per-journal check is tighter (0.001) than the system-wide
validateBalanced(0.01). documentDatedefaults to today (UTC-day floored). Never a raw instant — month reports and fiscal locks depend on the floor.- Import sign convention: Debit = positive, Credit = negative;
confirmImportsplits by sign. - No reversal mutation — correct by editing (SAVED), deleting, or posting an opposite entry.
totalDebit/totalCreditare denormalised and excludeTaxEntrylegs; the ledger remains the source of truth.backfillTotals()recomputes them across all entries via$merge.