Account Transactions — the GL ledger leg (and the shared writer)
The whole transaction module reduces to one idea:
One
AccountTransactionrow = one debit OR one credit against oneAccount. 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. Theamountis always positive; direction is carried bytype(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 childTaxEntrylegs. 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 sharerefId.relationId= "which legs balance me". Within one document there can be several independent balanced groups, each with its ownrelationId. Edits/deletes walkrelationIdto keep each group balanced. (A journal entry uses onerelationIdfor 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:
- Resolve the tax ids (
taxIds>taxes[]> singletaxId) viataxSvc.resolveLineTaxes(ids, amount, taxInclusive)→{ taxes[], baseAmount, totalAdditive, totalDeductive }. - Every resolved tax must have a GL
accountIdelse it throws (Tax "…" does not have a GL account configured). - Adjust the parent leg's amount:
- tax-inclusive:
amount = baseAmount − totalDeductive - tax-exclusive:
amount = baseAmount + totalAdditive − totalDeductive
- tax-inclusive:
- Snapshot the breakdown into
taxes[]and mirror the first intotaxId. - For each tax, write a
TaxEntryleg:parentId = parent leg _id, sharedrelationId, samedocumentDate/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)documentDateagainst fiscal periods; if the leg (non-tax) has/gets ataxId, it deletes oldTaxEntrychildren (deleteTaxEntriesByParentId) and recreates them (which can re-adjustamount); recomputespurityValue; thenrepo.update.updateWithRelations(model, relationAccountId?)(the GraphQLupdateAccountTransactionpath): refuses if the leg or any of itsrelationIdpartners isPOSTED(Posted transactions cannot be updated). For each partner it flipstypeto the opposite of the newmodel.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-assertsvalidateBalanced.delete(id, type="relations")(the GraphQLdeleteAccountTransactionpath):- Order legs (
itemId) andPOSTEDlegs are blocked viacanDelete. - If no
relationId: only an admin-group user can delete a standalone leg; otherwise throwsTransaction is not related to any other transaction. - Otherwise loads all
relationIdpartners, asserts the group has an even count and Σdebit == Σcredit, then deletes every partner. Re-assertsvalidateBalanced.
- Order legs (
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
exchangeRatemust NEVER zero out a posted amount — it is an invalid rate, not "multiply by zero".$ifNullalone does not catch0, hence the explicitsafeRate$cond.
Account balance (AccountService.balanceWithDrAnCr(accountId, {fromDate,toDate}, witExchangeRate)):
- Opening =
totalDrAnCrover[getDefaultOpeningDate(), fromDate − 1ms](only if afromDatewas supplied). - Current =
totalDrAnCrover the requested range. - Map each to a signed balance via the account's type (
mapBalance):balance = type.credit === "INCREASE" ? credits − debits : debits − credits. - 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/LoanRepaymentkinds 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'samount = entered / exchangeRatestorage 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 →createdByself. delete/findOneareignoreCompanyQuery+authNotRequired(the service re-checks tenant on post; delete re-checks viacanDelete).- The finance posted/locked guards (
@GuardPostedEntry,@GuardLockedPeriod) live on the header resolvers (journal/contra) keyed onApModules.JOURNAL_ENTRIES/CONTRA_ENTRIES. At the leg level the protections are:validateTransactionDate(period lock on every write) and the POSTED refusals inupdateWithRelations/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:
- Header service computes its lines and, for each, decides direction (often via
getTransactionType). - It calls
AccountTransactionService.create(leg)once per leg, passingrefId = header._id, a sharedrelationId(one for a journal entry; one per line for contra),kind,documentDate,status,amount,type. createvalidates the date, optionally spawnsTaxEntrylegs, computespurityValue, persists.- The header then re-asserts
accountSvc.validateBalanced.
6.2 Per-account statement page (bankAccountTransactionPage)
- Admin opens an account →
fetchTransaction({ accountId, fromDate, toDate, … }). - Resolver
page: ifaccountIdpresent it forcesstatus = POSTEDand computesbalanceWithDrAnCrPosted(accountId, {fromDate,toDate}); else company-widebalanceWithDrAnCr. transactionSvc.pageruns the running-balance + facet aggregation (data,totalRecords,totalPurityValue,totalDebits,totalCredits) withopeningBalanceseeded from the balance.- The resolver prepends a synthetic
OPENINGrow (only on page 1) carrying the opening balance, and buildssummary { opening, closing, totalDebits, totalCredits, type }.
6.3 XLSX import
importAccountTransaction(file)→importparses rows (Account by name, Payee by name, Amount or Debit/Credit columns, Date). Convention: debit = negative, credit = positive (amount = credit − debit). Returns a preview.confirmImportAccountTransaction(input)→confirmImport(txn): per rowcreate({ accountId, amount, documentDate, type: amount < 0 ? DEBIT : CREDIT, payeeId, relationId: new }).
6.4 Unhappy paths
- Date in a locked period:
validateTransactionDatethrows on any create/update. - Tax without GL account:
createTaxEntriesthrows. - 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:
validateBalancedthrows → 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 ofuseAccountTransactionQuery(). Methods:fetchTransaction,findOneTransaction,findCategories,addPettyCash,deleteTransaction,updateTransaction,postTransaction,importTransaction,confirmTransactionImport. After mutations it refetches the page (delete) or patches local state (post/update). Exposessummary,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/companyindirectly for FX in balance code. - No cron/jobs/events owned here. XLSX via
XlsxUtils/XlsxColumnUtils.
9. Gotchas & project-specific rules
OPENINGis synthetic.AccountTransactionTypes.OPENINGis injected as a display-only "Opening Balance" row by the page resolver — it is never persisted. Don't aggregate it.- No
createAccountTransactionmutation. Legs are born from document headers or import only. - Amount is always positive; sign is
type. Thedebit/creditGraphQL fields are derived splits, not stored. - 0/null exchangeRate ⇒ treated as 1 in
totalDrAnCr— a real, load-bearing bug fix; replicate thesafeRate$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'samount = entered / ratestorage rule. refId≠relationId.refId= document header;relationId= balancing group. A journal shares onerelationIdacross all legs; a contra uses one per line. WalkrelationIdfor balanced edits/deletes.TaxEntrylegs are hidden everywhere (kind != TaxEntryfilter in every lookup) and excluded from totals; they carryparentIdback to the spawning leg.- Order/tax legs are immutable via this module (
itemId⇒ blocked incanDelete/canUpdate). timestamps.createdAtis disabled — usedocumentDatefor chronology, notcreatedAt.Account.findByIdbypasses the company filter (global_id) so cross-company debtor/creditor lookups work — relevant when resolvingaccountfor 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.