accounting-up
v0.1.5
Published
MIT-licensed, Canadian-tax-capable, embeddable double-entry accounting core for Node.js — a free replacement for QuickBooks / FreshBooks / Wave for Canadian SMBs.
Maintainers
Readme
accounting-up
MIT-licensed, Canadian-tax-capable, embeddable double-entry accounting core for Node.js.
A standalone library that other applications can embed as an npm package: a free, offline, in-process replacement for QuickBooks / FreshBooks / Wave aimed at Canadian small businesses. No daemons, no databases to install, one SQLite file per company.
Status: early build. The
ledgermodule (double-entry core, chart of accounts, trial balance, P&L, balance sheet, cash flow), thetaxmodule (baked-in CRA rates, place-of-supply, GST/HST/PST/QST calculation, net-tax), theinvoicingmodule (customers, invoices, estimates, credit notes, payments, A/R aging, recurring billing, reminders, PDF), thebankmodule (CSV / OFX / QBO statement import + reconciliation), theexpensesmodule (records with ledger posting, rule-based categorization, receipt OCR parsing), thebillsmodule (vendors, bills, vendor credits, payments, A/P aging), thepayrollmodule (T4127 deduction engine, employees, payroll runs, remittance reports, T4/RL-1 XML), theinventorymodule (products, stock movements, weighted-average costing, COGS on invoice), thetimeTrackingmodule (projects, time entries, billable hours → invoice), thepaymentsmodule (Stripe Payment Links via an injected client, self-contained HTML payment pages), and thebackupmodule (whole-book checkpoints written by SQLite itself, CRA's six-year retention, and a safe restore) are implemented. Every core module from the brief is now built; seeWHAT-YOU-CAN-BUILD.mdfor an honest capability assessment.
What's in the npm package
files ships dist (ESM JavaScript + .d.ts), LICENSE and this README — the library, and
nothing else. Consuming it looks like any other Node package:
- No user interface. There is no web app, no window and no HTML in the package. The repo also
holds
ui/(a reference server-rendered web app over the library) andexamples/(a reference CLI); neither is published, and both exist only to show what an embedder can build. The two sections below that describe them are marked repo-only. - No native module, no server, no daemon. SQLite is the only storage and the binding is
injectable (
openBookWith) — Node's built-innode:sqliteis enough;better-sqlite3is an optional peer dependency and is never required. One SQLite file per company. - No network I/O and no vendor SDK in the dependency tree. Stripe, receipt OCR and PDF rasterization are injected by the host — the core makes no request and holds no key.
- No build step for the consumer.
dist/is committed to the tarball as ESM with its own type declarations.
What's new in 0.1.5
Everything below is additive; no existing API call changed shape. The receipt parser's own answers do change, and that is the point.
expenses— a price has to look like a price. The last-resort total scan, the fallback for a file with noTOTALlabel at all, now requires the amount to carry a currency symbol or cents after a decimal point; anything else is skipped. This is what stops a year, an order number or a reference being read as the amount: on a utility summary the only money-shaped text near the bottom of the page was the date lineBilling date: Sep 10, 2026, and the parser returned2026.00as the total. A labeledTOTAL 45still takes the bare45, since the label already vouches for it; only the unlabeled fallback is strict.expenses— anAmount Duetotal is treated as tax-inclusive. When the itemized tax is found, it is now subtracted from an amount-due figure exactly as it is from a labeled total, so the pre-tax amount can never have the tax counted twice. Previously only a labeled total had the tax taken off, which left a bill whose single money line was an amount due tax-inclusive, ready to be taxed again by the caller.expenses— payment terms can be derived from the file's own dates. When noNET:orTerms:tag is printed,paymentTermsDaysis the difference between the document's issue date and the due date it labels, when both are present and the due date is later. A printed tag still wins, since it is what the supplier actually said. The termsokcheck now reports which source it used ("this file states" versus "this file's own issue and due dates work out to"). One date alone derives nothing, so a window is never invented from half of a pair.
What's new in 0.1.4
Everything below is additive; no existing API call changed shape.
expenses— the receipt parser reads what a bill would otherwise have typed in by hand: the supplier's payment terms, its email and its phone.ReceiptExtractiongainspaymentTermsDays,emailandphone, each read from the page ornull. A printed term (NET: 14,Terms 30,Net 30 days) becomes a number of days;net amount(a money label, not a deadline), a week or month count, and numbers too large to be a payment window are refused rather than coerced, and the terms are reported as anokcheck because a bill's due date follows them. The phone is read as a phone is printed, so its separators are required: a GST/HST registration number and a barcode's digit run have no phone's shape and cannot land in the field. A labelled line (Tel:,Call us at) wins over the first number on the page, and a line labelled for the customer is skipped, because a bill-to block prints the customer's own number first. A value the file does not carry staysnull— a till receipt never prints terms — so the caller decides what a silent file means, rather than the parser inventing a number.
What's new in 0.1.3
Everything below is additive; no existing API changed shape. The receipt parser's own answers do change, and that is the point of the last item.
ledger— twelve everyday accounts join the chart of accounts (42 → 54). The things a new or small Canadian business meets in its first year and had nowhere good to put:1005Cash on hand,1010Undeposited funds,1050Payment processor clearing,2030Customer deposits (deferred revenue), which matters because a deposit taken before the work is done is not income yet,3020Owner contributions,4040Interest income,6035Software & subscriptions,6095Payment processing fees,6130Licences, dues & memberships,6140Travel,6145Parking & tolls, and6190Interest expense. A book that already exists keeps its own accounts.tax— corporate income tax due dates, and the business type they depend on.corporateTaxDueDateandcorporateTaxDueItemanswer when a corporation's T2 return is due (six months after the year-end, on the last day of that month),parseBusinessTypeandBUSINESS_TYPESname the two types, and the T2 is offered only once a business says it is a corporation, because a sole proprietor files no T2. Every item still states the rule that produced it.expenses— the receipt parser reads the right date and the right supplier. A document's own issue date is used as the date, never the line it labels as its late or due date, and a file whose only date is a deadline says so as a warning rather than passing the deadline off as the issue date. A month name glued to its day (October12,2026, which is what a tight label column produces) and a day-first month name (12 October 2026) both read as dates. The supplier is the business's legal name (Inc.,Ltd.,LLC) rather than a form label (INVOICEDATE: …) or the bill-to block at the top of the page, which is the customer.
What's new in 0.1.2
invoicing— refunds.recordRefundsends money back to a customer against an invoice, the mirror of a payment (Dr A/R, Cr cash), so the invoice stops counting cash that has left again:refundedCentsandnetPaidCentsare reported besidepaidCents, andbalanceCentsis what is still owed. A refund larger than what the customer actually paid is refused in words.- One ledger path, as everywhere else in this package. A refund is a real withdrawal, so it is written as a manual bank transaction and reconciles like any other money out.
cancelledis a derived status, never a stored one. An invoice fully reversed by issued credit notes reads ascancelled, computed from those credit notes, so the figure and the word cannot disagree.
What's new in 0.1.1
Everything below is additive — no existing API changed shape.
backup— whole-book checkpoints and a safe restore. A book is one SQLite file, so the failure that loses everything is a file with no history; CRA separately requires records to be kept six years. One mechanism answers both:createCheckpoint(SQLite's ownVACUUM INTO, so the copy is consistent with the book open),listCheckpoints,pruneCheckpointswithCHECKPOINT_RETENTION({ keepRecent: 30, keepMonths: 72 }) andretentionSentence(), andstageRestore(put a copy back, keeping a copy of what it replaced). See Records and checkpoints.reports— the year-end package.yearEndPackage/yearEndPackageZipbuild one archive for an accountant: printable statements, the general ledger, the GST/HST return's own numbers and the payroll remittance summary, plus a README that names what is deliberately not in it. Every figure is the book's own computed number. The ZIP is written bycrc32/zipStorein this package — dependency-free, STORE-only.tax— the why beside a number, and CRA's due dates.explainPlaceOfSupply(facts)says, in one sentence, which CRA rule decided a place of supply;PROVINCE_NAMES/provinceNameare the one table of province names;dueItemsreturns the next GST/HST and payroll due dates from the filing frequency and remitter type, each with the rule that produced it.invoicing— the tax reason and the registration number on the document.taxReasonLine(built from an invoice's own province, rate components and storedplaceOfSupplyfacts),taxRateText(a whole rate stays whole,9.975%never prints as9.98%),gstHstLine, andInvoice.placeOfSupplyis now kept so the sentence explains the rule that was applied rather than the one that usually applies.invoiceToPdftakesgstHstNumber.expenses— the 50% meals and entertainment ITC limit.splitMealItc,MEAL_ITC_CLAIMABLE_PERCENTandMEALS_ACCOUNT_CODE(6100), so a meals expense claims half the tax and expenses the rest — with the claimable half rounded down so the halves always add back to the tax paid.
Why it exists
The Node ecosystem has no MIT-licensed, Canadian-tax-capable, embeddable accounting package that is free for commercial reuse inside closed-source apps. This project is that missing piece.
Features (planned scope — every one below is implemented)
- Double-entry ledger + chart of accounts
- Invoicing: PDF output, recurring billing, estimates, credit notes, payment reminders, A/R tracking
- Expenses: entry, receipt OCR (host-injected; the reference UI uses PaddleOCR), rule-based categorization
- Bills + A/P
- Bank statement import (CSV/QBO/OFX) + reconciliation
- Sales tax: GST/HST/PST/QST per province, place-of-supply rules, remittance (net-tax) reports
- Payroll: CPP/QPP, EI, income-tax source deductions (CRA T4127), T4/RL-1 XML
- Inventory: products, stock levels, weighted-average costing, COGS on invoice
- Time tracking: projects, billable entries, create invoice from time
- Online invoice payment: Stripe (cards + ACSS pre-authorized debit) + client payment pages (self-contained HTML per invoice)
- Reports: P&L, balance sheet, trial balance, cash flow, AR/AP aging
- Records: whole-book checkpoints (SQLite
VACUUM INTO), six-year retention, safe restore
See ACCOUNTING-APP-BRIEF.md for the full specification, and
WHAT-YOU-CAN-BUILD.md for an honest assessment of what you can build with
this today — including how it compares to QuickBooks / FreshBooks / Wave and
where it falls short.
Requirements
- Node.js >= 22.6 (the tests run TypeScript natively via type stripping)
- A SQLite binding.
better-sqlite3is an optional peer dependency: install it andopenBook()works out of the box, or bring your own driver — including Node's built-innode:sqlite— and install no native module at all. See Choosing a database driver.
Install
npm install accounting-upQuick start
import { openBook } from "accounting-up";
const book = openBook(":memory:");
book.createAccount({ code: "1000", name: "Cash", type: "asset", normalBalance: "debit" });
book.createAccount({ code: "4000", name: "Revenue", type: "revenue", normalBalance: "credit" });
book.postEntry({
date: "2026-01-15",
memo: "Sale for cash",
lines: [
{ account: "1000", debit: 250_00 }, // integer cents
{ account: "4000", credit: 250_00 },
],
});
console.log(book.trialBalance());
console.log(book.balanceSheet("2026-12-31"));Choosing a database driver
The core never imports a SQLite binding. It talks to a small surface —
prepare, exec, transaction, pragma, close, plus run / get / all on a
prepared statement — and you say which binding provides it.
openBook(path) is the default and uses better-sqlite3 (loaded lazily, so a
consumer that never calls it never needs the package installed). openBookWith(driver, path)
takes your own driver instead:
import { openBookWith, type SqliteDriver } from "accounting-up";
import { DatabaseSync } from "node:sqlite";
// A driver over Node's built-in SQLite — no native module, no install step.
const nodeSqlite: SqliteDriver = (path) => {
const db = new DatabaseSync(path);
return {
prepare: (sql) => {
const stmt = db.prepare(sql);
return {
run: (...a: unknown[]) => stmt.run(...a),
get: (...a: unknown[]) => stmt.get(...a),
all: (...a: unknown[]) => stmt.all(...a),
};
},
exec: (sql) => { db.exec(sql); },
close: () => db.close(),
pragma: (text) => db.exec("PRAGMA " + text),
// better-sqlite3's shape: transaction RETURNS a callable rather than running it.
transaction: (fn) => (...args: unknown[]) => {
db.exec("BEGIN");
try {
const out = fn(...args);
db.exec("COMMIT");
return out;
} catch (e) {
try { db.exec("ROLLBACK"); } catch { /* the caller's error is the one that matters */ }
throw e;
}
},
};
};
const book = openBookWith(nodeSqlite, ":memory:");This is what lets the same core run in a normal Node process (on
better-sqlite3) and inside a sandboxed embedder that cannot ship native code
(on node:sqlite). The seam is deliberately at the binding and nothing else:
the ledger, the tax rules and every module behave identically whichever driver
is supplied — no consumer gets a different set of books.
Every book is opened with two pragmas (src/ledger/schema.ts):
foreign_keys = ON and journal_mode = WAL (a :memory: book keeps SQLite's
memory journal). WAL is why a copy of a book should be made by SQLite and not
by copyFile: a book's newest writes live in the -wal journal beside the
file until a checkpoint — measured on a real book, a 4 KB file with a
668 KB journal — so copying the file bytes alone can produce a copy that is
missing today's entries. VACUUM INTO (what the backup module uses) writes a
consistent copy with the book open.
Sales tax
import { calculateTax, determinePlaceOfSupply, computeNetTax, cents } from "accounting-up";
// Which province's rate applies? CRA place-of-supply rules.
const province = determinePlaceOfSupply({ supplyKind: "goods", goodsDeliveredTo: "NS" });
// Tax on a $250.00 sale delivered to Nova Scotia → 14% HST.
const tax = calculateTax(cents(25000), province);
tax.totalTaxCents; // 3500
// Quebec rounds QST separately: GST on the price, QST on the pre-GST price.
const qc = calculateTax(cents(10000), "QC");
qc.components; // [{ type: "gst", amountCents: 500 }, { type: "qst", amountCents: 998 }]
// Remittance: GST/HST collected minus input tax credits (return line 105).
computeNetTax(
{ gstCents: cents(1250), hstCents: cents(0), pstCents: cents(0), qstCents: cents(0) },
{ gstCents: cents(300), hstCents: cents(0), pstCents: cents(0), qstCents: cents(0) },
).gstHstNetTaxCents; // 950Rates are baked in from CRA / Revenu Québec with provenance per province
(src/tax/rates.ts, RATES_VERSION). Rates rarely change, but when they do
this repo notices: a cross-check test against the MIT sales-tax package runs
on every CI run, and a weekly check:tax-rates job diffs the table against the
live CRA page. Updating a rate is a deliberate, tested change — nothing
silently mutates at runtime.
Invoicing
import { openBook, cents } from "accounting-up";
const book = openBook("book.db");
book.installDefaultChartOfAccounts();
const inv = book.invoicing;
// A draft invoice is an estimate — nothing posts until it's issued.
const customer = inv.createCustomer({ name: "Acme Retail", province: "BC" });
const invoice = inv.createInvoice({
customerId: customer.id,
issueDate: "2026-03-01",
dueDate: "2026-03-31",
placeOfSupply: { supplyKind: "goods", goodsDeliveredTo: "NS" }, // 14% HST
lines: [{ description: "Widgets", quantity: 3, unitPriceCents: cents(2500) }],
});
// Issue → one balanced entry: Dr A/R, Cr Revenue, Cr GST/HST payable.
inv.issueInvoice(invoice.id);
book.accountBalance("1100"); // 8550 ($75 + 14%)
// Payments, credit notes, A/R aging, recurring billing, reminders…
inv.recordPayment(invoice.id, { date: "2026-03-10", amountCents: cents(8550) });
inv.agingReport("2026-04-01");
// PDF (pdfmake, MIT — the actively-maintained engine the brief's stale
// easyinvoice entry wraps anyway).
const pdf: Buffer = await inv.invoiceToPdf(invoice.id, {
businessName: "Maple & Pine Studio",
businessAddress: ["123 Main St", "Vancouver, BC"],
});Invoices post GST/HST to the 2100 net-tax account, QST to 2120 and PST to
2130 (overridable per invoice via accounts.tax). A draft invoice IS an
estimate/quotation; issuing it is acceptance. Credit notes reverse revenue and
tax and reduce the receivable. A/R always reconciles: the ledger 1100 balance
equals the sum of outstanding invoice balances.
Download PDF and the payment page share one look. Both are driven by a
single source of truth (src/invoicing/invoiceView.ts — theme tokens, status
badges, tax-row labels, payment-CTA strings), so a palette or wording change
updates both at once. The PDF includes the payment-methods logo (rasterized by
the host) and a clickable PAY $… SECURELY button when a Stripe link exists
on the invoice; without a link it shows the red "Balance due $…" bar and a
"contact us for a payment link" line, mirroring the web page. The layout
(screen vs print) is tuned separately in each renderer — see the note at the
top of invoiceView.ts for what's shared and what isn't.
Bank import & reconciliation
import { openBook, cents } from "accounting-up";
const book = openBook("book.db");
book.installDefaultChartOfAccounts();
const bank = book.banking;
// Import a statement: CSV (column detection, RFC-4180), OFX 1.x SGML,
// OFX 2.x XML, or QuickBooks QBO. Re-importing the same file is idempotent
// (deduped by bank reference; one shared import id per file).
const result = bank.importStatement({ accountCode: "1000", text: csvText });
result.inserted; // rows added
// Link a bank line to an existing journal entry (e.g. an invoice payment),
// or post the unmatched ones straight into the ledger.
bank.matchTransaction(lineId, entryId);
bank.postTransaction(lineId, {
lines: [
{ account: "6030", debit: cents(4210) }, // office supplies
{ account: "1000", credit: cents(4210) },
],
});
// Reconciliation: bank balance vs ledger balance as of a date.
// differenceCents === 0 → reconciled.
const report = bank.reconcile("1000", "2026-03-31");parseBankCsv / parseOfx / parseQbo are also exported standalone, so a
statement can be parsed without touching a book. The parsers are owned
in-house: the brief's ofx-to-csv was never published on npm and
node-ofx-parser has been stale since 2022, and a hand-rolled RFC-4180
tokenizer avoids the csv-parse runtime dependency entirely — the mapping
layer is where bank-format complexity lives anyway.
Reports
// Trial balance, P&L, balance sheet — see the ledger quick start above.
// Direct-method cash flow statement for a range. Every cash cent of every
// posted entry is attributed to a section by its counterpart account:
// Operating (A/R, inventory, A/P, tax payable, …), Investing (capital assets
// in the 15xx–16xx range) or Financing (equity, bank loan 2500).
const cf = book.cashFlowStatement("2026-01-01", "2026-03-31");
cf.operating.totalCents; // net cash from operations
cf.investing.totalCents; // equipment bought/sold
cf.financing.totalCents; // loan proceeds/repayments, owner contributions
cf.reconciles; // always true — sections sum to closing − openingA/R aging lives on the invoicing module (book.invoicing.agingReport(asOf))
and A/P aging on the bills module (book.bills.agingReport(asOf)); both
reconcile with their ledger accounts (1100 and 2000) by construction. The
cash flow report reconciles too (property-tested): each cash dollar is
attributed exactly once, so operating + investing + financing + transfers
always equals the change in cash.
Expenses
import { openBook, cents } from "accounting-up";
const book = openBook("book.db");
book.installDefaultChartOfAccounts();
const exp = book.expenses;
// Rule-based categorization: keyword → account, longest keyword wins.
exp.addCategorizationRule("staples", "6030");
// Recording an expense posts the journal entry immediately:
// Dr Office supplies 50.00 / Dr GST/HST paid (ITC) 6.50 / Cr Cash 56.50
// Category auto-fills from the rules; the ITC flows into the same net-tax
// machinery as invoicing (2110), so remittance nets HST collected vs paid.
const expense = exp.recordExpense({
date: "2026-02-03",
vendor: "Staples Business Depot",
description: "Printer ink",
amountCents: cents(5000),
taxCents: cents(650),
});
expense.category; // "6030" — auto-categorized
// Receipts: parse structured data from OCR text (vendor, date, total, tax).
const receipt = exp.extractReceiptFromText(ocrText);
// OCR itself is injected — the core stays dependency-free. Wrap any OCR
// engine in the host app and pass it in (the reference UI uses PaddleOCR via
// ppu-paddle-ocr, MIT, which beats tesseract.js on low-quality thermal
// receipts):
const parsed = await exp.ocrReceipt(imageBytes, ocrFunction);Receipt OCR is intentionally heuristic (brief §7.6): structured parsing is
best-effort — confidence says whether a labeled total and a date were both
found — and manual correction is easy (recordExpense takes an explicit
category, and recording never guesses: no category, no rule match → it
errors). Paid receipt-OCR SaaS is a later upgrade path, not a V1 dependency.
Expenses can be edited (a correction voids the original journal entry and posts a fresh one in the same transaction — the audit trail is kept) and, once voided, permanently deleted; a live expense can never be deleted out from under the books. The web UI offers Edit on live expenses and Delete on voided ones, both behind confirmation overlays.
Bills + A/P
import { openBook, cents } from "accounting-up";
const book = openBook("book.db");
book.installDefaultChartOfAccounts();
const bills = book.bills;
// Vendors carry default net payment terms; a bill's due date defaults to
// bill date + terms.
const vendor = bills.createVendor({ name: "Staples Canada", province: "ON", paymentTermsDays: 30 });
// Recording posts immediately — Dr expense (per line), Dr input tax credit,
// Cr A/P. Province = place of supply of the purchase; no province = no tax.
const bill = bills.recordBill({
vendorId: vendor.id,
billDate: "2026-03-01",
province: "ON",
lines: [{ description: "Office supplies", unitPriceCents: cents(10000) }],
});
book.accountBalance("2000"); // 11300 ($100 + 13% HST)
// Pay it (Dr A/P, Cr cash; partial payments allowed), vendor credits reduce
// it, and A/P aging buckets what's still owed.
bills.payBill(bill.id, { date: "2026-03-20", amountCents: cents(11300) });
bills.agingReport("2026-04-01");Input tax credits default to the same net-tax machinery as invoicing:
GST/HST paid → 2110 (nets against GST/HST collected on the CRA return),
QST paid → 2120 and PST paid → 2130 (nets against the separate
remittances). All per-component accounts are overridable via accounts.tax.
Vendor credits (returns, allowances) reverse expense and ITC and reduce the
payable. A/P always reconciles: the ledger 2000 balance equals the sum of
outstanding bill balances.
Payroll
import { openBook, cents } from "accounting-up";
const book = openBook("book.db");
book.installDefaultChartOfAccounts();
const payroll = book.payroll;
const employee = payroll.createEmployee({
firstName: "Ada",
lastName: "Lovelace",
province: "ON",
payPeriod: "monthly",
sin: "000000000",
});
// Run payroll: deterministic CRA T4127 deductions (CPP/CPP2, EI, federal +
// provincial tax, QPIP for Quebec), YTD-aware so annual caps bind.
const run = payroll.runPayroll({
date: "2026-01-31",
periodStart: "2026-01-01",
periodEnd: "2026-01-31",
lines: [{ employeeId: employee.id, grossCents: cents(500000) }],
});
run.lines[0].netCents; // take-home pay
run.lines[0].cppCents; // CPP (QPP for Quebec employees)
run.lines[0].federalTaxCents; // + provincialTaxCents
// What you owe where: the CRA vs Revenu Québec (QPP/QPIP/EI-QC/QC tax).
const remittance = payroll.remittanceReport("2026-01-01", "2026-12-31");
remittance.craCents; // CRA remittance
remittance.revenuQuebecCents; // Revenu Québec remittance
// Annual slips for e-filing: T4 (all provinces) and RL-1 (Quebec).
const t4xml = payroll.t4Xml({
year: 2026,
businessNumber: "123456789",
rpAccount: "123456789RP0001",
businessName: "Maple & Pine Studio",
businessAddress: "123 Main St",
businessCity: "Toronto",
businessProvince: "ON",
businessPostalCode: "M5V 2T6",
});
const rl1xml = payroll.rl1Xml({
year: 2026,
employerName: "Maple & Pine Studio",
employerAddress: "123 Main St",
employerCity: "Montréal",
employerPostalCode: "H2X 1Y4",
});Every run posts one balanced entry per employee: Dr wages (gross), Dr
employer contributions, Cr cash (net), Cr CPP/QPP payable, Cr EI payable,
Cr income-tax payable (and Cr QPIP for Quebec). The deduction engine
implements the CRA T4127/T4032 structure in integer cents and is golden-tested
against the published T4032 Ontario worked example (a $1,300 weekly earner
with an $80 RRSP → CPP $73.35, EI $21.19, federal $5,957.85/yr, Ontario
$3,264.26/yr). All 2026 rates are verified against CRA/Revenu Québec sources
(src/payroll/rates.ts, PAYROLL_RATES_VERSION); a new year is one reviewed
data change. TD1 claims: the basic personal amount for every province plus a
generic "additional tax credits" field per employee (tuition, disability,
RRSP, …) — the same single field QuickBooks/Wave offer. Known V1 limits: the
individual spousal/dependant TD1 claim codes are not modelled, and the
low-income tax reductions for NB/NL/NS/PE are not (BC's and Ontario's are) —
over-withholding there is refunded at T1 filing.
Inventory
Products track SKU, cost, selling price, and stock levels. Weighted-average
costing updates on each purchase receipt. When an invoice line references a
product (productId), issuing it automatically posts COGS (Dr expense / Cr
inventory) and decreases stock. Voiding the invoice restores stock.
const inventory = book.inventory;
const widget = inventory.createProduct({
sku: "WIDGET-A",
name: "Widget",
unitCostCents: cents(500),
sellPriceCents: cents(1200),
quantityOnHand: 100,
});
// Purchase receipt: 50 more at $6/unit → new avg = (100×5 + 50×6) / 150 = $5.33
inventory.recordPurchase(widget.id, 50, cents(3000));
// Sell 5 via invoice (posts Dr COGS / Cr Inventory automatically).
const invoice = book.invoicing.createInvoice({
customerId: customer.id,
issueDate: "2026-01-15",
province: "ON",
lines: [{ description: "Widget", quantity: 5, unitPriceCents: cents(1200), productId: widget.id }],
});
book.invoicing.issueInvoice(invoice.id);Time tracking
Projects hold a default hourly rate; time entries track billable work. Creating an invoice from unbilled entries produces one line per entry (duration × rate) and links them to prevent double-billing.
const tt = book.timeTracking;
const project = tt.createProject({
name: "Website Redesign",
customerId: customer.id,
defaultRateCents: cents(15000), // $150/hr
});
tt.createTimeEntry({ projectId: project.id, date: "2026-01-15", durationMinutes: 120 });
tt.createTimeEntry({ projectId: project.id, date: "2026-01-16", durationMinutes: 60 });
// Create draft invoice from unbilled entries (3 hrs × $150 = $450 + tax).
const draft = tt.createInvoiceFromTimeEntries(project.id, {
customerId: customer.id,
issueDate: "2026-01-20",
province: "ON",
});
book.invoicing.issueInvoice(draft.id);Records and checkpoints
A book is one SQLite file. The failure that loses everything is a file with no history, and CRA separately requires business records to be kept for six years — so one mechanism covers both: a checkpoint, which is a complete copy of the one file.
import {
openBook, createCheckpoint, listCheckpoints, pruneCheckpoints, stageRestore,
CHECKPOINT_RETENTION, retentionSentence, checkpointDir,
} from "accounting-up";
const book = openBook("book.db");
// Take a copy NOW. `VACUUM INTO` runs with the book still OPEN, so the copy is a consistent
// database (never a half-written file) and is a complete book on its own.
const copy = createCheckpoint(book, "manual");
copy.name; // "2026-10-07_060453_manual.db" — UTC, so a name is unambiguous and sorts by time
copy.path; // <book folder>/checkpoints/<book name>/2026-10-07_060453_manual.db
copy.sizeBytes; // bytes on disk
listCheckpoints(book); // newest first: { name, createdAt, sizeBytes, reason, path }
checkpointDir(book.path); // where a book's copies live — beside the book, per book
retentionSentence(); // "The newest 30 copies are always kept, and then the newest copy of
// each month for 72 months (6 years) — CRA asks for six years of records."
CHECKPOINT_RETENTION; // { keepRecent: 30, keepMonths: 72 }
// Apply the retention rule — the newest 30, then the newest copy of each month for 72 months.
// A `before-restore` copy is kept beyond both windows: it is the undo of an undo.
pruneCheckpoints(book); // { removed, kept }
// Put a copy back. THE BOOK MUST BE CLOSED FIRST — this is file work, and swapping a file under
// an open SQLite connection is how a book is lost.
book.close();
const { restoredFrom, safety } = stageRestore("book.db", copy.name);
// `safety` is a copy of what was just replaced (reason `before-restore`), so a restore is itself
// undoable — and the stale `-wal` / `-shm` / `-journal` beside the restored file is removed.Why a copy of the file, and not a git branch. A branch is for parallel lines of work that get merged; a book's whole point is that there is exactly one true version of it, so branches raise the worst possible question for a set of books — which branch is my real books? A checkpoint is a full copy of the one file, on a single timeline.
Why SQLite writes it, and not copyFile. A book runs in WAL mode, and its newest work lives
in the journal beside it (measured on a real book: a 4 KB file with a 668 KB journal), so copying the
file bytes alone can produce a copy that is missing today's entries — and can catch a half-written
database. VACUUM INTO writes a consistent, standalone copy with the book open. For the same reason
stageRestore copies the book file and its journal when it takes the safety copy of what it is
about to replace.
The four reasons, written into each copy's file name, so a list explains itself:
manual, day (once a day), before-year-end-close, before-restore. Not a copy after every
change: a day of posting is dozens of changes, which is hundreds of unnamed near-identical copies a
week — a log, not a history.
Online payments (Stripe)
Get paid online without a backend: the core builds a Stripe Payment Link
for an invoice's outstanding balance via an injected Stripe client (the
official stripe SDK — MIT — stays the host's dependency; the core performs
no network I/O and never sees a secret key), then renders a
self-contained HTML payment page you can upload to any static host.
Stripe hosts checkout; cards and Canadian bank pre-authorized debit (ACSS)
are offered by default.
Which Stripe key do I choose?
When you create a key at dashboard.stripe.com/apikeys, Stripe asks "How will you be using this key?" — the right answer depends on your situation:
| Situation | Choose on Stripe | Why | |---|---|---| | You're the developer AND the business owner (sole proprietor using this for your own business) | Powering an integration you built | You're building this app for yourself. The key powers your own integration. | | You're a developer building this for other businesses (the platform/Connect model) | Powering an integration you built | Your platform account initiates the OAuth flow that connects each business owner's own Stripe account. | | You're a business owner (non-technical, someone set this app up for you) | Providing this key to a third-party application | You're giving your key to this app so it can create payment links on your behalf. | | AI agent / automation | Authorizing an AI agent | Not used by this project. Don't choose this. |
Setup: the quick version
- Copy
.env.exampleto.env - Go to dashboard.stripe.com/apikeys
- Click Create secret key
- Choose the option that matches your situation (see table above)
- On the next screen, follow the guide below for your chosen option
- Copy the key (
sk_test_...for testing,sk_live_...for real payments) - Paste it into
.envasSTRIPE_SECRET_KEY=sk_... - Or paste it in the web UI: Settings → Online payments → paste key → Connect Stripe
That's it. Issue an invoice, click Create payment link, share the link with your client.
What to pick on each screen
If you chose "Powering an integration you built":
The next screen asks what kind of integration. Choose:
→ One-time payments ✅
This is the right one. Payment Links are one-time payments — a client clicks the link, pays once, done. It gives you 30 permissions which is everything this app needs.
Don't choose:
- Recurring subscriptions and billing — not used (no subscription billing)
- In-person payments with Terminal — not used (no card readers)
- Reporting, analytics, and accounting — read-only, too limited
If you chose "Powering an integration you built" → One-time payments, the next screen is the permissions picker. Leave all permissions at their defaults (Read/Write as shown). The one-time-payments preset already covers everything this app needs.
If you chose "Providing this key to a third-party application":**
The next screen asks for details about the app. Fill in:
- Name:
bookkeeper(or whatever you called this app) - URL: Your app's real URL — for example
https://yourbusiness.ca. Do NOT uselocalhost:3000— that only works on your own computer. Your clients need a real website address to reach the payment page. If you don't have a website yet, you can host the payment page on a free static host (GitHub Pages, Netlify) and use that URL.
Then the permissions screen appears. Make sure these are set to Write (the app needs to create Payment Links and read Invoices):
- Core → Invoices → Write
- Core → Customers → Read
- Payment Links → Payment Links → Write
- Core → Products → Write (for the line-item name on each link)
Everything else can stay at None (left unchecked). The app doesn't need access to your bank account, payouts, or anything else.
That's it. Click Create key and copy the sk_... value.
Setup: what each role needs to know
If you're a developer building this for your own business:
- Create a Stripe account in your business/legal name
- Choose "Powering an integration you built"
- Paste the key in
.envor Settings - You're done — you're both the platform and the business owner
If you're a developer building this for OTHER businesses (the Connect model):
- Create a Stripe account in YOUR name (the platform)
- Choose "Powering an integration you built"
- Enable Connect (Settings → Connect) and create an OAuth app to get a
client_id(Dashboard → Developers → OAuth) - Your platform key + client id + redirect URI go in
.env - Each business owner connects their OWN Stripe account via a one-click
"Connect Stripe" button — see
STRIPE-CONNECT.mdfor the full step-by-step for every situation - You never hold their keys — Stripe handles the connection securely
- You pay nothing per connected account (Stripe's Connect pricing, §7.4)
Both models stay supported: the simpler direct-key flow (solo dev-owner)
and the Connect OAuth flow (developer-for-owner and solo owner via
the one-click button). See STRIPE-CONNECT.md.
If you're a business owner (non-technical):
- Ask the developer who set up the app for your Stripe key
- Or create one yourself: go to dashboard.stripe.com → API keys → Create secret key
- Choose "Providing this key to a third-party application"
- Fill in the app name and your real website URL (not localhost)
- On the permissions screen, enable Write for Invoices, Payment Links, and Products — leave everything else off
- Paste the key in Settings → Online payments
- The app uses it to create payment links — you never see code or terminals
- Your money goes to YOUR Stripe-connected bank account, not the developer's
What works and what doesn't (honest status)
| Feature | Status |
|---|---|
| Create Stripe Payment Links from invoices | ✅ Works now |
| Self-contained HTML payment pages (upload to any host) | ✅ Works now |
| Cards + Canadian bank debit (ACSS) | ✅ Works now |
| Stripe key stored in .env (never in code) | ✅ Works now |
| Stripe key set via web UI (Settings page) | ✅ Works now |
| Auto-detect payment via webhooks → ledger update | ⚠️ Not yet — you must call recordPayment() manually or wire the webhook yourself (~50 lines) |
| Stripe Connect OAuth (one-click "Connect Stripe" for business owners) | ✅ Works now — Settings → Online payments → Connect Stripe account; see STRIPE-CONNECT.md |
| Payment pages: Save / Copy HTML (email-ready inline styles) + Print / Save-as-PDF (browser-perfect, headers/footers toggleable) | ✅ Works now |
| Recurring billing (auto-generate invoices on a schedule) | ✅ Works now — the web UI Recurring page exposes templates, pause/resume, and catch-up on boot |
| Stripe Subscriptions (auto-charge via Stripe) | ❌ Not yet — different from recurring billing; requires Stripe Subscriptions API integration |
| Foreign-currency invoices (reference FX) | ✅ Works now — per-line currency + user-entered rate; the ledger stays CAD, the original amount + rate are kept on the line |
Recurring payments: what exists vs. what doesn't
Most accounting programs handle recurring payments. Here's how this project does — and doesn't — handle them:
What's built (library level):
- Recurring templates — set a customer, frequency (every N days/weeks/months/years), line items, and place of supply
generateDueRecurring(asOf)— scans all active templates, creates AND issues an invoice for each one whosenextRunDatehas arrived, then advances the schedule- Run this daily (via cron, a scheduled task, or the CLI) and invoices are generated automatically — the web UI also catches up on startup, filling in any missed invoices since the last run
- Each generated invoice is a real ledger entry — Dr A/R, Cr revenue + tax, same as a manual invoice
The web UI has a full Recurring page: create schedules (with per-line
"Tax exempt" toggles), pause/resume with smart cycle detection (resuming past
one billing cycle warns that missed invoices will be generated before you
commit), generate-due-now, a Stripe payment link per schedule, and a payment
page. See ui/README.md.
What's NOT built yet:
- Stripe Subscriptions (auto-charge via Stripe on a schedule) are a separate Stripe feature — different from recurring invoice templates. Building this requires integrating with Stripe's Subscriptions API, not just Payment Links.
How it works today for a subscription business:
- Create a recurring template:
book.invoicing.createRecurringTemplate({ name, customerId, frequency: "1 month", lines: [...] })(legacy "monthly"/"quarterly"/"yearly" still parse; every N days/weeks/months/years supported) - Run
book.invoicing.generateDueRecurring("2026-08-28")daily — it creates and issues invoices automatically (the web UI catches up on startup) - Each invoice gets a Stripe Payment Link (if configured) — the client pays online
- The business owner records the payment when Stripe deposits it
This is the "recurring invoice" model (like Wave/FreshBooks), not the "auto-charge subscription" model (like Stripe Billing). For a small business sending the same invoice monthly, it works. For a SaaS that does metered usage billing, you'd need Stripe Subscriptions integration (not yet built).
Code example
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // host's key
const link = await book.payments.createPaymentLink(stripe, invoice.id);
link.url; // https://buy.stripe.com/… — charges the balance incl. tax
link.linkId; // map checkout.session.completed webhooks back via this
const html = book.payments.paymentPageHtml(invoice.id, {
businessName: "Maple & Pine Studio",
businessAddress: ["123 Main St", "Vancouver, BC"],
businessEmail: "[email protected]",
});
// write `invoice-INV-0001.html`, upload anywhere — details, totals, PAID
// status, and the Pay button, all inline-styled, no JS, no external assets.The link is stored on the invoice and reused idempotently; pass
{ force: true } after a partial payment or credit note changes the balance.
When the webhook reports payment, call book.invoicing.recordPayment(...) as
usual — the ledger stays the source of truth. With Stripe Connect, configure
the injected client per connected account
(new Stripe(platformKey, { stripeAccount })) — each user connects their own
account (they do KYC, they pay Stripe's normal fees, the platform pays
nothing, per brief §7.4).
Example: the reference CLI (repo-only — not in the npm package)
examples/bookkeeper/cli.ts is a complete single-file bookkeeping app for a
Canadian small business — the proof of the library's out-of-the-box value.
It covers the whole monthly cycle: bank import + reconciliation, expenses
with rules, bills + A/P, invoicing (including PDF and payment pages), time
billing, payroll, and every report.
node examples/bookkeeper/cli.ts init
cd # amounts in dollars, dates YYYY-MM-DD, book defaults to ./company.db
node examples/bookkeeper/cli.ts --help
node examples/bookkeeper/cli.ts customer add "Acme Retail" --province ON
node examples/bookkeeper/cli.ts invoice add 1 --date 2026-03-01 --province ON \
--line "Consulting:2:150.00" --line "Setup fee:1:100.00"
node examples/bookkeeper/cli.ts invoice issue 1
node examples/bookkeeper/cli.ts invoice pay 1 452.00
node examples/bookkeeper/cli.ts report pnl --from 2026-03-01 --to 2026-03-31The CLI only exposes accounting-proper operations — it is the "what to
do right" reference (no edit/delete; corrections go through void / credit
notes / adjusting entries). It covers the full SMB cycle plus the
correction toolkit: invoice void, credit note add, journal add /
journal list / journal void (manual + adjusting entries), period
close / period open / period status, account add (chart of
accounts), and recurring add / recurring list / recurring generate
— all through the core package only (no Stripe, no OCR; PDFs are fine
because pdfmake is a core dependency).
Its source is the wrapper recipe: parse arguments, call the facade, print the result.
Web UI (proof of concept — repo-only, not in the npm package)
ui/ is a small server-rendered web app over the library — no build
step, and all accounting logic stays server-side (a few tiny inline scripts
handle dynamic line rows, copy/save/print, and drag-drop receipt scanning).
The UI uses a handful of devDependencies (sharp, pdfjs-dist,
@napi-rs/canvas, PaddleOCR) that never ship in the npm package. A small
business can run its whole accounting from a browser:
node ui/server.ts # → http://localhost:3000 (book: ./company.db)Dashboard (cash, A/R, A/P, net GST/HST, YTD income) · invoicing with PDF and
payment pages · recurring schedules · expenses with rules, receipt
scanning (images + PDFs via PaddleOCR), edit/void/delete · bills + A/P · time
billing · payroll with T4/RL-1 XML downloads · bank import + reconciliation ·
chart of accounts · Stripe online payments (direct key or one-click
Connect OAuth) · every report. See ui/README.md.
Like the CLI, ui/ is not part of the npm package — see
What's in the npm package. It is an optional repo-level layer that
demonstrates the library, exactly as the brief's library-first architecture intends.
Module map
| Module | Purpose | Status |
|---|---|---|
| ledger | accounts, journal entries, balances, period close | implemented |
| tax | sales-tax rates, place-of-supply, calculation, net-tax | implemented |
| invoicing | invoices/estimates/credit notes, PDF, recurring, A/R | implemented |
| expenses | expense records, OCR hook, rule-based categorization | implemented |
| bank | CSV/QBO/OFX parsers, statement import, reconciliation | implemented |
| bills | vendors, bills, vendor credits, payments, A/P aging | implemented |
| payroll | T4127 deduction engine, runs, remittance, T4/RL-1 XML | implemented |
| reports | trial balance, P&L, balance sheet, cash flow, AR/AP aging | implemented |
| inventory | products, stock movements, weighted-average costing, COGS | implemented |
| timeTracking | projects, time entries, billable hours → invoice | implemented |
| payments | Stripe Payment Links (injected client), HTML payment pages | implemented |
| backup | whole-book checkpoints (VACUUM INTO), six-year retention, safe restore | implemented |
Design notes
- Money is integer cents. Never floats. Rounding rules are explicit and deterministic, per CRA rules.
- One SQLite file per company/book, opened in-process. No server, no daemon,
works offline. The binding is a parameter:
better-sqlite3by default, or your own driver viaopenBookWith. - Library-first: the core is UI-agnostic with a clean, documented API.
- 100% license-clean: MIT / Apache-2.0 / BSD components only. No copyleft.
See ledger-reference-notes.md for the ledger design spec. Tax rates and the
rate-update mechanism are documented in src/tax/rates.ts.
Development
npm install
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm test # node --test
npm run build # emit dist/ (ESM + .d.ts)
npm run check:tax-rates # online: diff baked-in rates against the live CRA tablePublishing
accounting-up is publish-ready. npm pack produces a clean tarball — 217 files, 255 kB packed
(1.0 MB unpacked), and every one of them is dist/ (ESM + .d.ts), plus package.json,
LICENSE and this README. No src/, no ui/, no examples/, no test/, no scripts/ —
checked with npm pack --dry-run. prepublishOnly runs the build and the full test suite as a
guard.
Notes for a release:
- The public
.d.tssurface is self-contained — it names no database binding, so a consumer does not need@types/better-sqlite3to type-check against it. better-sqlite3is an optional peer dependency (peerDependenciesMeta), so installing the package never forces a native module on anyone. TypeScript consumers need@types/nodeas usual for a Node library; they no longer need@types/better-sqlite3at all.- To release: confirm the
accounting-upname is still free, bumpversion, thennpm publish(prepublishOnlybuilds and runs the full suite first). Check the tarball without publishing withnpm pack --dry-run. - Publishing is not the same as being loaded. A host keeps its own copy of the package, and
whether a new version reaches a running app is the host's business: one that compares the
installed version against the declared range will fetch the update (this project's own embedder
does, on a throttled background check, and then needs a restart); one that only looks for the
folder does not. Either way
^0.1.0in a consumer's manifest already admits0.1.1— a patch release needs no manifest change.
License
MIT — free for commercial use, including inside closed-source applications.
