Taxation — tax definitions & the line-tax computation engine

The whole taxation module reduces to one idea:

A Taxation row 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 balanced TaxEntry GL leg. A tax is ADDITIVE (adds on top, e.g. VAT) or DEDUCTIVE (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 Taxation document = { name, percentage, typeId (→ tax_type master), accountId (→ GL account) }. CRUD + paged list + XLSX export.
  • The computation engine (taxation.model.ts): computeLineTaxes and 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 the taxes[] snapshot), and isWithholdingTax (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_type master, 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 on Taxation. A tax is not hard-bound to purchase vs sales; the same tax row can be selected on either. The additive/deductive behaviour comes from typeId's master key, not from this enum. (The admin "Type" picker binds typeId to a tax_type master, not to TaxationTypes.)

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): typeMaster (from typeId), accountAccount (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 taxationPage repository query ignores keyword, name, typeId in buildQuery (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 − totalDeductive

Key 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 is 1.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 lineTotal is 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. lineAdditiveTax sums only ADDITIVE amounts (falls back to a legacy single taxAmount when no taxes[] 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.taxIdsmodel.taxesmodel.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 transactionsAccountTransactionService.createcreateTaxEntries (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:

  1. resolves the taxes via resolveLineTaxes,
  2. adjusts the parent leg's amount:
    model.amount = taxInclusive
         ? comp.baseAmount − comp.totalDeductive
         : comp.baseAmount + comp.totalAdditive − comp.totalDeductive
  3. for each computed tax, writes a TaxEntry leg: amount = t.amount, accountId = t.accountId, parentId = parent leg _id, relationId shared, remark = "Tax <name>", and direction:
    taxTransType = deductive
         ? (parentType === CREDIT ? DEBIT : CREDIT)   // WHT flips the parent's side
         : parentType
    If any computed tax has no accountId, it throws Tax "<name>" does not have a GL account configured... (HTTP 400).

(b) Sales/Purchase/Return ordersorder.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 flips

refId = 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" (BE permission/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.tsx is server-guarded via ApGuardBuilder.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

  1. Admin opens /taxation (sidebar more → tax-maintenance) → TaxationPage.
  2. Clicks add → CreateTaxation modal (Formik). Fills Name, Type (ApMasterSelectInput masterKey="tax_type"vat/wht), Account (ApAccountSelection → the tax GL account), Percentage (2–100).
  3. Submit → saveTaxationcreateTaxation mutation → TaxationService.create (stamps createdBy). Context re-fetches the page.

6.2 Apply a tax to a line (inline create)

  1. On a transaction/order/payment line, ApTaxSelection (ApSelectInputAsync, createable) lists taxes; typing a new name + "create" opens the same CreateTaxation modal pre-filled with the typed name → the new tax is selected on return.
  2. Supports isMulti (multiple taxes per line) and an optional Inclusive checkbox (inclusiveName) bound alongside the select.
  3. On save the selected taxId/taxIds + taxInclusive flow to the leg; the writer (createTaxEntries or order buildTaxEntry) computes amounts and posts TaxEntry legs.

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's accountId.
  • Percentage 0 / >100 → blocked by the admin Yup schema before submit.
  • Deleting a tax does not retro-fix posted legs — past TaxEntry legs keep their snapshotted taxes[]/taxId (soft-delete preserves history).

7. Admin UI

Route: pages/taxation.tsxMainLayout 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.tsxApTaxSelection: the reusable async-creatable tax picker used by finance/order/payment lines; supports multiple, 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-implements computeLineTaxes client-side for live totals and classifies deductive by direction === 'DEDUCTIVE' or a wht/withhold substring 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/taxes resolve-fields on AccountTransaction, order items, etc. (schema.gql shows Taxation referenced as tax/taxes on ~13 types).

9. Gotchas & project-specific rules

  • Direction is derived, not stored. ADDITIVE vs DEDUCTIVE comes from the tax type master (key === "wht" → deductive), via isWithholdingTax (cached). Changing a tax's typeId flips its behaviour everywhere.
  • TaxationTypes (PURCHASE/SALES) is a dead enum on Taxation — 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.
  • accountId is 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 via order.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 ↔︎ admin line-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: taxationPage ignores keyword/name/typeId in the repository query, so name search is effectively client-side only.
  • ref is schema-required but never generated by the resolver; taxes are keyed by name in practice.