@wtfalch/invoicing
v0.1.1
Published
Norwegian invoices: gapless per-org numbering, credit notes, MVA (VAT) computed and rounded per rate, line items as first-class input, HTML documents, a period CSV export and a typed EHF seam.
Readme
@wtfalch/invoicing
Norwegian invoices: gapless per-org numbering, credit notes, MVA (Norwegian VAT) computed and rounded correctly per rate, line items as first-class input, an HTML document, a period CSV export with the VAT termin helper, and a typed EHF seam. It is not an accounting system and it never files anything with the government -- see the root README for scope.
Install
pnpm add @wtfalch/invoicing
pnpm exec invoicing-migrations # copies migrations/*.sql into drizzle/ as the next numbersApply the copied file with the host's own migrate script, or call migrate()
against any Queryable for a quick local setup:
import { migrate } from '@wtfalch/invoicing';
await migrate(db); // idempotent@wtfalch/authz is an optional peer, scaffolded per the estate's package
template: the invoicing catalogue offers invoicing:issue (write) and
invoicing:read (read). A host that does not use @wtfalch/authz can ignore
catalogue/checkInvoicingRead/checkInvoicingIssue entirely and authorize
calls its own way -- this package never calls them itself.
Issuing an invoice
Seller and buyer identity is plain data the caller supplies on every call --
this package has no counterparty store. orgNumber is optional: a
private-person buyer or a foreign counterparty may not have one.
import { issueInvoice } from '@wtfalch/invoicing';
const invoice = await issueInvoice(db, {
organisationId,
currency: 'NOK',
seller: {
legalName: 'Factory AS',
orgNumber: '123456789',
vatRegistered: true,
address: { street: 'Fabrikkveien 1', postalCode: '0001', city: 'Oslo', country: 'NO' },
},
buyer: {
legalName: 'Customer AS',
orgNumber: '987654321',
address: { street: 'Kundeveien 2', postalCode: '0002', city: 'Bergen', country: 'NO' },
},
lines: [
{ description: 'Consulting, September', quantity: '10', unitPriceMicros: '1000000000', vatRate: 25 },
],
dueDate: '2026-10-31', // mandatory: "vederlag og betalingsforfall"
deliveryDate: '2026-09-23', // mandatory: "tidspunkt ... for levering"
deliveryPlace: 'Oslo', // mandatory: "... og sted for levering"
reverseCharge: false, // true forces every line to vatRate 0
});
invoice.number; // "1", "2", ... -- gapless per (organisationId, 'invoice')
invoice.netMicros; // sum of line nets, in @wtfalch/ledger's micros
invoice.vatMicros; // VAT rounded once per rate group -- see "VAT rounding" below
invoice.grossMicros;
invoice.vatBreakdown; // [{ rate, netMicros, vatMicros }], one entry per rate usedunitPriceMicros and every other money amount is @wtfalch/ledger's
Money.micros -- convert with that package's toMicros/fromMicros, e.g.
toMicros('100.00'). quantity is a plain decimal string ("2.5" hours is
fine); it is not money and is not validated against microsSchema.
Line items are first-class input, independent of @wtfalch/billing: a host
that computes its own line items (e.g. manage, invoicing a customer's own
customer) calls issueInvoice directly with lines it built itself.
VAT rounding
The MVA split by rate (vatBreakdown) rounds once per rate group, from
that group's unrounded net sum -- never by summing each line's
individually-rounded VAT. Three lines of 3.33 net at 25% sum to 9.99 net;
the correct VAT is round(9.99 × 0.25) = 2.50, not round(3.33 × 0.25) × 3 =
0.83 × 3 = 2.49. netMicros/vatMicros/grossMicros on the returned
document are the sums of the already-rounded vatBreakdown entries, so they
always agree with what a reader gets by adding up the printed table.
Credit notes
import { issueCreditNote } from '@wtfalch/invoicing';
const creditNote = await issueCreditNote(db, {
organisationId,
invoiceId: invoice.id,
reason: 'Wrong quantity billed',
// lines: [...] -- omit for a full reversal (mirrors the invoice exactly)
});
creditNote.number; // its own gapless series: (organisationId, 'credit_note')
creditNote.referencesDocumentId; // invoice.id
creditNote.dueDate; // always null -- a credit note creates no new payment obligationA credit note mirrors the original invoice's seller, buyer, currency,
delivery date/place and reverse-charge flag. Omit lines for a full
reversal of the invoice's own lines; pass lines for a partial credit. It
can only be issued against an invoice, never against another credit note.
Documents and export
import { renderInvoiceHtml, exportPeriod, mvaTermin } from '@wtfalch/invoicing';
renderInvoiceHtml(invoice); // a self-contained HTML document
// evori (the planned accounting system) has no API yet: a CSV summary plus
// each document's HTML, for manual import.
const period = await exportPeriod(db, organisationId, { start: '2026-09-01', end: '2026-10-01' });
period.csv; // header + one row per invoice/credit note in the range
period.documents; // [{ id, kind, number, html }]
mvaTermin('2026-09-23'); // { number: 5, start: '2026-09-01', end: '2026-11-01', label: '2026 termin 5 (sep-okt)' }exportPeriod groups by each document's issueDate (mva-loven §15-1's
tidfesting rule), not by when the row was created. mvaTermin identifies
Norway's six bimonthly VAT reporting periods; it does not compute a filing
deadline (term 3's summer extension is unverified against a primary source
here -- an accountant applies their own deadline from the termin number).
The EHF seam
v1 does not produce EHF (Norway's e-invoice format, mandatory for B2B from
2027-01-01). toEhfInvoiceData is a pure mapping from an issued document to
a typed shape an external EHF/UBL generator can build against; no XML, no
PEPPOL access-point call.
import { toEhfInvoiceData } from '@wtfalch/invoicing';
import type { EhfEmitter } from '@wtfalch/invoicing';
const data = toEhfInvoiceData(invoice);
// an EhfEmitter (not shipped here) turns `data` into EHF XML / a PEPPOL submissionTests
pnpm test # PGlite, in memory
TEST_DATABASE_URL=postgres://... pnpm test # a real Postgres; a scratch schema per runGapless numbering under concurrency is only meaningfully exercised against a
real Postgres (PGlite is a single connection, so "concurrent" calls in that
tier serialize rather than racing) -- see docs/adr/0007-testing.md.
