npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 ledger module (double-entry core, chart of accounts, trial balance, P&L, balance sheet, cash flow), the tax module (baked-in CRA rates, place-of-supply, GST/HST/PST/QST calculation, net-tax), the invoicing module (customers, invoices, estimates, credit notes, payments, A/R aging, recurring billing, reminders, PDF), the bank module (CSV / OFX / QBO statement import + reconciliation), the expenses module (records with ledger posting, rule-based categorization, receipt OCR parsing), the bills module (vendors, bills, vendor credits, payments, A/P aging), the payroll module (T4127 deduction engine, employees, payroll runs, remittance reports, T4/RL-1 XML), the inventory module (products, stock movements, weighted-average costing, COGS on invoice), the timeTracking module (projects, time entries, billable hours → invoice), the payments module (Stripe Payment Links via an injected client, self-contained HTML payment pages), and the backup module (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; see WHAT-YOU-CAN-BUILD.md for 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) and examples/ (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-in node:sqlite is enough; better-sqlite3 is 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 no TOTAL label 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 line Billing date: Sep 10, 2026, and the parser returned 2026.00 as the total. A labeled TOTAL 45 still takes the bare 45, since the label already vouches for it; only the unlabeled fallback is strict.
  • expenses — an Amount Due total 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 no NET: or Terms: tag is printed, paymentTermsDays is 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 terms ok check 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. ReceiptExtraction gains paymentTermsDays, email and phone, each read from the page or null. 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 an ok check 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 stays null — 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: 1005 Cash on hand, 1010 Undeposited funds, 1050 Payment processor clearing, 2030 Customer deposits (deferred revenue), which matters because a deposit taken before the work is done is not income yet, 3020 Owner contributions, 4040 Interest income, 6035 Software & subscriptions, 6095 Payment processing fees, 6130 Licences, dues & memberships, 6140 Travel, 6145 Parking & tolls, and 6190 Interest expense. A book that already exists keeps its own accounts.
  • tax — corporate income tax due dates, and the business type they depend on. corporateTaxDueDate and corporateTaxDueItem answer when a corporation's T2 return is due (six months after the year-end, on the last day of that month), parseBusinessType and BUSINESS_TYPES name 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. recordRefund sends 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: refundedCents and netPaidCents are reported beside paidCents, and balanceCents is 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.
  • cancelled is a derived status, never a stored one. An invoice fully reversed by issued credit notes reads as cancelled, 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 own VACUUM INTO, so the copy is consistent with the book open), listCheckpoints, pruneCheckpoints with CHECKPOINT_RETENTION ({ keepRecent: 30, keepMonths: 72 }) and retentionSentence(), and stageRestore (put a copy back, keeping a copy of what it replaced). See Records and checkpoints.
  • reports — the year-end package. yearEndPackage / yearEndPackageZip build 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 by crc32 / zipStore in 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 / provinceName are the one table of province names; dueItems returns 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 stored placeOfSupply facts), taxRateText (a whole rate stays whole, 9.975% never prints as 9.98%), gstHstLine, and Invoice.placeOfSupply is now kept so the sentence explains the rule that was applied rather than the one that usually applies. invoiceToPdf takes gstHstNumber.
  • expenses — the 50% meals and entertainment ITC limit. splitMealItc, MEAL_ITC_CLAIMABLE_PERCENT and MEALS_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-sqlite3 is an optional peer dependency: install it and openBook() works out of the box, or bring your own driver — including Node's built-in node:sqlite — and install no native module at all. See Choosing a database driver.

Install

npm install accounting-up

Quick 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; // 950

Rates 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 − opening

A/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

  1. Copy .env.example to .env
  2. Go to dashboard.stripe.com/apikeys
  3. Click Create secret key
  4. Choose the option that matches your situation (see table above)
  5. On the next screen, follow the guide below for your chosen option
  6. Copy the key (sk_test_... for testing, sk_live_... for real payments)
  7. Paste it into .env as STRIPE_SECRET_KEY=sk_...
  8. 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 use localhost: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 .env or 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.md for 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 whose nextRunDate has 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:

  1. 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)
  2. Run book.invoicing.generateDueRecurring("2026-08-28") daily — it creates and issues invoices automatically (the web UI catches up on startup)
  3. Each invoice gets a Stripe Payment Link (if configured) — the client pays online
  4. 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-31

The 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-sqlite3 by default, or your own driver via openBookWith.
  • 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 table

Publishing

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.ts surface is self-contained — it names no database binding, so a consumer does not need @types/better-sqlite3 to type-check against it.
  • better-sqlite3 is an optional peer dependency (peerDependenciesMeta), so installing the package never forces a native module on anyone. TypeScript consumers need @types/node as usual for a Node library; they no longer need @types/better-sqlite3 at all.
  • To release: confirm the accounting-up name is still free, bump version, then npm publish (prepublishOnly builds and runs the full suite first). Check the tarball without publishing with npm 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.0 in a consumer's manifest already admits 0.1.1 — a patch release needs no manifest change.

License

MIT — free for commercial use, including inside closed-source applications.