@companyio/accounting
v0.1.11
Published
Reusable accounting domain package for multi-tenant business applications
Readme
@companyio/accounting
Installable double-entry accounting for multi-tenant SaaS apps.
Use it from this repo or any other app. After you publish the package (npm, GitHub Packages, a git URL, or a private registry), install it in the other application and run accounting init there. Accounting tables live in that app’s existing PostgreSQL database. This package does not create a second database or its own Prisma Client.
What the host must provide
| Host owns | Package owns |
| --------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| PostgreSQL + DATABASE_URL | Domain logic (posting, ledgers, reports, periods) |
| Prisma schema and migrations | Schema source (prisma/accounting.prisma) and accounting init |
| Authentication / sessions | Fastify routes via attachAccounting |
| Mapping session → { main_business_id, branch_id } (host switcher is the branch) | One platform General Ledger for all products |
Requirements: Node.js, PostgreSQL, Prisma on the host, zod (installed with this package). Fastify ≥ 4 is required only if you call attachAccounting / registerAccountingRoutes.
Package boundaries
| Owns | Does not own |
| ------------------------------------------------ | ------------------------------------------------------------- |
| Chart of accounts, journals, one GL, periods | Invoices, POS tickets, fuel sales, school fees |
| AccountingParty + external refs | Contacts identity (link via externalType / externalRefId) |
| Reversals, idempotency, trial balance / P&L / BS | HR obligations, advances, loans as domain tables |
| Generic sourceModule + referenceId | Knowledge of what those source ids mean |
| | Product-specific ledgers (FuelLedger, school books, …) |
Domain packages own the business event. Accounting owns the single financial General Ledger for Fuel, POS, School, Sales, Purchasing, Expenses, HR, Payments, and future products. Fuel tank/meter/stock history stays in @companyio/fuel and is not part of this ledger.
Source references
Every journal carries:
sourceModule— opaque domain name (hr,fuel,pos,school, …)referenceId— opaque domain document id- Optional
idempotencyKey— retry-safe unique key within the branch (e.g.hr:salary_payment:pay_123)
Accounting does not interpret those values.
Money
Amounts are decimal major units with 2 dp, rounded through integer cents. Prisma stores Decimal(14,2). Use toCents / fromCents / roundMoney / addMoney from this package.
Tenant context
type AccountingContext = {
main_business_id: string;
branch_id: string;
user_id?: string; // used as createdBy/postedBy when posting journals
};Domain adapters
Product posting helpers (postFuelJournal, postSchoolJournal, /integrations/fuel, /integrations/school) were removed. They posted into the same GL but used product-prefixed entry types and account mapping fields.
Post from every product the same way:
await accounting.operations.post(context, userId, {
sourceModule: 'fuel', // or pos | school | sales | …
kind: 'credit_sale',
amount: 8000,
transactionDate: '2026-09-21',
referenceId: 'sale-001',
party: { type: 'CUSTOMER', name: 'Fleet', externalType: 'contacts_party', externalRefId: '…' },
});
// or explicit lines:
await accounting.journals.post(context, {
entryType: 'CREDIT_SALE',
sourceModule: 'sales',
referenceId: invoiceId,
transactionDate: '2026-09-21',
description: invoiceNumber,
lines: [/* balanced AR + revenue */],
idempotencyKey: `sales:invoice:${invoiceId}`,
});Install in another app
pnpm add @companyio/accounting
# or: npm install @companyio/accountingUntil it is on a registry, install from git or a packed tarball:
pnpm add github:<org>/<repo>#<tag>
# or
pnpm pack # in this package, then pnpm add /path/to/companyio-accounting-0.1.0.tgzThen, from the host app directory that contains Prisma:
pnpm exec accounting init
# optional: pnpm exec accounting init --schema path/to/schema.prisma
pnpm --filter @companyio/api exec prisma migrate deploy --config prisma7.config.ts
pnpm prisma generateaccounting init:
- Finds
prisma/schema.prisma(orapps/api/prisma/schema.prisma, or--schema) - Requires a PostgreSQL datasource
- Appends accounting models between
// @companyio/accounting:beginand// @companyio/accounting:end - Copies a host-owned migration into the host
prisma/migrationsfolder
Then run the host’s usual migrate + generate against the same DATABASE_URL. If models are already present, the CLI prints Accounting already initialized. and does not duplicate them.
pnpm exec accounting statusWire Fastify (typical)
Map main_business_id and branch_id from the authenticated session, never from the client body.
import Fastify from 'fastify';
import { attachAccounting } from '@companyio/accounting';
const app = Fastify();
const accounting = attachAccounting(app, {
prisma, // host PrismaClient after generate (must include Accounting* models)
prefix: '/api/v1/accounting', // optional; this is the default
authenticate: async (request) => getUser(request.headers.authorization),
getContext: (_request, user) => ({
main_business_id: user.main_business_id,
branch_id: user.branch_id,
}),
});
await app.listen({ port: 3000 });attachAccounting returns the same services object you can call from your own routes (accounting.journals.post, and so on).
Without Fastify, use services only:
import { createAccounting } from '@companyio/accounting';
const accounting = createAccounting({
prisma,
getContext: () => ({ main_business_id: '...', branch_id: '...' }),
});Tests can pass store: createMemoryAccountingStore() instead of Prisma.
Tenancy
Every row is scoped by both:
main_business_id— SaaS tenantbranch_id— organization unit inside that tenant. In Fuel this is the branch you switch to; books are not shared across branches of the same business.
Books are not shared across branch_id values under the same business. Idempotency is unique on main_business_id + branch_id + idempotencyKey.
Money and journals
Amounts are major currency units with 2 decimal places (for example 500.5 → 500.50). Journals must balance (Σ debit = Σ credit). Posted entries are immutable; corrections use reversals in the same organization.
const cash = await accounting.accounts.create(context, {
code: '1100',
name: 'Cash',
type: 'ASSET',
});
const entry = await accounting.journals.post(context, {
entryType: 'SALE',
sourceModule: 'pos',
referenceId: 'sale-001',
transactionDate: '2026-09-21',
description: 'Invoice posted',
lines: [
{ accountId: cash.id, debit: 500, credit: 0 },
{ accountId: revenue.id, debit: 0, credit: 500 },
],
idempotencyKey: 'pos:sale:sale-001',
userId: user.id,
});
await accounting.journals.reverse(context, entry.id, user.id);
await accounting.accounts.seedDefaults(context);
await accounting.ledger.getAccountLedger(context, { accountId: cash.id, from, to });
await accounting.parties.getStatement(context, { partyId });
await accounting.reports.trialBalance(context, { from, to });
await accounting.reports.profitAndLoss(context, { from, to });
await accounting.reports.balanceSheet(context, { asOf });HTTP routes
Default prefix: /api/v1/accounting. All routes except GET .../health require authenticate to succeed.
Other apps (Fuel, school, POS) do not implement posting rules. They either:
- Call
POST /api/v1/accounting/postafter a business document is saved, or - If they host this package (like
apps/api), callaccounting.operations.postin-process.
POST /post seeds the default chart if needed, upserts parties by externalRefId, and posts a balanced journal. Account ids are optional — the default codes in STANDARD_CHART_CODES are used (1110 cash, 1200 AR, 2100 AP, 4100 sales, 6400 expenses, 1500 assets, …).
{
"sourceModule": "fuel",
"kind": "cash_sale",
"amount": 15000,
"transactionDate": "2026-09-21",
"referenceId": "sale-001",
"instrument": "CASH"
}Kinds: cash_sale, credit_sale, payment, expense, other_income, asset_purchase, purchase, supplier_payment, cogs, salary.
Credit/receivable kinds need party or partyId. Example credit sale:
{
"kind": "credit_sale",
"amount": 8000,
"transactionDate": "2026-09-21",
"referenceId": "inv-88",
"party": {
"type": "CUSTOMER",
"name": "City Logistics",
"externalType": "organization",
"externalRefId": "org-88"
}
}Fuel Management’s UI keeps calling /fuel/* for operations. The Fuel API posts the matching accounting event so the chart, journals, P&L, and receivables stay in this package.
| Method | Path | Purpose |
| --------- | ---------------------------- | ------------------------------------------------------------------------------------------ |
| GET | /health | Package health |
| GET/POST | /accounts | List / create |
| GET/PATCH | /accounts/:id | Read / update |
| GET | /ledger/:accountId | Account ledger |
| GET/POST | /journal | List / post |
| GET | /journal/:id | Read entry |
| POST | /journal/:id/reverse | Reverse |
| GET/POST | /parties | List / create |
| GET | /parties/:id/statement | Party statement |
| GET | /reports/trial-balance | Trial balance |
| GET | /reports/pnl | Profit and loss |
| GET | /reports/balance-sheet | Balance sheet |
| GET | /reports/receivables | AR by party |
| GET | /reports/payables | AP by party |
| GET | /reports/reconciliation | Control-account check |
| GET/POST | /periods | List / create |
| POST | /periods/:id/close /open | Close or reopen |
| POST | /post | Operational journals (sale, expense, AR, AP, asset, income) — use this from other apps |
| POST | /seed-defaults | Default chart of accounts |
| POST | /opening-balance | Opening journal |
School and fuel
Do not post through product-specific Accounting helpers. Fuel keeps pumps/meters/stock in @companyio/fuel; financial recognition goes through Sales / Payments / Purchasing / Expenses adapters into this GL (sourceModule + referenceId). School fees and POS tickets use the same journals.post / operations.post contract with their own sourceModule.
Fuel kind values on the operational API: cash_sale, credit_sale, payment, expense, purchase, supplier_payment, salary, plus cogs / asset_purchase / other_income.
Publish checklist
This package ships dist/, prisma/, and the CLI bin accounting. Before publish, build:
pnpm --filter @companyio/accounting build
# or from packages/accounting: pnpm buildprepublishOnly runs tsc. Consumers need the built dist and the prisma/ folder (CLI reads prisma/accounting.prisma and prisma/migrations/0001_accounting_baseline).
pnpm add @companyio/accounting
pnpm exec accounting init --schema path/to/schema.prisma
pnpm --filter @companyio/api exec prisma migrate deploy --config prisma7.config.ts
pnpm prisma generate