@spinekit/bd-tax
Bangladesh statutory tax filing — Mushak 6.3 VAT invoices + VDS/TDS withholding certificates.
Part of Spine, Classytic's ERP — the arc-module layer that composes the
framework-agnostic @classytic/bd-tax kernel into an @classytic/arc
application via createApp({ modules }).
Install
npm install @spinekit/bd-tax @classytic/bd-tax @classytic/arc @classytic/mongokit \
@classytic/primitives mongoose zod
Kernels and the framework are peer dependencies — the host installs a single copy; this module bundles nothing.
Usage
import { createApp } from '@classytic/arc/factory';
import { createBdTaxModule } from '@spinekit/bd-tax';
const app = await createApp({
modules: [
createBdTaxModule({
connection, // your mongoose connection
permissions, // { view, manage, ... } role gates
// BYO engine + host seams (extraActions, extraRoutes, bridges) as needed
}),
],
});
The module's bootstrap return is recorded at fastify.arc.modules['bd-tax']
for container-free cross-module wiring.
The one rule: the host projects, the module decides
A host resolves its own order into a buyer and raw items — and stops. Fiscal position (which SRO relief applies), rate-code remapping, VAT calculation, serial allocation, the filing window and form selection are Bangladesh.
// HOST: projection. No tax math.
const salesBridge: SalesSourceBridge = async (orderId) => {
const order = await orders.get(orderId);
return {
buyer: {
name: order.customer.name,
countryCode: order.shippingAddress.country, // a FACT, not "INTERNATIONAL"
},
items: order.lines.map((l) => ({
description: l.name, quantity: l.qty, unitPrice: l.unitPriceMinor,
})),
sourceModel: 'Order',
sourceId: orderId,
};
};
The module then resolves the fiscal position, remaps rate codes, prices every
line, builds the Mushak 6.3, allocates the serial and persists it — idempotently
on (sourceModel, sourceId), enforced by a unique index rather than a
read-then-write check.
POST /generate does the same from raw items directly, under the issue
permission (distinct from file: BD VAT Act §51 makes the legal invoice
accompany the goods, so a cashier issues one; nobody wants a cashier filing the
monthly return).
If you find yourself calling calculateInvoiceTax or resolveFiscalPosition
in your host, stop — that is the module's job, and a second copy of it will
drift silently. The one legitimate override is bridges.fiscalPosition, for a
deployment that merges its own tax classes into a tax-core TaxResolver.
The monthly return — and why you should not re-host it
GET /accounting/musok/return/:period serves the full statutory return:
regime → form (STANDARD_VAT… → Mushak 9.1, SME_TOT → 9.2,
COTTAGE_EXEMPT → nothing to file), output VAT bucketed per VAT rate from
issued Mushak 6.3 invoices, input VAT credited from your ledger.
Two things about that return are yours, not Bangladesh's, and they are the only two you wire:
createBdTaxModule({
connection,
permissions,
resolveSeller, // issuing party (BIN + legal identity)
bridges: {
sales: orderToTaxableSale, // order id → neutral taxable sale
regime: (ctx) => branchRegimeOf(ctx.organizationId), // → 9.1 / 9.2 / none
inputVat: (period, ctx) => ledgerInputVat(period, ctx), // credit + VDS
},
});
Both bridges are optional and default safely: no regime bridge means
STANDARD_VAT (file a 9.1 — filing an unneeded return is recoverable, silently
not filing one is not), and no inputVat bridge means zero credit, so the
return still builds before you have a ledger.
Filing periods are resolved in Asia/Dhaka, not UTC (override with
filingTimeZone). This matters: Bangladesh is UTC+6, so a UTC month disagrees
with the filing month at both ends by six hours, reporting the start of one
month as the end of the previous one. Nothing throws when this is wrong.
musokOmitBuiltinReads: true exists for a host that genuinely needs a different
statutory shape. Do not reach for it merely to enrich the return — that creates
a second implementation of an NBR rule, and the copies drift silently. Wire the
bridges instead, or extend the module.
Cloning this for another country
@classytic/ca-tax and @classytic/us-tax already implement the same
@classytic/tax-core TaxResolver contract that @classytic/bd-tax does, so a
sibling spine module is a composition job, not a rewrite. What you keep and what
you replace:
| concern | reuse as-is | why |
|---|---|---|
period boundaries, serial year, YYYY-MM parsing |
@spinekit/kit/period |
zone is a parameter; already country-neutral |
| fiscal position | bridges.fiscalPosition port |
typed as tax-core's signature, so your pack's resolver drops straight in |
| per-line tax computation | your pack (calculateInvoiceTax equivalent), or tax-core's resolver.computeLine |
the only genuinely country-specific math |
ports (SellerResolver, sales/regime/input-tax bridges) |
shared/ports.ts shapes |
nothing in them is BD |
| serial allocation | allocateMushakSerial shape |
atomic per-(scope, year) counter; rename, don't re-derive |
| idempotent issuance | issueMushak shape |
unique index + duplicate-key read-back; see the CHANGELOG for why the naive version double-issues |
| per-rate month aggregate | MushakRepository.aggregateMonthlyVat shape |
groups on lines.vatRate; no BD assumption |
| the MODEL, the FORMS, regime→form dispatch | rewrite | Mushak 6.3/9.1/9.2 are Bangladesh; GST/HST returns are not |
src/pack.ts is the complete list of jurisdiction literals — country code,
filing timezone, currency. Nothing else in the module hardcodes any of them, so
that file plus the model and forms is your diff. Amounts are minor units and the
module only ever sums them, so a currency with a different exponent needs no
code change.
License
SEE LICENSE IN LICENSE — Classytic Source-Available License (Community & Commercial). Evaluation and development use are free; production use requires a commercial license. Versions before 1.0.0 remain MIT.