Taxation — tax definitions & the line-tax computation engine
The whole taxation module reduces to one idea:
A
Taxationrow is just a named rate pointed at a GL account; all the intelligence lives in one pure function —computeLineTaxes(amount, taxes, inclusive)— that turns an amount + a set of taxes into{ base, additive, deductive, lineTotal }, and the transaction ledger turns each computed tax into its own balancedTaxEntryGL leg. A tax isADDITIVE(adds on top, e.g. VAT) orDEDUCTIVE(withheld, e.g. WHT). Whether a tax is deductive is not stored on the tax — it is derived from the tax's type master (key === "wht").
Source: BE finance/taxation · Admin taxation · Route pages/taxation.tsx
1. Purpose & scope
The module owns:
- The tax catalogue: a
Taxationdocument ={ name, percentage, typeId (→ tax_type master), accountId (→ GL account) }. CRUD + paged list + XLSX export. - The computation engine (
taxation.model.ts):computeLineTaxesand helpers (lineAdditiveTax,lineNetBase) — pure, side-effect-free math used everywhere tax appears. - The resolution layer (
taxation.service.ts):resolveLineTaxes(load tax docs by id → tag each ADDITIVE/DEDUCTIVE → run the math),applyLineTaxes(mutate a model with thetaxes[]snapshot), andisWithholdingTax(the WHT detector, cached).
It explicitly does not:
- Post any GL legs itself. Tax legs (
kind = TaxEntry) are written by the transaction service (createTaxEntries, for non-order kinds) and the order transaction builder (order.transaction.ts → buildTaxEntry, for sales/purchase/return orders). See transaction.md and ../inventory/_overview.md. - Define tax types (VAT/WHT). Those are master data (
tax_typemaster, see §2.3 and ../master-data/_overview.md). - Handle jurisdictions, tax periods, or tax returns/filing. There is no tax-return reporting object — only the catalogue + the per-line computation.
2. Data model
2.1 finance_taxation — the tax definition (Taxation)
finance/taxation/taxation.schema.ts. Extends BaseSchema (_id, ref, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, canView/...), timestamps: true, registers mongoose-delete.
| field | type | required? | description |
|---|---|---|---|
ref |
string | yes (schema) / optional (input) | Reference code. The schema marks it required, but CreateTaxationInput.ref is nullable and the resolver does not generate one — in practice taxes are identified by name. |
name |
string | yes (input) | Display name (e.g. "VAT 7.5%"). |
typeId |
ObjectId → masters (tax_type) |
no | Links to the tax type master (vat/wht). Drives ADDITIVE vs DEDUCTIVE classification. |
accountId |
ObjectId → finance_accounts |
no | The GL account the tax posts to. Required at posting time — if a tax is used on a line and has no accountId, leg generation throws (§4.4). |
percentage |
number | yes | The rate, as a percent (e.g. 7.5 = 7.5%). |
export enum TaxationTypes { PURCHASE = "PURCHASE", SALES = "SALES" } // declared; not stored on Taxation
@ApSchema({ collection: "finance_taxation", timestamps: true })
export class Taxation extends BaseSchema {
ref: string; // required in schema; optional in input
name: string;
typeId: Types.ObjectId; // → masters (tax_type: vat | wht)
accountId: Types.ObjectId; // → finance_accounts (the tax GL account)
percentage: number; // e.g. 7.5
}
TaxationTypes(PURCHASE/SALES) is declared but not a field onTaxation. A tax is not hard-bound to purchase vs sales; the same tax row can be selected on either. The additive/deductive behaviour comes fromtypeId's masterkey, not from this enum. (The admin "Type" picker bindstypeIdto atax_typemaster, not toTaxationTypes.)
2.2 The persisted tax breakdown — TransactionLineTax
When a tax is applied to a transaction line, the computed breakdown is snapshotted onto the leg as taxes: TransactionLineTax[] (finance/transaction/transaction.schema.ts), with taxId/ taxInclusive mirroring the primary (first) tax for legacy single-tax readers:
@ApSchema()
export class TransactionLineTax {
taxId: Types.ObjectId;
name: string;
percentage: number;
direction: string; // LineTaxDirection — ADDITIVE | DEDUCTIVE
amount: number; // the computed tax amount for this line
accountId: Types.ObjectId; // copied from the Taxation at compute time
}// taxation.model.ts
export enum LineTaxDirection { ADDITIVE = "ADDITIVE", DEDUCTIVE = "DEDUCTIVE" }2.3 Tax types (master data, seeded)
typeId references the tax_type master, seeded at boot (master/constants.ts):
{ key: "tax_type", name: "Tax Type", children: [
{ key: "vat", name: "Value Added Tax (VAT)" }, // → ADDITIVE
{ key: "wht", name: "Withholding Tax (WHT)" } // → DEDUCTIVE
]}isWithholdingTax(tax) loads master(typeId) and returns taxType.key?.toLowerCase() === "wht" (cached in whtCache). So any tax whose type master key is wht is treated as deductive; every other type is additive.
3. API surface
Resolver is @ApGqlAuthorize(); mutations are audited via @AuditMeta({ module: "taxation", collection: "finance_taxation", ... }) → ../../platform/audit-trail.md.
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
taxationPage |
Query | TaxationPageInput |
TaxationPageResult |
tax-maintenance view |
findOneTaxation |
Query | TaxationQueryInput |
Taxation (nullable) |
tax-maintenance view |
createTaxation |
Mutation | CreateTaxationInput |
Taxation |
create-taxation |
updateTaxation |
Mutation | _id, UpdateTaxationInput |
Taxation |
update-taxation |
deleteTaxation |
Mutation | _id |
Boolean |
delete-taxation |
Resolve-fields on Taxation (taxation.resolver.ts): type → Master (from typeId), account → Account (from accountId).
input CreateTaxationInput {
ref: String
name: String!
percentage: Float!
typeId: String
accountId: String
}
input UpdateTaxationInput { # PartialType(CommonTaxationInput)
ref: String name: String percentage: Float typeId: String accountId: String
}
input TaxationPageInput {
ref / name / percentage / typeId / accountId / _id / fromDate / toDate / keyword
skip: Float! take: Float! sortBy: String sortOrder: SortOrder
}
type Taxation {
_id ref name typeId accountId percentage: Float!
type: Master # resolve-field
account: Account # resolve-field
}The
taxationPagerepository query ignoreskeyword,name,typeIdinbuildQuery(schemaKeysQuery(..., ignoreKeys: ["keyword","name","typeId"])) — i.e. there is no server-side keyword/name search on the list; filtering is effectively by the remaining schema keys only.
REST: GET /api/taxation/download?downloadType=xlsx (@ApiAuthorize()) → XLSX with Name, Percentage, Document Date.
4. Business rules & calculations
4.1 Validation (admin create form)
taxation/components/create.tsx Yup schema: name required; type (the tax_type master) required; account required; percentage required, min(2) ("Percentage cannot be 0") and max(100). (Backend CreateTaxationInput only enforces name + percentage non-null.)
4.2 The core computation — computeLineTaxes(amount, taxes, inclusive)
taxation.model.ts. This is the single source of truth for every tax number in the app.
additiveRate = Σ percentage of ADDITIVE taxes // deductive taxes excluded from base extraction
baseAmount = inclusive && additiveRate > 0
? amount / (1 + additiveRate/100) // strip embedded additive tax
: amount // exclusive: amount IS the base
for each tax t:
taxAmt = baseAmount * t.percentage / 100
if t.direction == DEDUCTIVE → totalDeductive += taxAmt
else → totalAdditive += taxAmt
taxAmount = totalAdditive + totalDeductive
lineTotal = inclusive
? amount − totalDeductive // inclusive: entered amount already has additive in it
: baseAmount + totalAdditive − totalDeductiveKey invariants (verified by taxation.model.spec.ts):
| Scenario | Input | base | additive | deductive | lineTotal |
|---|---|---|---|---|---|
| Exclusive VAT 7.5% | 100,000 excl | 100,000 | 7,500 | 0 | 107,500 |
| Inclusive VAT 7.5% | 107,500 incl | 100,000 | 7,500 | 0 | 107,500 (= entered) |
| Exclusive VAT + WHT 5% | 100,000 excl | 100,000 | 7,500 | 5,000 | 102,500 |
| Inclusive VAT + Edu 2% | 107,500 incl | 107,500 / 1.095 | 107,500 − base | 0 | 107,500 |
| Inclusive VAT + WHT | 107,500 incl | 107,500 / 1.075 | (in entered) | base × 5% | 107,500 − base×5% |
Notes that a rebuild must preserve:
- Inclusive base extraction divides by
(1 + Σ additiveRate/100), so for VAT+Edu inclusive the divisor is1.095(7.5 + 2), not applied tax-by-tax. Deductive taxes never enter the divisor. - Inclusive deductive taxes are computed on the extracted base, and
lineTotalis the entered amount minus deductive (additive is already inside the entered amount). lineNetBase(amount, inclusive, taxes, legacyTaxAmount)=inclusive ? amount − additiveTax : amount— the pre-tax base used by reports/COGS.lineAdditiveTaxsums only ADDITIVE amounts (falls back to a legacy singletaxAmountwhen notaxes[]snapshot exists).
4.3 Resolution — resolveLineTaxes(taxIds, amount, inclusive)
taxation.service.ts. Loads each tax by id, classifies it (direction = isWithholdingTax ? DEDUCTIVE : ADDITIVE), copies its accountId, then delegates to computeLineTaxes. Returns ILineTaxComputation { baseAmount, taxes[], totalAdditive, totalDeductive, taxAmount, lineTotal }. Empty/unknown ids → passthrough (base = amount, no taxes).
applyLineTaxes(model, existing) mutates a model in place: sets model.taxes[] (the snapshot), model.taxAmount, and model.taxId (= first tax, the legacy mirror). resolveTaxIds picks ids from model.taxIds → model.taxes → model.taxId → falls back to existing.*; a taxId === null explicitly clears taxes.
4.4 Where tax becomes GL legs (TaxEntry)
Tax never posts itself; two writers generate kind = TaxEntry legs:
(a) Generic transactions — AccountTransactionService.create → createTaxEntries (transaction.service.ts). Triggered when a leg carries taxId/taxIds/taxes and its kind is not TaxEntry and not an order kind (ORDER_TAX_KINDS = { SalesInvoice, PurchaseInvoice, SalesReturnOrder, PurchaseReturnOrder } — orders handle their own tax, see (b)). It:
- resolves the taxes via
resolveLineTaxes, - adjusts the parent leg's amount:
model.amount = taxInclusive ? comp.baseAmount − comp.totalDeductive : comp.baseAmount + comp.totalAdditive − comp.totalDeductive - for each computed tax, writes a
TaxEntryleg:amount = t.amount,accountId = t.accountId,parentId = parent leg _id,relationIdshared,remark = "Tax <name>", and direction:If any computed tax has notaxTransType = deductive ? (parentType === CREDIT ? DEBIT : CREDIT) // WHT flips the parent's side : parentTypeaccountId, it throwsTax "<name>" does not have a GL account configured...(HTTP 400).
(b) Sales/Purchase/Return orders — order.transaction.ts → buildTaxEntry. The order line already holds its taxes[] snapshot (computed via the same engine in the item services). For each tax leg:
baseType = order.kind === PurchaseInvoice ? DEBIT : CREDIT
taxType = deductive ? (baseType === CREDIT ? DEBIT : CREDIT) : baseType // WHT flipsrefId = order._id, itemId = line._id, relationId = line._id, accountId = tax.accountId (throws the same "no GL account" error if missing). Order tax legs are re-created idempotently: deleteMany({ refId, kind: TaxEntry }) (or by itemId) before re-adding.
WHT (deductive) always uses the opposite DR/CR side of the line it attaches to, so a withholding tax reduces the cash/payable instead of adding to it. ADDITIVE taxes (VAT, Edu) take the same side as the parent leg.
4.5 GL legs — worked examples
Selling a service for ₦100,000 exclusive, VAT 7.5% (additive) — sales invoice (baseType = CREDIT):
| Leg | Account | Type | Amount | kind |
|---|---|---|---|---|
| Revenue (line) | Sales/Income | CREDIT | 100,000 | (order line) |
| VAT | VAT Output (the tax's accountId) |
CREDIT | 7,500 | TaxEntry |
| Receivable | AR control | DEBIT | 107,500 | (bill leg) |
Same sale but with WHT 5% (deductive) withheld by the customer:
| Leg | Account | Type | Amount | kind |
|---|---|---|---|---|
| VAT | VAT account | CREDIT | 7,500 | TaxEntry |
| WHT | WHT account | DEBIT (flipped vs the CREDIT base) | 5,000 | TaxEntry |
The WHT leg debits because the customer keeps 5,000 to remit — the business collects 100,000 + 7,500 − 5,000 = 102,500 net (matches lineTotal).
4.6 Tax legs are excluded from line displays & journal balance
TaxEntry legs are filtered out of the document-line lookups ($lookupTransactions* all $match: { kind: { $ne: TaxEntry } }) and are not summed in the whole-book balance check (accountBalanced only sums JournalEntry/AdvanceTransaction/LoanRepayment kinds). See transaction.md and _overview.md §1.3.
5. Permissions
- Module:
TAX_MAINTENANCE = "tax-maintenance"(BEpermission/permission.enum.ts). - Actions (admin
constants/UserAccess.ts → TAX_MAINTENANCE):view,create-taxation,view-taxation-details,update-taxation,delete-taxation. - The Next.js route
pages/taxation.tsxis server-guarded viaApGuardBuilder.isAuth().haveAccess(TAX_MAINTENANCE.MODULE, ...VIEW, "/"). - The taxes themselves are referenced (read) by many other modules (payment, order, asset, etc.) via
ApTaxSelection, which only needs read access. Full model: ../../platform/permissions-access.md.
6. Flows
6.1 Define a tax
- Admin opens
/taxation(sidebarmore → tax-maintenance) →TaxationPage. - Clicks add →
CreateTaxationmodal (Formik). Fills Name, Type (ApMasterSelectInput masterKey="tax_type"→vat/wht), Account (ApAccountSelection→ the tax GL account), Percentage (2–100). - Submit →
saveTaxation→createTaxationmutation →TaxationService.create(stampscreatedBy). Context re-fetches the page.
6.2 Apply a tax to a line (inline create)
- On a transaction/order/payment line,
ApTaxSelection(ApSelectInputAsync,createable) lists taxes; typing a new name + "create" opens the sameCreateTaxationmodal pre-filled with the typed name → the new tax is selected on return. - Supports
isMulti(multiple taxes per line) and an optional Inclusive checkbox (inclusiveName) bound alongside the select. - On save the selected
taxId/taxIds+taxInclusiveflow to the leg; the writer (createTaxEntriesor orderbuildTaxEntry) computes amounts and postsTaxEntrylegs.
6.3 Unhappy paths
- Tax with no GL account used on a posting →
Tax "<name>" does not have a GL account configured. Please map an account to this tax before proceeding.(HTTP 400). Fix: set the tax'saccountId. - Percentage 0 / >100 → blocked by the admin Yup schema before submit.
- Deleting a tax does not retro-fix posted legs — past
TaxEntrylegs keep their snapshottedtaxes[]/taxId(soft-delete preserves history).
7. Admin UI
Route: pages/taxation.tsx → MainLayout selectedKeys={['more','tax-maintenance']} → modules/taxation/page.tsx. (BE↔︎admin map: BE finance/taxation lives under admin top-level taxation/, not finance/.)
Context (taxation/context.tsx) — sole consumer of useTaxationQuery(). Exposes fetchTaxation(filter), saveTaxation(_id, input) (create-or-update switch), deleteTaxations(_id), plus taxation[], totalRecords, modal, filter state. Every mutation re-runs fetchTaxation.
Components:
components/create.tsx— the Formik form (§4.1 / §6.1).components/select.tsx—ApTaxSelection: the reusable async-creatable tax picker used by finance/order/payment lines; supportsmultiple, inline create, and an optional grouped "Inclusive" checkbox.line-tax.ts— the admin mirror of the BE computation (computeLineTaxBreakdown,getLineTaxes,lineNetTax,isDeductiveTax,extractTaxIds,toTaxSelectOptions). It re-implementscomputeLineTaxesclient-side for live totals and classifies deductive bydirection === 'DEDUCTIVE'or awht/withholdsubstring on the tax type. Keep these two implementations in lockstep when porting.
8. Dependencies & integrations
| Calls / is called by | Why |
|---|---|
MasterService.findById(typeId) |
resolve the tax type (vat/wht) for WHT detection + type resolve-field |
AccountService.findById(accountId) |
the tax GL account (account resolve-field) |
AccountTransactionService.createTaxEntries |
(calls into taxation) compute + post TaxEntry legs for non-order kinds |
Order order.transaction.ts / inventory/order/item/item.service.ts, purchase/sales item services |
apply tax to invoice lines, snapshot taxes[], post order TaxEntry legs |
payment module |
accepts taxId/taxIds/taxInclusive on lines → tax legs (UI surface currently commented out — see payment.md §7) |
assets module, report service |
consume computeLineTaxes/lineNetBase for tax-aware amounts |
MasterService (XLSX) |
none extra — controller export only emits Name/Percentage/Date |
- No cron/jobs, no events. XLSX export is the only external output.
- Consumed widely via the
tax/taxesresolve-fields onAccountTransaction, order items, etc. (schema.gqlshowsTaxationreferenced astax/taxeson ~13 types).
9. Gotchas & project-specific rules
- Direction is derived, not stored. ADDITIVE vs DEDUCTIVE comes from the tax type master (
key === "wht"→ deductive), viaisWithholdingTax(cached). Changing a tax'stypeIdflips its behaviour everywhere. TaxationTypes(PURCHASE/SALES) is a dead enum onTaxation— not a stored field; do not rely on it to scope a tax to purchase vs sales.- Inclusive base extraction uses the summed additive rate (
amount / (1 + Σadditive/100)), and deductive taxes are excluded from that divisor but still computed on the extracted base. - WHT legs flip the DR/CR side of the parent leg; ADDITIVE taxes match it. Get this wrong and the book won't balance.
accountIdis mandatory at posting time even though it is nullable on the schema/input — posting a tax with no GL account throws.- Order kinds are excluded from
createTaxEntries(ORDER_TAX_KINDS) because orders post their own tax legs viaorder.transaction.ts. Adding a new order-like kind that should self-handle tax means adding it to that set. - Two copies of the math (BE
taxation.model.ts↔︎ adminline-tax.ts). They are intentionally kept in parity (the BE spec encodes the canonical numbers); diverging them silently breaks live UI totals vs posted amounts. - List search is limited:
taxationPageignoreskeyword/name/typeIdin the repository query, so name search is effectively client-side only. refis schema-required but never generated by the resolver; taxes are keyed bynamein practice.