@classytic/invoice
v0.6.2
Published
Country-agnostic invoice engine for MongoDB — invoices, bills, credit notes, receipts, aging, payments
Downloads
790
Readme
@classytic/invoice
Production-grade invoice engine for Node.js + MongoDB. Country-agnostic, composable, AI-agent friendly.
2 models. 7 services. 5 bridge ports. 16 domain events. 90 tests. Zero framework lock-in.
npm install @classytic/invoice @classytic/mongokit mongooseWhy
There is no standalone invoice engine for Node.js. Every ERP (Odoo, SAP, ERPNext) bundles invoicing into a monolith. If you need invoicing in your Node.js app, you build from scratch or adopt a full ERP.
@classytic/invoice is the missing primitive — a standalone engine that handles invoices, bills, credit notes, receipts, payments, and aging reports. Plug it into any Node.js project: Express, Fastify, NestJS, or a CLI tool. Compose it with a ledger, a tax engine, or a payment processor — or use it standalone.
What's new in 0.2.0
- Atomic state transitions —
post/cancel/void/unpostmigrated torepo.claim()CAS. Concurrent transitions no longer race; the loser getsErrors.transitionConflict(409). - Tenant-safe payment delta —
_applyPaymentDelta+applyCreditNotenow route through mongokit plugins. The prior rawModel.findOneAndUpdatepath is gone. - PATCH-time recompute —
repo.update()now rederivestotalAmount/amountDue/ line subtotals wheneverlines/totalAmount/amountPaidchange. The schema'spre('validate')hook only fires on.save(), so PATCHes through Arc auto-CRUD previously leftamountDuestale after a line edit. HttpErrorwire shape —InvoiceErrorimplements@classytic/repo-core/errors'HttpError. LegacystatusCode/detailsaccessors kept for compat.- Brand-typed money —
Cents<C>alias overMinorUnits<C>. Hosts writetype Paisa = Cents<'BDT'>and get compile-time cross-currency safety. - 9 new doc-side features (additive, no breaks): per-line
accountCode, per-tax-code account map,notes→ JE label,customerReference,billingAddress/shippingAddresssnapshots, per-linetrackingdims,roundingAdjustment, per-linewithholdingTax,onInvoiceCreatedreverse-pointer callback. See CHANGELOG.md for the migration table.
Quick Start
import { createInvoiceEngine } from '@classytic/invoice';
import mongoose from 'mongoose';
const connection = mongoose.createConnection('mongodb://localhost/myapp');
const engine = createInvoiceEngine({
mongoose: connection,
currency: 'USD',
});
// Create + post an invoice in one call
const invoice = await engine.record.createAndPost({
moveType: 'out_invoice',
partnerId: 'customer-123',
partnerName: 'Acme Corp',
lines: [
{ description: 'Consulting (10h)', quantity: 10, unitPrice: 15000 },
{ description: 'License fee', quantity: 1, unitPrice: 50000 },
],
}, { organizationId: 'org-1' });
console.log(invoice.number); // INV-2026-0001
console.log(invoice.totalAmount); // 200000 (cents)
console.log(invoice.status); // posted
// Record payment
await engine.services.payment.recordPayment({
invoiceId: invoice._id,
paymentId: 'stripe-pi-xxx',
amount: 200000,
method: 'card',
}, { organizationId: 'org-1' });
// invoice.paymentStatus → 'paid'Models
| Model | Collection | Purpose |
|-------|-----------|---------|
| Invoice | invoices | Invoices, bills, credit notes, receipts — unified via moveType discriminator. Lines embedded as subdocuments. |
| Payment | invoice_payments | Aggregate root for one money-movement event (gateway charge, manual cash receipt, bank credit). Owns lifecycle pending → received → reversed/failed and the cumulative refundedAmount. |
| PaymentAllocation | payment_allocations | Ties one or more Payment records to one or more invoices. Carries reconciliation state (reconciliationStatus, reconciledAt, externalRef) for bank-statement matching. |
| PaymentTerm | payment_terms | Installment template (e.g. 2/10 Net 30, 50% Now + 50% in 30 Days). Resolved into Invoice.paymentSchedule[] at post-time. |
| RecurringInvoice | recurring_invoices | Subscription template — frequency, anchor day, proration policy, external billing-provider link. |
Move Types
| moveType | Label | Creates A/R? | Use Case |
|----------|-------|-------------|----------|
| out_invoice | Customer Invoice | Yes | B2B credit sales, subscription billing |
| in_invoice | Vendor Bill | Yes (A/P) | Purchase orders, supplier bills |
| out_refund | Credit Note | Reduces A/R | Returns, allowances, corrections |
| in_refund | Vendor Credit Note | Reduces A/P | Supplier corrections |
| receipt | Receipt | No | POS cash/card sales, prepaid orders |
Architecture
Your App (Express, Fastify, NestJS, CLI)
|
v
@classytic/invoice ← this package
|
├── LedgerBridge ──→ @classytic/ledger (or any accounting system)
├── TaxCalculator ──→ @classytic/bd-tax (or Avalara, TaxJar, custom)
└── CatalogBridge ──→ your product catalog
|
v
@classytic/mongokit ← repository layer (hooks, plugins, pagination)
|
v
mongoose + MongoDBAll bridges are optional. Without a LedgerBridge, no journal entries are posted. Without a TaxCalculator, tax fields stay zero. The engine works standalone — bridges add power, not requirements.
Services
InvoiceService — CRUD + line management
const { invoice } = engine.services;
// Create draft
const draft = await invoice.create({
moveType: 'out_invoice',
partnerId: 'cust-1',
lines: [{ description: 'Widget', quantity: 10, unitPrice: 500 }],
}, ctx);
// Manage lines
await invoice.addLine(draft._id, { description: 'Shipping', quantity: 1, unitPrice: 1500 }, ctx);
await invoice.updateLine(draft._id, 1, { quantity: 20 }, ctx);
await invoice.removeLine(draft._id, 2, ctx);
// Update draft metadata
await invoice.update(draft._id, { notes: 'Net 30', dueDate: new Date('2026-02-01') }, ctx);
// Delete (draft only)
await invoice.delete(draft._id, ctx);PostingService — lifecycle transitions
const { posting } = engine.services;
await posting.post(id, ctx); // draft → posted (assigns number, computes tax, posts to ledger)
await posting.cancel(id, 'reason', ctx); // posted → cancelled (only if not_paid, reverses JE)
await posting.void(id, 'reason', ctx); // posted → voided (creates reversing JE, even if partial)
await posting.unpost(id, ctx); // posted → draft (only if not_paid)Payment — record, reverse, reconcile, refund
Money movement lives on the Payment aggregate. Allocations bind a payment to one or more invoices; reconciliation closes the bank-matching loop; refund creates a credit note that nets against AR without unwinding the original allocation.
const { invoices } = engine.repositories;
// Record a payment + allocate to an invoice (creates a Payment if `payment` supplied)
const allocation = await invoices.recordPayment({
invoiceId: id,
amount: 50000,
payment: { totalAmount: 50000, currency: 'USD', methodKind: 'bank_transfer' },
}, ctx);
// Reverse (undo allocation — chargeback, duplicate capture)
await invoices.reversePayment(allocation._id, 'chargeback', ctx);
// Reconcile against a bank statement / processor settlement
await invoices.reconcileAllocation(
allocation._id,
'bank-txn-998877',
'ops-user-1',
ctx,
);
// Refund (returns money to customer — posts a credit note, does NOT unwind allocation)
await invoices.refundPayment(
{ paymentRecordId: String(allocation.paymentRecordId), refundAmount: 20000, reason: 'partial return' },
ctx,
);PaymentAllocation reconciliation
Each PaymentAllocation carries a reconciliationStatus (unreconciled → matched / manual) plus reconciledAt, reconciledBy, externalRef. Host bank-import jobs call reconcileAllocation(allocationId, externalRef, actorId, ctx, { mode }) once a bank line matches. Reconciliation emits the primitives payment.reconciled event and does NOT alter Invoice.amountPaid (that already moved at recordPayment time) — it only closes the audit loop.
paymentSchedule[] projection
When an invoice carries a paymentTermId, post() materialises an embedded paymentSchedule[] (oldest-due-first installments with dueDate, amount, paid, status). Subsequent recordPayment / reversePayment / writeOff / applyCreditNote calls automatically project the settlement onto this array — installments march pending → partial → paid (or overdue once dueDate < now()), and reversals unwind newest-paid-first. Consumers can read the schedule directly off the invoice doc without re-deriving from the allocation ledger.
refundPayment()
invoices.refundPayment({ paymentRecordId, refundAmount, reason?, idempotencyKey? }, ctx) bumps Payment.refundedAmount, flips status to 'reversed' ONLY when the cumulative refunds equal totalAmount (partial refunds keep the payment 'received'), and creates one credit note per linked invoice — distributing the refund proportionally to each allocation's amount. Emits the primitives payment.refunded event. Returns { payment, creditNoteIds }.
CreditNoteService — corrections
const { creditNote } = engine.services;
// Full credit note (mirrors all lines)
const cn = await creditNote.createFull(invoiceId, ctx);
// Partial credit note (selected lines/amounts)
const partialCn = await creditNote.createPartial(invoiceId, {
lines: [{ sequence: 1, quantity: 5 }], // credit 5 of 10 items
notes: 'Damaged goods return',
}, ctx);
// Post + apply (reduces original invoice's amountDue)
const { creditNote: posted, originalInvoice } = await creditNote.applyCredit(cn._id, ctx);AgingService — reports
const { aging } = engine.services;
// Open invoices by partner
const open = await aging.getOpenByPartner('cust-1', ctx);
// AR aging report (30/60/90 day buckets)
const report = await aging.agingReport(ctx, {
side: 'receivable',
buckets: [30, 60, 90],
asOfDate: new Date('2026-03-31'),
});
// Partner balance
const balance = await aging.getPartnerBalance('cust-1', ctx);ReceiptService — POS shortcut
const { receipt } = engine.services;
// Create + post receipt (no A/R)
const rcpt = await receipt.createReceipt({
partnerId: 'walk-in',
lines: [{ description: 'POS Sale', quantity: 1, unitPrice: 15000 }],
}, ctx);
// Create + post + pay in one call
const { receipt: paid, payment } = await receipt.createPaidReceipt({
partnerId: 'walk-in',
lines: [{ description: 'POS Sale', quantity: 1, unitPrice: 15000 }],
paymentId: 'pos-txn-001',
method: 'cash',
}, ctx);Semantic API (MCP / AI Agents)
// High-level verbs — one call does everything
await engine.record.createAndPost(input, ctx);
await engine.record.payByNumber('INV-2026-0001', 50000, 'card', 'pay-id', ctx);
await engine.record.receipt(input, ctx);
// Runtime discovery — MCP tools introspect capabilities
engine.introspect.moveTypes(); // [{ value: 'out_invoice', label: 'Customer Invoice' }, ...]
engine.introspect.statuses(); // [{ value: 'draft', availableActions: ['post'] }, ...]
engine.introspect.capabilities(); // { ledger: true, tax: false, payment: true, catalog: false }Multi-Tenancy
Same scope strategy as @classytic/flow:
// Multi-tenant (default): each org sees only its own invoices
createInvoiceEngine({
mongoose: connection,
scope: { strategy: 'field', field: 'organizationId' },
});
// Single-tenant: no scoping
createInvoiceEngine({
mongoose: connection,
scope: { strategy: 'none' },
});
// Custom scoping
createInvoiceEngine({
mongoose: connection,
scope: { strategy: 'custom', resolve: (ctx) => ({ tenantId: ctx.tenantId }) },
});Numbering
Auto-generated sequential numbers per move type, per org, per fiscal year:
INV-2026-0001 (Customer Invoice)
BILL-2026-0001 (Vendor Bill)
CN-2026-0001 (Credit Note)
VCN-2026-0001 (Vendor Credit Note)
RCT-2026-0001 (Receipt)Override per move type:
createInvoiceEngine({
mongoose: connection,
numbering: {
out_invoice: { prefix: 'SALE', partition: 'monthly', padding: 5, separator: '/' },
// → SALE/202601/00001
},
});Or provide a fully custom generator:
numbering: {
out_invoice: {
generator: {
async getNext(moveType, orgId) {
return `MUSHAK-${orgId}-${Date.now()}`; // Country-specific format
},
},
},
},Bridge Contracts
LedgerBridge
interface LedgerBridge {
createJournalEntry(input: LedgerPostInput): Promise<string>;
reverseJournalEntry(journalEntryId: string, reason: string, ctx: LedgerReverseContext): Promise<string>;
recordPayment(input: LedgerPaymentInput): Promise<string>;
}
interface LedgerPostInput {
organizationId?: string;
session?: unknown; // caller's mongoose ClientSession (ctx.session) — join the transaction
invoiceId: string;
moveType: MoveType;
partnerId: string;
date: Date;
currency: string;
lines: LedgerPostLine[];
totalAmount: number;
taxAmount: number;
notes?: string; // engine populates from invoice.notes — prepend to JE label
customerReference?: string; // B2B PO / contract id — include in JE narrative
roundingAdjustment?: number; // ±1¢ residual — add balancing JE leg to accounts.roundingAdjustment
idempotencyKey?: string;
}
interface LedgerPostLine {
description: string;
amount: number; // net (excluding tax), integer cents
taxAmount: number;
taxCode?: string;
taxes?: Array<{ code: string; rate: number; amount: number; name?: string }>; // full breakdown
accountCode?: string; // per-line GL override (falls back to accounts.revenue/expense)
tracking?: Record<string, string>; // project / class / salesRep — thread to JE line metadata
withholdingTax?: { code: string; rate: number; amount: number; name?: string };
withholdingTaxes?: typeof withholdingTax[]; // stacked withholdings (BD VDS + AIT)
productId?: string;
}Per-tax-code GL routing (LedgerBridgeAccounts). Hosts that file taxes to multiple authorities (CA: GST/PST, AU: GST/WET, BD: VAT/SD) route via the Record form. resolveTaxAccount(map, code) is the canonical resolver.
import type { LedgerBridgeAccounts, TaxAccountMap } from '@classytic/invoice/domain/contracts';
import { resolveTaxAccount } from '@classytic/invoice/domain/contracts';
const accounts: LedgerBridgeAccounts = {
receivable: '1200', payable: '2000',
revenue: '4000', expense: '5000', cash: '1000',
taxPayable: { // per-tax-code routing
HST: '2110',
'PST-BC':'2120',
QST: '2125',
default: '2100',
},
taxReceivable: '1150', // string form still works
withholdingTaxReceivable: '1180', // VDS / TDS / CWT claims
roundingAdjustment: '7900', // FX rounding gain/loss
};
// In your bridge:
const acct = resolveTaxAccount(accounts.taxPayable, tax.code);PartnerBridge
Resolves partner contact + (optional) address. The engine calls getAddress() at post() time and freezes billingAddress / shippingAddress snapshots onto the invoice — compliance / retro-print requirement that historical invoices keep the address as of the issue date.
interface PartnerBridge {
resolveContact(partnerId: string, moveType: MoveType): Promise<PartnerContact | null>;
// Optional — engine skips address freeze when omitted.
getAddress?(partnerId: string, ctx: { organizationId?: string; session?: unknown }):
Promise<{ billing?: PartnerAddressSnapshot | null; shipping?: PartnerAddressSnapshot | null } | null>;
}TaxCalculator
interface TaxCalculator {
calculateLineTax(line: TaxLineInput, context: TaxContext): Promise<TaxResult> | TaxResult;
calculateInvoiceTax?(lines: TaxLineInput[], context: TaxContext): Promise<TaxResult[]>;
}Payment gateways
Gateway integration is NOT in this package — it lives in @classytic/primitives/payment-gateway, which defines the standard provider contract (ProviderCapabilities, CreateIntentParams, PaymentIntent, PaymentResult, RefundResult, WebhookEvent). Host wires a provider adapter (Stripe, bKash, etc.) against that contract; on a successful intent capture the host calls invoices.recordPayment(...) to land the money, and on a refund webhook calls invoices.refundPayment(...). The invoice engine stays gateway-agnostic.
CatalogBridge
See @classytic/invoice/domain/contracts for full type definitions.
State Machine
Invoice Status:
draft ──→ posted ──→ cancelled (if not_paid)
──→ voided (even if partially paid)
posted ──→ draft (unpost, if not_paid)
Payment Status (derived from amounts):
not_paid ──→ partial ──→ paid
paid ──→ partial ──→ not_paid (on reversal)Events
All operations emit typed domain events:
engine.events.on('invoice.posted', async (data) => {
console.log(`Invoice ${data.number} posted for ${data.totalAmount}`);
});
engine.events.on('invoice.paid', async (data) => {
// Send receipt email, update CRM, trigger fulfillment...
});16 events: invoice.created, invoice.posted, invoice.cancelled, invoice.voided, invoice.paid, invoice.partially_paid, invoice.payment.recorded, invoice.payment.reversed, invoice.credit_note.created, invoice.credit_note.applied, invoice.receipt.created, invoice.line.added, invoice.line.updated, invoice.line.removed, invoice.updated, invoice.deleted.
Configuration
createInvoiceEngine({
// Required
mongoose: connection,
// Defaults
currency: 'USD', // ISO 4217
scope: { strategy: 'field', field: 'organizationId' },
// Bridges (all optional)
ledger: myLedgerBridge,
tax: myTaxCalculator,
catalog: myCatalogBridge,
// PartnerBridge — resolves contact + (optional) addresses for posting-time freeze
partner: myPartnerBridge,
// Numbering
numbering: { out_invoice: { prefix: 'INV', partition: 'yearly', padding: 4 } },
// MongoKit plugins (audit, cache, soft-delete, etc.)
plugins: {
invoice: [auditTrailPlugin(), cachePlugin({ adapter: redis })],
},
// Schema extensions
schemaOptions: {
invoice: {
extraFields: { departmentId: { type: String, index: true } },
extraIndexes: [{ fields: { departmentId: 1, date: -1 } }],
},
},
// Idempotency deduplication
idempotency: true,
// Reverse-pointer callback fired after each successful createDraft. Stamp
// an `invoiceIds[]` array on a source document (PDF import / order /
// subscription) so downstream queries join either direction.
onInvoiceCreated: async (inv) => {
if (inv.sourceType === 'source-document' && inv.sourceId) {
await sourceDocs.updateOne({ _id: inv.sourceId }, { $push: { invoiceIds: inv._id } });
}
},
// Custom model names (avoid collisions)
modelNames: { invoice: 'MyAppInvoice', paymentAllocation: 'MyAppPayAlloc' },
});Subpath Exports
import { createInvoiceEngine } from '@classytic/invoice'; // Engine factory
import { MOVE_TYPES, Errors } from '@classytic/invoice/domain'; // Domain constants
import type { TaxCalculator } from '@classytic/invoice/domain/contracts'; // Bridge types
import type { InvoiceRepository } from '@classytic/invoice/repositories'; // Repo types
import { InvoiceEvents } from '@classytic/invoice/events'; // Event constants
import { resolveScopeConfig } from '@classytic/invoice/scope'; // Scope utilitiesDevelopment
npm test # All 301 tests
npm run test:unit # Unit only (no DB, <1s)
npm run test:services # Service tests (MongoMemoryServer)
npm run test:integration # Full lifecycle flows
npm run test:e2e # Engine creation + bridge wiring
npm run test:smoke # Export verification
npm run typecheck # tsc --noEmit
npm run lint # biome check
npm run build # tsdown → dist/Peer Dependencies
| Package | Version | Required | Why |
|---------|---------|----------|-----|
| @classytic/mongokit | >=3.16.0 | Yes | repo.claim / claimVersion / cursor field-grade primitives |
| @classytic/primitives | >=0.7.1 | Yes | MinorUnits brand, Money, splitAllocation, ApprovalChain |
| @classytic/repo-core | >=0.6.0 | Yes | HttpError + canonical error codes |
| mongoose | >=9.4.1 | Yes | Schema layer |
| zod | >=4.0.0 | Yes | Validation schemas |
All are peer dependencies — never bundled. Install them in your project.
License
MIT
