Account Transactions — the GL ledger leg (and the shared writer)

The whole transaction module reduces to one idea:

One AccountTransaction row = one debit OR one credit against one Account. It is the atom of the general ledger — every financial event in zerp (journal, contra, sales, purchase, payment, cashbook, note, trade, asset, stock, tax, …) ultimately becomes a set of these rows. The amount is always positive; direction is carried by type (DEBIT/CREDIT). No balance is ever stored — an account's balance is the aggregation Σ(debits) vs Σ(credits), signed by the account's type. This module also owns the shared writer (AccountTransactionService.create/update/delete) that every document subsystem calls, plus tax-leg generation, relation-aware edits, posting, fiscal-date validation, and all balance aggregations.

Source: BE src/modules/finance/transaction · Admin src/modules/finance/transaction

See _overview.md for the double-entry model, journal.md for the manual header, and contra.md for the two-leg transfer.


1. Purpose & scope

The transaction module is responsible for:

  • The ledger leg schema (finance_account_transactions) and its indexes.
  • The single shared writer every subsystem uses: create / update / updateWithRelations / delete / deleteByRelationId / deleteOrderTransactions.
  • Tax-leg generation — when a leg carries taxId/taxIds/taxes, it spawns child TaxEntry legs.
  • relationId-aware update/delete that keeps each balanced group internally consistent.
  • Posting (postAccountTransaction) and fiscal-date validation on every write.
  • Balance aggregations: totalDrAnCr, totalAmount, running balance, per-category batch.
  • The "bank account transaction page" (per-account statement with opening balance + running balance).
  • getTransactionType(accountId, INCREASE|DECREASE) — the helper higher-level flows use to decide which leg is debit vs credit for a given account, without hardcoding.

It does NOT:

  • Define the chart of accounts (that is account/category).
  • Own document headers — those live in journal, contra, orders, payments, etc. This module is called by them.
  • Store balances — all balances are aggregations (§4.5).

2. Data model

Collection finance_account_transactions — the ledger leg

Source: transaction/transaction.schema.ts. The class is a Mongoose discriminator keyed on kind, and timestamps.createdAt is disabled (the leg uses documentDate, not createdAt, for chronology).

field type required description
_id, ref, companyId, branchId, documentCode, updatedAt/By, canView/... from BaseSchema. ref generated by prefix (PIV/SIV/TRN — see §3). documentCode defaults to ref.
accountId ObjectId yes the GL account this leg hits.
type AccountTransactionTypes DEBIT / CREDIT / OPENING. default CREDIT. (OPENING is synthetic — never persisted, see §9.)
amount number absolute value; sign is carried by type.
kind AccountTransactionKind discriminator: which subsystem wrote this leg. default SalesInvoice.
status AccountTransactionStatus SAVED / POSTED. default SAVED.
documentDate number (unix ms) the date used for period locking, date-range balances, and running-balance order.
exchangeRate number rate to base currency. default 1. A 0/null rate is coerced to 1 when summing (§4.5).
refId ObjectId FK → the parent document header (JournalEntry/ContraEntry/Order/…). All legs of one document share refId.
ref2Id ObjectId secondary parent ref (used by some flows, e.g. asset paired entries).
relationId ObjectId groups a leg with its balancing partner(s) — the legs that must net to zero.
parentId ObjectId a TaxEntry leg points to the transaction leg that spawned it.
invoiceId ObjectId links a payment/knockoff leg to the invoice it settles.
itemId ObjectId for order legs, the order_items line. Order legs cannot be edited/deleted via this module (canDelete/canUpdate).
payeeId ObjectId subdivides a debtor/creditor account by counterparty (the AR/AP sub-ledger key).
taxId ObjectId primary tax (mirrors taxes[0]).
taxInclusive boolean whether amount already includes tax. default false.
taxes TransactionLineTax[] persisted multi-tax breakdown (snapshot).
taxIds string[] transient input only (not persisted) — consumer passes selected tax ids; createTaxEntries resolves them into taxes[].
costCenterId, classId, analysisCodeId, departmentId ObjectId analysis dimensions (all masters).
cashflowCategory CashFlowCategory INVESTING/FINANCING/OPERATING/UNCLASSIFIED. default UNCLASSIFIED.
paymentType AccountPaymentTypes CASH/BANK/STOCK. default CASH.
purity, purityValue, amount2, bankCharges number gold/jewellery + bank-charge extensions. purityValue = amount × purity / 100 (computed on write).
receiptIds ObjectId[] attached file uploads.
isBill boolean flags a bill leg. default false.
remark string free-text memo.

Non-persisted/derived (DTO-only, resolved at read time): credit, debit (split of amount by type), balance, openingBalance, relations, relationBalanced, account, costCenter, class, analysisCode, department, tax, payee, invoice, receipts, refOrRef2Id, fromDate/toDate.

// transaction/transaction.schema.ts
export enum AccountTransactionTypes { CREDIT = "CREDIT", DEBIT = "DEBIT", OPENING = "OPENING" }
export enum AccountTransactionStatus { SAVED = "SAVED", POSTED = "POSTED" }
export enum AccountPaymentTypes { CASH = "CASH", BANK = "BANK", STOCK = "STOCK" }

export enum AccountTransactionKind {
  OrderPayment, OrderPayment2, SalesInvoice, PurchaseRequisition, PurchaseInvoice,
  CashBookEntry, StockAdjustment, StockTransfer, NoteEntry, PaymentEntry, JournalEntry,
  AssetEntry, ContraEntry, TradeEntry, KnockoffEntry, SalesReturnOrder, PurchaseReturnOrder,
  TaxEntry, AdvanceTransaction, LoanRepayment
}   // (string enum — values equal the keys)

@ApSchema({
  collection: `finance_account_transactions`,
  timestamps: { createdAt: false },
  discriminatorKey: "kind"
})
export class AccountTransaction extends AccountTransactionEntity {
  @Prop({ type: String, enum: AccountTransactionKind, default: AccountTransactionKind.SalesInvoice })
  kind: string;
  // ...
}
// embedded tax breakdown
export class TransactionLineTax {
  taxId: ObjectId; name: string; percentage: number;
  direction: string;   // LineTaxDirection ADDITIVE | DEDUCTIVE
  amount: number; accountId: ObjectId;
}

Indexes: accountId; refId; ref2Id; relationId; status; kind; the dimensions; { companyId, accountId, date }, { companyId, date }, documentDate, { companyId, documentDate:-1 } (default list sort), { companyId, accountId, documentDate:-1 } (per-account statement), { accountId, refId }, { accountId, ref2Id } (asset lookups).

Soft delete: plugin(SoftDelete, { deletedAt, deletedBy }) — soft-deleted legs drop out of every aggregation, so balances self-correct on delete.

Tenant scoping: companyId auto-injected by AbstractBaseRepository. Row-level scoping (buildQuery): privileged users see all; Customer/Supplier see only their linked account's legs (accountId = userAccountId); other non-privileged users see only legs they created (createdBy).

refId vs relationId (the two grouping keys)

  • refId = "which document header do I belong to". All legs of a journal/contra/order share refId.
  • relationId = "which legs balance me". Within one document there can be several independent balanced groups, each with its own relationId. Edits/deletes walk relationId to keep each group balanced. (A journal entry uses one relationId for the whole entry; a contra entry uses one per line — see journal.md / contra.md.)

3. API surface

All resolvers are @ApGqlAuthorize(); mutations carry @AuditMeta({ module: 'transaction', collection: 'finance_account_transactions', ... }). Source: transaction/transaction.resolver.ts.

Operation Type Input Returns Notes
bankAccountTransactionPage Query AccountTransactionPageInput AccountTransactionPageResult per-account statement with opening row + running balance + summary
findAccountTransactions Query AccountTransactionQueryInput [AccountTransaction] sorted by documentDate, running balance
findOneAccountTransaction Query AccountTransactionQueryInput AccountTransaction authNotRequired, ignoreCompanyQuery
updateAccountTransaction Mutation transaction: UpdateAccountTransactionInput AccountTransaction canUpdate(_id, true) then updateWithRelations
deleteAccountTransaction Mutation _id Boolean authNotRequired, ignoreCompanyQuery; relation-aware delete
postAccountTransaction Mutation _id Boolean posts the leg + all relations
importAccountTransaction Mutation import: ImportTransactionInput { file } [ImportedTransaction] XLSX parse → preview
confirmImportAccountTransaction Mutation import: ConfirmAccountTransactionImportInput Boolean writes legs

There is no createAccountTransaction GraphQL mutation — legs are created only through document headers (journal, contra, payment, …) or the import flow. The shared AccountTransactionService.create is the internal entry point.

Resolve-fields: debit (type==DEBIT ? amount : 0), credit (type==CREDIT ? amount : 0), canDelete, canUpdate, account, payee, invoice, receipts, costCenter, class, analysisCode, department, tax, and relations (@deptLimit(1); legs sharing relationId, excluding self; legacy asset fallback by refId+AssetEntry).

GraphQL types (generated, src/schema.gql)

type AccountTransaction {
  _id: String  companyId: String  branchId: String  ref: String  documentDate: Float
  refId: String  ref2Id: String  itemId: String  payeeId: String  relationId: String
  accountId: String!  costCenterId/classId/analysisCodeId/departmentId: String (+ joined Master)
  taxId: String  tax: Taxation  taxInclusive: Boolean  taxes: [AccountTransactionLineTax!]
  invoiceId: String  account: Account
  amount: Float!  debit: Float  credit: Float  purity: Float  purityValue: Float  balance: Float
  bankCharges: Float  exchangeRate: Float  remark: String
  type: AccountTransactionTypes!  status: AccountTransactionStatus
  paymentType: AccountPaymentTypes  relations: [AccountTransaction!]  kind: String  payee: User
  cashflowCategory: CashFlowCategory  invoice: AccountTransactionInvoice  relationBalanced: Boolean
}
enum AccountTransactionTypes { CREDIT  DEBIT  OPENING }
enum AccountTransactionStatus { SAVED  POSTED }
enum AccountPaymentTypes { CASH  BANK  STOCK }
enum AccountTransactionKind { OrderPayment OrderPayment2 SalesInvoice PurchaseRequisition
  PurchaseInvoice CashBookEntry StockAdjustment StockTransfer NoteEntry PaymentEntry JournalEntry
  AssetEntry ContraEntry TradeEntry KnockoffEntry SalesReturnOrder PurchaseReturnOrder TaxEntry
  AdvanceTransaction LoanRepayment }

type AccountTransactionPageResult {
  totalRecords: Float!  totalPurityValue: Float
  summary: AccountTransactionSummary  data: [AccountTransaction!]!
}
type AccountTransactionSummary { opening: Float  closing: Float  totalDebits: Float  totalCredits: Float  type: String }

REST

GET /api/transactions/download?downloadType=xlsx (transaction.controller.ts, @ApiAuthorize) — flattens legs to XLSX rows (Ref, Document No, Account, Status, Remark, Date, Credit, Debit, Amount (signed: debit negative — re-importable), Balance, Cost Center, Class, Analysis Code).


4. Business rules & calculations

4.1 getTransactionType — business intent → debit/credit

This is the chokepoint that turns "I want to increase/decrease this account" into the correct leg type for that account's normal balance (no hardcoding per account):

// transaction/transaction.service.ts
public async getTransactionType(accountId, type: "INCREASE" | "DECREASE"): Promise<AccountTransactionTypes> {
  const accountType = await this.accountSvc.getType(accountId);   // { type, debit:"INCREASE"|"DECREASE", credit:... }
  return accountType.debit === type ? AccountTransactionTypes.DEBIT : AccountTransactionTypes.CREDIT;
}

Worked example: an ASSET account has debit: "INCREASE". To increase it → getTransactionType(asset, "INCREASE") === DEBIT. To increase a LIABILITY (debit: "DECREASE") → CREDIT. The normal-balance table (ACCOUNT_TYPES in finance.model.ts) is reproduced in _overview.md §1.1. getUserTransactionType(userId, …) is the debtor/creditor variant: it resolves the user's AR/AP account (accountSvc.getUserAccount) and returns { type, accountId }.

4.2 create — the shared writer

// transaction/transaction.service.ts → create
public async create(model): Promise<AccountTransaction> {
  if (model.documentDate != null)
    await this.fiscalPeriodSvc.validateTransactionDate(model.documentDate, model.companyId?.toString());  // period lock
  return await this.withRetryTransaction("create_transaction", async () => {
    const hasTax = !!model.taxId || !!model.taxIds?.length || !!model.taxes?.length;
    if (hasTax && model.kind !== TaxEntry && !ORDER_TAX_KINDS.has(model.kind))
      await this.createTaxEntries(model);                        // spawn child TaxEntry legs (non-order kinds)
    model.purityValue = ((model.amount || 0) * (model.purity || 0)) / 100;
    return await super.create(model);
  });
}
// ORDER_TAX_KINDS = { SalesInvoice, PurchaseInvoice, SalesReturnOrder, PurchaseReturnOrder } — they handle their own tax.

AbstractBaseRepository.create stamps ref (prefix by kind: PurchaseInvoice→PIV, SalesInvoice→SIV, else TRN) and documentCode = ref.

4.3 Tax legs (createTaxEntries)

When a non-order leg carries tax, child TaxEntry legs are spawned:

  1. Resolve the tax ids (taxIds > taxes[] > single taxId) via taxSvc.resolveLineTaxes(ids, amount, taxInclusive){ taxes[], baseAmount, totalAdditive, totalDeductive }.
  2. Every resolved tax must have a GL accountId else it throws (Tax "…" does not have a GL account configured).
  3. Adjust the parent leg's amount:
    • tax-inclusive: amount = baseAmount − totalDeductive
    • tax-exclusive: amount = baseAmount + totalAdditive − totalDeductive
  4. Snapshot the breakdown into taxes[] and mirror the first into taxId.
  5. For each tax, write a TaxEntry leg: parentId = parent leg _id, shared relationId, same documentDate/status, accountId = tax.accountId, amount = tax.amount, and: for ADDITIVE tax, type = parent type; for DEDUCTIVE (withholding) tax, type = opposite of parent type (this is what makes withholding net correctly).

TaxEntry legs are excluded from journal totals and document line displays (every $lookup filters kind != TaxEntry). They are cleaned up by deleteTaxEntriesByParentId / deleteTaxEntriesByRelationId on edit/delete.

4.4 update / updateWithRelations / delete — relation-aware edits

  • update(id, model) (single leg, no session): validates the (possibly inherited) documentDate against fiscal periods; if the leg (non-tax) has/gets a taxId, it deletes old TaxEntry children (deleteTaxEntriesByParentId) and recreates them (which can re-adjust amount); recomputes purityValue; then repo.update.
  • updateWithRelations(model, relationAccountId?) (the GraphQL updateAccountTransaction path): refuses if the leg or any of its relationId partners is POSTED (Posted transactions cannot be updated). For each partner it flips type to the opposite of the new model.type (keeping the pair balanced), propagates date/dimension/tax/amount changes, re-derives each partner's tax legs, then updates the main leg and re-asserts validateBalanced.
  • delete(id, type="relations") (the GraphQL deleteAccountTransaction path):
    • Order legs (itemId) and POSTED legs are blocked via canDelete.
    • If no relationId: only an admin-group user can delete a standalone leg; otherwise throws Transaction is not related to any other transaction.
    • Otherwise loads all relationId partners, asserts the group has an even count and Σdebit == Σcredit, then deletes every partner. Re-asserts validateBalanced.
  • deleteByRelationId(relationId) / deleteOrderTransactions(refId) — bulk helpers used by document subsystems to unwind a balanced group or all legs of an order.

4.5 Balance aggregations (no stored balance)

// transaction/transaction.repository.ts → totalDrAnCr(query, witExchangeRate=false)
const safeRate = { $cond: [{ $or: [eq(rate,0), eq(rate,null)] }, 1, "$exchangeRate"] };  // 0/null → 1
debits  = Σ amount where type == "DEBIT"   (× safeRate when witExchangeRate)
credits = Σ amount where type == "CREDIT"  (× safeRate when witExchangeRate)

A 0/null exchangeRate must NEVER zero out a posted amount — it is an invalid rate, not "multiply by zero". $ifNull alone does not catch 0, hence the explicit safeRate $cond.

Account balance (AccountService.balanceWithDrAnCr(accountId, {fromDate,toDate}, witExchangeRate)):

  1. Opening = totalDrAnCr over [getDefaultOpeningDate(), fromDate − 1ms] (only if a fromDate was supplied).
  2. Current = totalDrAnCr over the requested range.
  3. Map each to a signed balance via the account's type (mapBalance): balance = type.credit === "INCREASE" ? credits − debits : debits − credits.
  4. Return { type, debits, credits, balance, openingBalance, closingBalance }.

balanceWithDrAnCrPosted adds status: POSTED. balanceWithDrAnCrCategory[Posted] does the same per chart-of-accounts category. totalDrAnCrByCategoryBatch batches POSTED dr/cr by account.categoryId.

Running balance (the per-account statement): a $setWindowFields window over documentDate, _id adds runningBalance = Σ(DEBIT ? +amount : −amount) (unbounded → current), offset by the supplied openingBalance. Computed on the full filtered set before pagination so each row reflects full history. (Sign convention here is fixed debit-positive, independent of account type — it is a movement view, not a normal-balance view.)

4.6 System-wide balance check (accountBalanced / validateBalanced)

After every multi-leg write, the calling service asserts the whole company's books balance:

// account/account.service.ts → accountBalanced
{ debits, credits } = totalDrAnCr(
  { companyId, kind: { $in: [JournalEntry, AdvanceTransaction, LoanRepayment] } },
  /* witExchangeRate */ true
);
balanced = Math.abs(debits − credits) < 0.01;   // 1-kobo tolerance
// validateBalanced → throws HttpException "Account is not balanced" (NOT_ACCEPTABLE) if not.

Only JournalEntry/AdvanceTransaction/LoanRepayment kinds are multiplied by exchangeRate — those are stored in account currency (amount = base ÷ rate). All other kinds are stored in base currency already; multiplying them by rate would double-convert and throw the trial balance off. This is the dual of the journal's amount = entered / exchangeRate storage rule (see journal.md §4.2).

4.7 Posting (postTransaction)

// transaction/transaction.service.ts → postTransaction
if (transaction.status === POSTED) return;                              // idempotent
if (transaction.companyId !== contextSvc.companyId) throw Forbidden;    // tenant guard
if (transaction.relationId)
  // post the WHOLE balanced group together
  related = find({ relationId }); await Promise.all(related.map(tx => repo.update(tx._id, { status: POSTED })));
else
  repo.update(id, { status: POSTED });

A document header (journal/contra) posts its legs via updateMany({ refId }, { status }) instead; this per-leg path posts the relationId group.

4.8 Status / state machine

   create (status from model — typically SAVED)
        │
        ▼
   ┌────────┐   postAccountTransaction (this leg + its relationId group)
   │ SAVED  │ ──────────────────────────────────────────────────────────▶ ┌────────┐
   │(draft) │                                                              │ POSTED │
   └────────┘   (no per-leg un-post mutation; header-level save reverts)    └────────┘
        │
   editable / deletable      POSTED: updateWithRelations & delete both refuse
   (unless order/tax leg)     ("Posted transactions cannot be updated/deleted")

4.9 Transactionality

create, updateWithRelations, delete, deleteByRelationId, deleteOrderTransactions, confirmImport each run inside withRetryTransaction(...) (single Mongo session, WriteConflict/ Transient retry + backoff). Nested calls (e.g. a journal that calls transacSvc.create per line) join the already-open session via setSession rather than opening a new one — so the header, all legs, tax legs, and the final validateBalanced commit atomically or roll back together. (Honoured only when mongdb_transaction_enabled === "true".)


5. Permissions

  • Resolvers are @ApGqlAuthorize(); mutations @AuditMeta({ module:'transaction', ... }).
  • Row-level scoping is enforced in transaction.repository.ts → buildQuery (not a guard): privileged → all; Customer/Supplier → own account; other non-privileged → createdBy self.
  • delete/findOne are ignoreCompanyQuery + authNotRequired (the service re-checks tenant on post; delete re-checks via canDelete).
  • The finance posted/locked guards (@GuardPostedEntry, @GuardLockedPeriod) live on the header resolvers (journal/contra) keyed on ApModules.JOURNAL_ENTRIES / CONTRA_ENTRIES. At the leg level the protections are: validateTransactionDate (period lock on every write) and the POSTED refusals in updateWithRelations/delete/canDelete/canUpdate.

Full RBAC: ../../platform/permissions-access.md. Period locks: ../../platform/workflow-approval-engine.md.


6. Flows

6.1 How a document emits legs (the universal pattern)

Every document subsystem follows the same shape — see journal.md §4.2 and contra.md §4 for the concrete derivations:

  1. Header service computes its lines and, for each, decides direction (often via getTransactionType).
  2. It calls AccountTransactionService.create(leg) once per leg, passing refId = header._id, a shared relationId (one for a journal entry; one per line for contra), kind, documentDate, status, amount, type.
  3. create validates the date, optionally spawns TaxEntry legs, computes purityValue, persists.
  4. The header then re-asserts accountSvc.validateBalanced.

6.2 Per-account statement page (bankAccountTransactionPage)

  1. Admin opens an account → fetchTransaction({ accountId, fromDate, toDate, … }).
  2. Resolver page: if accountId present it forces status = POSTED and computes balanceWithDrAnCrPosted(accountId, {fromDate,toDate}); else company-wide balanceWithDrAnCr.
  3. transactionSvc.page runs the running-balance + facet aggregation (data, totalRecords, totalPurityValue, totalDebits, totalCredits) with openingBalance seeded from the balance.
  4. The resolver prepends a synthetic OPENING row (only on page 1) carrying the opening balance, and builds summary { opening, closing, totalDebits, totalCredits, type }.

6.3 XLSX import

  1. importAccountTransaction(file)import parses rows (Account by name, Payee by name, Amount or Debit/Credit columns, Date). Convention: debit = negative, credit = positive (amount = credit − debit). Returns a preview.
  2. confirmImportAccountTransaction(input)confirmImport (txn): per row create({ accountId, amount, documentDate, type: amount < 0 ? DEBIT : CREDIT, payeeId, relationId: new }).

6.4 Unhappy paths

  • Date in a locked period: validateTransactionDate throws on any create/update.
  • Tax without GL account: createTaxEntries throws.
  • Edit/delete a POSTED leg: refused. Delete an order/tax leg: refused (canDelete).
  • Delete an unbalanced/odd relation group: throws (Debit and credit amount is not equal / One of the transaction is not deleted).
  • Whole-book imbalance after write: validateBalanced throws → rollback.

7. Admin UI

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

  • Page: page.tsx — the bank/account transactions list (per-account statement). Header: search, status, date range, cost-center/class/analysis-code filters, XLSX/PDF download (api/transactions/download).
  • State: context.tsx (useTransactionState) — the only consumer of useAccountTransactionQuery(). Methods: fetchTransaction, findOneTransaction, findCategories, addPettyCash, deleteTransaction, updateTransaction, postTransaction, importTransaction, confirmTransactionImport. After mutations it refetches the page (delete) or patches local state (post/update). Exposes summary, totalRecords, totalPurityValue.
  • Components: detail.tsx (leg detail + relations + receipts), import/confirm flows, credit/debit modals. Note: there is no create-transaction form — legs are created through document modules (journal, contra, payment, cashbook) or import.

8. Dependencies & integrations

  • Called by (the whole point of the module): journal, contra, payment, cashbook, note, trade, asset, order (sales/purchase/returns), stock adjustment/transfer, knockoff, advance, loan — every subsystem that posts to the GL. Each passes its own kind.
  • Calls: account (getType, getUserAccount, validateBalanced); category (normal-balance type, via account); taxation (resolveLineTaxes); fiscal (validateTransactionDate); upload (receipts); user (payee resolution); order (invoice/order item joins); exchange/company indirectly for FX in balance code.
  • No cron/jobs/events owned here. XLSX via XlsxUtils / XlsxColumnUtils.

9. Gotchas & project-specific rules

  • OPENING is synthetic. AccountTransactionTypes.OPENING is injected as a display-only "Opening Balance" row by the page resolver — it is never persisted. Don't aggregate it.
  • No createAccountTransaction mutation. Legs are born from document headers or import only.
  • Amount is always positive; sign is type. The debit/credit GraphQL fields are derived splits, not stored.
  • 0/null exchangeRate ⇒ treated as 1 in totalDrAnCr — a real, load-bearing bug fix; replicate the safeRate $cond, not a bare $ifNull.
  • Only journal-kind legs are FX-multiplied in accountBalanced (they are stored in account currency). Other kinds are base-currency; multiplying them double-converts. This pairs with the journal's amount = entered / rate storage rule.
  • refIdrelationId. refId = document header; relationId = balancing group. A journal shares one relationId across all legs; a contra uses one per line. Walk relationId for balanced edits/deletes.
  • TaxEntry legs are hidden everywhere (kind != TaxEntry filter in every lookup) and excluded from totals; they carry parentId back to the spawning leg.
  • Order/tax legs are immutable via this module (itemId ⇒ blocked in canDelete/canUpdate).
  • timestamps.createdAt is disabled — use documentDate for chronology, not createdAt.
  • Account.findById bypasses the company filter (global _id) so cross-company debtor/creditor lookups work — relevant when resolving account for a leg.
  • Running balance is debit-positive regardless of account type — it's a movement ledger view, not the signed normal-balance from mapBalance. Don't conflate the two.