Journal Entries — balanced manual/auto GL postings

The whole journal module reduces to one idea:

A JournalEntry is a header that groups N balanced AccountTransaction legs. 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 by refId and grouped together by a single shared relationId. 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/totalCredit onto the header for list views.

It does NOT:

  • Own the ledger leg itself — that is AccountTransaction (see transaction.md); the journal service only orchestrates AccountTransactionService.create/update/deleteMany.
  • Decide debit-vs-credit for you — for manual entries the user supplies debit/credit directly. (Auto-generated journals from other subsystems, e.g. ASSET_DEPRECIATION, supply legs already resolved via getTransactionType; see transaction.md §getTransactionType.)
  • Generate tax legs — manual journal legs carry taxId/taxIds and the shared transaction writer spawns the TaxEntry legs (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.

status lives on both header and legs. The header JournalEntry.status and every AccountTransaction.status for that refId are 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 debit or credit (mutually exclusive). The service derives the ledger leg's type from 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-zero debit becomes 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.
  • documentDate floored 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):

  1. validateEntry(model) (balance re-checked).
  2. Read the current entry before opening the write transaction (snapshot avoids holding the session through expensive nested $lookups).
  3. Reuse the existing relationId (from any input line that already has _id), else mint a new one.
  4. 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 in delete().
  5. Upserts: for each input line, recompute amount = entered / exchangeRate, re-derive type from debit, then transacSvc.update(_id, …) if it has an _id, else create(…).
  6. entryRepo.update(id, model)computeAndStoreTotalsvalidateBalanced("(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 the edit-posted action (@GuardPostedEntry).

A "void" in zerp is functionally a delete (soft-delete the header + its legs) — there is no tombstone status. The audit trail (@AuditMeta DELETE 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 → SAVED

postJournalEntries/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.

confirmImport deliberately does NOT wrap addEntry in another transactionaddEntry manages 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 }) on updateJournalEntry: loads the entry by _id arg, blocks the mutation if status === POSTED unless the user holds edit-posted on journal-entries (CASL). Options default idArg='_id', action=EDIT_POSTED, module=JOURNAL_ENTRIES.
  • @GuardLockedPeriod({ repositoryToken, module: JOURNAL_ENTRIES }) on postJournalEntry / postManyJournalEntry (the latter with idArg:"ids", isArray:true): blocks posting an entry whose legs fall in a LOCKED/CLOSED fiscal period unless the user holds post-to-locked-period.
  • Additionally, the central chokepoint AccountTransactionService.create calls fiscalPeriodSvc.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.CREATE and Import on JOURNAL_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)

  1. Admin opens Journal Entry → Add Journal EntryCreateJournalEntry modal (a Formik form with a debit/credit grid; journal/components/create.tsx).
  2. 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.
  3. Submit maps lines → transactions[] ({ accountId, debit, credit, amount, type, exchangeRate, remark, payeeId, costCenterId, classId, analysisCodeId, departmentId, taxIds, taxInclusive }) and calls createJournalEntry / updateJournalEntry.
  4. Resolver createJournalEntryJournalEntryService.addEntry: validateEntry → (txn) create header (JN…) → addTransaction writes one DEBIT/CREDIT leg per line (shared relationId, amount = entered/rate, kind = JournalEntry) → computeAndStoreTotalsvalidateBalanced.
  5. Side effects: N rows in finance_account_transactions (all refId = header._id); any line carrying taxId/taxIds additionally spawns TaxEntry legs (via the shared writer); audit CREATE snapshot.

6.2 Post / save (status transition)

  1. Admin selects rows → Post (or Save). postManyJournalEntry(ids) / saveManyJournalEntry(ids).
  2. @GuardLockedPeriod checks each entry's period (post only); resolver loops postJournalEntry(id).
  3. Service (txn): transacSvc.updateMany({ refId }, { status }) + entryRepo.update(id, { status }).
  4. List + detail refetch; canPost flips.

6.3 XLSX import

  1. Admin uploads a sheet (Importjournals/import). importJournal(file)JournalEntryService.import parses 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).
  2. User reviews the parsed preview, then confirmJournalImport(ConfirmJournalImportInput)confirmImport: validates each documentDate against fiscal periods, then calls addEntry once, mapping each row to a line with type = amount < 0 ? CREDIT : DEBIT and [type.toLowerCase()] = Math.abs(amount). The same balance rule applies — an unbalanced import sheet throws.

6.4 Unhappy paths

  • Unbalanced / all-zero lines: validateEntry throws (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: validateBalanced throws Account is not balanced → txn rolls back.
  • Edit a POSTED entry without edit-posted: @GuardPostedEntry rejects.
  • Post into a locked period without post-to-locked-period: @GuardLockedPeriod rejects; and any leg dated into a locked period is rejected by validateTransactionDate regardless of status.

7. Admin UI

Source: zerp-admin/src/modules/finance/journal/.

  • Page: JournalEntryPage (page.tsx) — header with ApDurationPicker (date range + XLSX/PDF download via ApDownloadButton2 hitting journal-entry/download), Add Journal Entry button (permission-gated), Import button. Filter bar: search, status (AccountTransactionStatus), cost-center / class / analysis-code / department master filters. Body renders JournalEntryTable.
  • State: context.tsx (useJournalEntryState) is the only consumer of useJournalEntryQuery(). Methods: journalEntryPage, findOneJournalEntry, getJournalSummary, createJournalEntry / updateJournalEntry (both via saveJournalEntry(_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, ApRateInput for exchange rate, and a live debit/credit balance test that blocks submit. Inline account/customer/tax creation via the Ap*Selection components.
  • 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, findById for the balance check); master (currency/cost-center/class/analysis-code/department lookups on import); exchange (rate conversion in totalDrAnCr); company (base currency); fiscal (validateTransactionDate on 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. The type enum is the discriminator for these auto-journals.
  • No cron/jobs/events owned by this module. XLSX parse/build via zync-nest-library (XlsxUtils) and XlsxColumnUtils.

9. Gotchas & project-specific rules

  • transactions is virtual. The header stores transactions: []; the real legs are joined by $lookup on refId (excluding TaxEntry, $limit 2000) 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 relationId per 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).
  • documentDate defaults 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; confirmImport splits by sign.
  • No reversal mutation — correct by editing (SAVED), deleting, or posting an opposite entry.
  • totalDebit/totalCredit are denormalised and exclude TaxEntry legs; the ledger remains the source of truth. backfillTotals() recomputes them across all entries via $merge.