@classytic/invoice
v0.1.2
Published
Country-agnostic invoice engine for MongoDB — invoices, bills, credit notes, receipts, aging, payments
Downloads
408
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.
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. |
| PaymentAllocation | payment_allocations | Tracks which payments cover which invoices. Separate collection for many-to-many (bulk payments, partial payments). |
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)
├── PaymentBridge ──→ @classytic/revenue (or Stripe, 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)PaymentService — record and reconcile
const { payment } = engine.services;
// Record payment
const allocation = await payment.recordPayment({
invoiceId: id,
paymentId: 'pay-123',
amount: 50000,
method: 'bank_transfer',
}, ctx);
// Reverse (chargeback, refund)
await payment.reversePayment(allocation._id, 'chargeback', ctx);
// Payment history
const history = await payment.getPaymentHistory(id, ctx);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): Promise<string>;
recordPayment(input: LedgerPaymentInput): Promise<string>;
}TaxCalculator
interface TaxCalculator {
calculateLineTax(line: TaxLineInput, context: TaxContext): Promise<TaxResult> | TaxResult;
calculateInvoiceTax?(lines: TaxLineInput[], context: TaxContext): Promise<TaxResult[]>;
}PaymentBridge / 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,
payment: myPaymentBridge,
catalog: myCatalogBridge,
// 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,
// 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 90 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 |
|---------|---------|----------|
| @classytic/mongokit | >=3.5.6 | Yes |
| mongoose | >=9.4.1 | Yes |
Both are peer dependencies — never bundled. Install them in your project.
License
MIT
