@lacspace/invoice 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +51 -0
- package/README.md +126 -0
- package/dist/index.cjs +149 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +154 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Lacspace Free Licence
|
|
2
|
+
Version 1.0, August 2026
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Lacspace
|
|
5
|
+
|
|
6
|
+
PREAMBLE
|
|
7
|
+
|
|
8
|
+
This software is published by Lacspace under the Lacspace Free Licence โ a free,
|
|
9
|
+
permissive licence that lets you use this software for any purpose, including in
|
|
10
|
+
commercial products and services, at no cost. It grants the same freedoms as
|
|
11
|
+
common permissive open-source licences; the only condition is that this notice
|
|
12
|
+
travels with the software. The canonical, always-current text of this licence is
|
|
13
|
+
maintained at https://lacspace.com/licenses/lacspace-free-1.0
|
|
14
|
+
|
|
15
|
+
GRANT OF RIGHTS
|
|
16
|
+
|
|
17
|
+
Permission is hereby granted, free of charge, to any person or organisation
|
|
18
|
+
obtaining a copy of this software and its associated documentation and data files
|
|
19
|
+
(the "Software"), to deal in the Software without restriction, including without
|
|
20
|
+
limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
21
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
22
|
+
Software is furnished to do so, subject to the conditions below. These rights are
|
|
23
|
+
granted for any purpose, personal or commercial, and are perpetual, worldwide,
|
|
24
|
+
non-exclusive, and royalty-free.
|
|
25
|
+
|
|
26
|
+
CONDITIONS
|
|
27
|
+
|
|
28
|
+
The above copyright notice, this permission notice, and the name of this licence
|
|
29
|
+
("Lacspace Free Licence") shall be included in all copies or substantial portions
|
|
30
|
+
of the Software.
|
|
31
|
+
|
|
32
|
+
TRADEMARKS
|
|
33
|
+
|
|
34
|
+
This licence does not grant permission to use the trade names, trademarks, service
|
|
35
|
+
marks, logos, or product names of Lacspace, except as required to reproduce the
|
|
36
|
+
notice above or to describe the origin of the Software in a truthful manner.
|
|
37
|
+
|
|
38
|
+
DISCLAIMER OF WARRANTY AND LIMITATION OF LIABILITY
|
|
39
|
+
|
|
40
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
42
|
+
FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
43
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
|
|
44
|
+
AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION
|
|
45
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
The Lacspace Free Licence is a source-available, permissive licence and is not (as
|
|
50
|
+
of this version) an OSI-approved licence. In substance it grants the same freedoms
|
|
51
|
+
as the MIT Licence. Learn more at https://lacspace.com/licenses
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# @lacspace/invoice
|
|
4
|
+
|
|
5
|
+
**An invoice model, numbering & tax-rollup engine โ pure, immutable & serializable.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@lacspace/invoice)
|
|
8
|
+
[](https://bundlephobia.com/package/@lacspace/invoice)
|
|
9
|
+
[](https://www.npmjs.com/package/@lacspace/invoice)
|
|
10
|
+
[](https://github.com/lacspace/npm-packages/blob/main/LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
> The invoicing math every billing system re-implements badly. A tiny set of **pure functions** over a plain `Invoice` object โ compute per-line and total amounts, roll tax up by rate, generate sequential invoice numbers, track payments, and emit a render-ready row table. No PDF library, no floats.
|
|
15
|
+
|
|
16
|
+
- ๐ง **Immutable** โ payments and status transitions return a brand-new invoice; your input is never mutated
|
|
17
|
+
- ๐พ **Serializable** โ `Invoice` is plain data, safe to `JSON.stringify` and persist
|
|
18
|
+
- ๐ช **Exact money** โ integer **minor units** everywhere, so tax never loses a penny
|
|
19
|
+
- ๐งพ **Tax rollup** โ lines grouped by rate into a summary, consistent with the totals
|
|
20
|
+
- ๐จ๏ธ **Render-ready** โ `renderRows` hands a normalized table to `@lacspace/pdf` or `@lacspace/xlsx` โ with **zero dependency** on them
|
|
21
|
+
- โก Isomorphic โ Node, edge runtimes & browsers ยท ๐ฆ ESM + CJS ยท fully typed ยท zero deps
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm i @lacspace/invoice # or pnpm add / yarn add / bun add
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Build an invoice
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createInvoice, invoiceNumber } from "@lacspace/invoice";
|
|
33
|
+
|
|
34
|
+
const inv = createInvoice({
|
|
35
|
+
number: invoiceNumber(1, { year: 2026 }), // "INV-2026-000001"
|
|
36
|
+
currency: "USD",
|
|
37
|
+
seller: { name: "Lacspace", taxId: "T-1" },
|
|
38
|
+
buyer: { name: "Acme Co", email: "ap@acme.test" },
|
|
39
|
+
lines: [
|
|
40
|
+
{ description: "Widget", qty: 2, unitPrice: 1000, taxRate: 0.13, discount: 200 },
|
|
41
|
+
{ description: "Sticker", qty: 1, unitPrice: 500, taxRate: 0 },
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
inv.lines[0]; // { ..., net: 1800, tax: 234, total: 2034 }
|
|
46
|
+
inv.totals; // { subtotal: 2300, discount: 200, taxTotal: 234, total: 2534, amountPaid: 0, balanceDue: 2534 }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Tax grouped by rate
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
inv.taxSummary;
|
|
53
|
+
// [{ rate: 0, net: 500, tax: 0 }, { rate: 0.13, net: 1800, tax: 234 }]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Rounding order:** each line's tax is rounded first (`round(net * taxRate)`), then the already-rounded per-line taxes are summed into both `taxSummary` and `totals.taxTotal` โ so the summary always reconciles with the total.
|
|
57
|
+
|
|
58
|
+
## Track payments โ immutably
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { recordPayment } from "@lacspace/invoice";
|
|
62
|
+
|
|
63
|
+
let cur = recordPayment(inv, 2000); // cur.status === "partial", balanceDue 534
|
|
64
|
+
cur = recordPayment(cur, 534); // cur.status === "paid", balanceDue 0
|
|
65
|
+
|
|
66
|
+
recordPayment(inv, 3000); // throws InvoiceError { code: "overpayment" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Status transitions & overdue
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { markIssued, markVoid, isOverdue } from "@lacspace/invoice";
|
|
73
|
+
|
|
74
|
+
const issued = markIssued(inv, Date.now());
|
|
75
|
+
const voided = markVoid(inv);
|
|
76
|
+
isOverdue({ ...issued, dueAt: Date.now() - 1 }); // true โ past due with a balance
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Hand it to a renderer
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { renderRows } from "@lacspace/invoice";
|
|
83
|
+
|
|
84
|
+
const { columns, rows } = renderRows(inv);
|
|
85
|
+
// columns: ["Description", "Qty", "Unit", "Tax %", "Line total"]
|
|
86
|
+
// rows: [["Widget", 2, 1000, 13, 2034], ["Sticker", 1, 500, 0, 500]]
|
|
87
|
+
// โ feed straight into @lacspace/xlsx or a PDF table builder
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
| Function | Description |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `createInvoice(input)` | build a computed, serializable `Invoice` (lines, totals, tax summary) |
|
|
95
|
+
| `recordPayment(inv, amount, opts?)` | immutable; add a payment, recompute balance, set `paid`/`partial` |
|
|
96
|
+
| `markIssued(inv, at?)` | immutable transition to `issued` (throws on paid/void) |
|
|
97
|
+
| `markVoid(inv)` | immutable transition to `void` (throws on paid) |
|
|
98
|
+
| `isOverdue(inv, now?)` | `dueAt` in the past **and** a positive balance |
|
|
99
|
+
| `invoiceNumber(seq, opts?)` | deterministic number, default `"INV-2026-000123"` |
|
|
100
|
+
| `renderRows(inv)` | `{ columns, rows }` โ a normalized table for PDF / XLSX |
|
|
101
|
+
|
|
102
|
+
Types exported: `Party`, `InvoiceLineInput`, `InvoiceLine`, `TaxSummaryRow`, `InvoiceTotals`, `InvoiceStatus`, `Invoice`, and the `InvoiceError` class.
|
|
103
|
+
|
|
104
|
+
All amounts are integer minor units; `taxRate` is a fraction (`0.13` = 13%). `net = qty * unitPrice - discount`, `tax = round(net * taxRate)`, `total = net + tax`.
|
|
105
|
+
|
|
106
|
+
## Licensing
|
|
107
|
+
|
|
108
|
+
This package is **free** under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** โ permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice.
|
|
109
|
+
|
|
110
|
+
Not every Lacspace package is free. We also offer **Commercial** (paid), **Client-specific**, and **Private** (proprietary) packages under separate terms. See the full **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
111
|
+
|
|
112
|
+
<!-- LACSPACE-DEV-PLATFORM -->
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## The Lacspace Developer Platform
|
|
117
|
+
|
|
118
|
+
`@lacspace/invoice` is part of **63+ zero-dependency, isomorphic TypeScript packages**. Explore the ecosystem:
|
|
119
|
+
|
|
120
|
+
- ๐๏ธ **All packages** โ https://developer.lacspace.com/packages
|
|
121
|
+
- ๐งญ **Developer handbook** โ https://developer.lacspace.com/handbook
|
|
122
|
+
- ๐งช **Live playground** โ https://developer.lacspace.com/playground
|
|
123
|
+
- ๐ฅ๏ธ **Finished app templates** โ https://templates.lacspace.com
|
|
124
|
+
- ๐ **Scaffold a full app** โ `npm create lacspace-app@latest`
|
|
125
|
+
|
|
126
|
+
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** โ a permissive, free-to-use licence.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var InvoiceError = class _InvoiceError extends Error {
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "InvoiceError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
Object.setPrototypeOf(this, _InvoiceError.prototype);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function roundMinor(n) {
|
|
13
|
+
return n < 0 ? -Math.round(-n) : Math.round(n);
|
|
14
|
+
}
|
|
15
|
+
function computeLine(input) {
|
|
16
|
+
if (!Number.isFinite(input.qty) || input.qty < 0) {
|
|
17
|
+
throw new InvoiceError(
|
|
18
|
+
`Line "${input.description}" has a negative or invalid qty`,
|
|
19
|
+
"invalid_qty"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const discount = input.discount ?? 0;
|
|
23
|
+
const taxRate = input.taxRate ?? 0;
|
|
24
|
+
const net = input.qty * input.unitPrice - discount;
|
|
25
|
+
const tax = roundMinor(net * taxRate);
|
|
26
|
+
return {
|
|
27
|
+
...input,
|
|
28
|
+
net,
|
|
29
|
+
tax,
|
|
30
|
+
total: net + tax
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function summarizeTax(lines) {
|
|
34
|
+
const byRate = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const rate = line.taxRate ?? 0;
|
|
37
|
+
const row = byRate.get(rate) ?? { rate, net: 0, tax: 0 };
|
|
38
|
+
row.net += line.net;
|
|
39
|
+
row.tax += line.tax;
|
|
40
|
+
byRate.set(rate, row);
|
|
41
|
+
}
|
|
42
|
+
return [...byRate.values()].sort((a, b) => a.rate - b.rate);
|
|
43
|
+
}
|
|
44
|
+
function createInvoice(input) {
|
|
45
|
+
if (!input.lines || input.lines.length === 0) {
|
|
46
|
+
throw new InvoiceError("An invoice must have at least one line", "no_lines");
|
|
47
|
+
}
|
|
48
|
+
const lines = input.lines.map(computeLine);
|
|
49
|
+
const subtotal = lines.reduce((s, l) => s + l.net, 0);
|
|
50
|
+
const discount = lines.reduce((s, l) => s + (l.discount ?? 0), 0);
|
|
51
|
+
const taxTotal = lines.reduce((s, l) => s + l.tax, 0);
|
|
52
|
+
const total = subtotal + taxTotal;
|
|
53
|
+
const amountPaid = input.amountPaid ?? 0;
|
|
54
|
+
const balanceDue = total - amountPaid;
|
|
55
|
+
const totals = {
|
|
56
|
+
subtotal,
|
|
57
|
+
discount,
|
|
58
|
+
taxTotal,
|
|
59
|
+
total,
|
|
60
|
+
amountPaid,
|
|
61
|
+
balanceDue
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
number: input.number,
|
|
65
|
+
status: input.status ?? "draft",
|
|
66
|
+
currency: input.currency,
|
|
67
|
+
seller: input.seller,
|
|
68
|
+
buyer: input.buyer,
|
|
69
|
+
lines,
|
|
70
|
+
totals,
|
|
71
|
+
taxSummary: summarizeTax(lines),
|
|
72
|
+
issuedAt: input.issuedAt,
|
|
73
|
+
dueAt: input.dueAt,
|
|
74
|
+
notes: input.notes,
|
|
75
|
+
meta: input.meta
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function recordPayment(inv, amount, opts) {
|
|
79
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
80
|
+
throw new InvoiceError("Payment amount must be positive", "invalid_amount");
|
|
81
|
+
}
|
|
82
|
+
const amountPaid = inv.totals.amountPaid + amount;
|
|
83
|
+
if (amountPaid > inv.totals.total) {
|
|
84
|
+
throw new InvoiceError(
|
|
85
|
+
"Payment exceeds the amount due",
|
|
86
|
+
"overpayment"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const balanceDue = inv.totals.total - amountPaid;
|
|
90
|
+
const status = balanceDue === 0 ? "paid" : "partial";
|
|
91
|
+
return {
|
|
92
|
+
...inv,
|
|
93
|
+
status,
|
|
94
|
+
totals: { ...inv.totals, amountPaid, balanceDue },
|
|
95
|
+
meta: opts?.at !== void 0 ? { ...inv.meta ?? {}, lastPaymentAt: opts.at } : inv.meta
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function markVoid(inv) {
|
|
99
|
+
if (inv.status === "paid") {
|
|
100
|
+
throw new InvoiceError("Cannot void a paid invoice", "already_paid");
|
|
101
|
+
}
|
|
102
|
+
return { ...inv, status: "void" };
|
|
103
|
+
}
|
|
104
|
+
function markIssued(inv, at) {
|
|
105
|
+
if (inv.status === "paid") {
|
|
106
|
+
throw new InvoiceError("Cannot re-issue a paid invoice", "already_paid");
|
|
107
|
+
}
|
|
108
|
+
if (inv.status === "void") {
|
|
109
|
+
throw new InvoiceError("Cannot issue a void invoice", "void");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...inv,
|
|
113
|
+
status: "issued",
|
|
114
|
+
issuedAt: at ?? inv.issuedAt
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function isOverdue(inv, now = Date.now()) {
|
|
118
|
+
return inv.dueAt !== void 0 && inv.dueAt < now && inv.totals.balanceDue > 0;
|
|
119
|
+
}
|
|
120
|
+
function invoiceNumber(seq, opts) {
|
|
121
|
+
const prefix = opts?.prefix ?? "INV";
|
|
122
|
+
const year = opts?.year ?? (/* @__PURE__ */ new Date()).getFullYear();
|
|
123
|
+
const pad = opts?.pad ?? 6;
|
|
124
|
+
const sep = opts?.separator ?? "-";
|
|
125
|
+
const num = String(Math.trunc(seq)).padStart(pad, "0");
|
|
126
|
+
return `${prefix}${sep}${year}${sep}${num}`;
|
|
127
|
+
}
|
|
128
|
+
function renderRows(inv) {
|
|
129
|
+
const columns = ["Description", "Qty", "Unit", "Tax %", "Line total"];
|
|
130
|
+
const rows = inv.lines.map((l) => [
|
|
131
|
+
l.description,
|
|
132
|
+
l.qty,
|
|
133
|
+
l.unitPrice,
|
|
134
|
+
(l.taxRate ?? 0) * 100,
|
|
135
|
+
l.total
|
|
136
|
+
]);
|
|
137
|
+
return { columns, rows };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
exports.InvoiceError = InvoiceError;
|
|
141
|
+
exports.createInvoice = createInvoice;
|
|
142
|
+
exports.invoiceNumber = invoiceNumber;
|
|
143
|
+
exports.isOverdue = isOverdue;
|
|
144
|
+
exports.markIssued = markIssued;
|
|
145
|
+
exports.markVoid = markVoid;
|
|
146
|
+
exports.recordPayment = recordPayment;
|
|
147
|
+
exports.renderRows = renderRows;
|
|
148
|
+
//# sourceMappingURL=index.cjs.map
|
|
149
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AA4FO,IAAM,YAAA,GAAN,MAAM,aAAA,SAAqB,KAAA,CAAM;AAAA,EAEtC,WAAA,CAAY,SAAiB,IAAA,EAAe;AAC1C,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,cAAA;AACZ,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAEZ,IAAA,MAAA,CAAO,cAAA,CAAe,IAAA,EAAM,aAAA,CAAa,SAAS,CAAA;AAAA,EACpD;AACF;AAOA,SAAS,WAAW,CAAA,EAAmB;AACrC,EAAA,OAAO,CAAA,GAAI,CAAA,GAAI,CAAC,IAAA,CAAK,KAAA,CAAM,CAAC,CAAC,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA;AAC/C;AAEA,SAAS,YAAY,KAAA,EAAsC;AACzD,EAAA,IAAI,CAAC,OAAO,QAAA,CAAS,KAAA,CAAM,GAAG,CAAA,IAAK,KAAA,CAAM,MAAM,CAAA,EAAG;AAChD,IAAA,MAAM,IAAI,YAAA;AAAA,MACR,CAAA,MAAA,EAAS,MAAM,WAAW,CAAA,+BAAA,CAAA;AAAA,MAC1B;AAAA,KACF;AAAA,EACF;AACA,EAAA,MAAM,QAAA,GAAW,MAAM,QAAA,IAAY,CAAA;AACnC,EAAA,MAAM,OAAA,GAAU,MAAM,OAAA,IAAW,CAAA;AACjC,EAAA,MAAM,GAAA,GAAM,KAAA,CAAM,GAAA,GAAM,KAAA,CAAM,SAAA,GAAY,QAAA;AAC1C,EAAA,MAAM,GAAA,GAAM,UAAA,CAAW,GAAA,GAAM,OAAO,CAAA;AACpC,EAAA,OAAO;AAAA,IACL,GAAG,KAAA;AAAA,IACH,GAAA;AAAA,IACA,GAAA;AAAA,IACA,OAAO,GAAA,GAAM;AAAA,GACf;AACF;AAUA,SAAS,aAAa,KAAA,EAAuC;AAC3D,EAAA,MAAM,MAAA,uBAAa,GAAA,EAA2B;AAC9C,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,MAAM,IAAA,GAAO,KAAK,OAAA,IAAW,CAAA;AAC7B,IAAA,MAAM,GAAA,GAAM,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA,IAAK,EAAE,IAAA,EAAM,GAAA,EAAK,CAAA,EAAG,GAAA,EAAK,CAAA,EAAE;AACvD,IAAA,GAAA,CAAI,OAAO,IAAA,CAAK,GAAA;AAChB,IAAA,GAAA,CAAI,OAAO,IAAA,CAAK,GAAA;AAChB,IAAA,MAAA,CAAO,GAAA,CAAI,MAAM,GAAG,CAAA;AAAA,EACtB;AAEA,EAAA,OAAO,CAAC,GAAG,MAAA,CAAO,MAAA,EAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM,CAAA,CAAE,IAAA,GAAO,EAAE,IAAI,CAAA;AAC5D;AAcO,SAAS,cAAc,KAAA,EAYlB;AACV,EAAA,IAAI,CAAC,KAAA,CAAM,KAAA,IAAS,KAAA,CAAM,KAAA,CAAM,WAAW,CAAA,EAAG;AAC5C,IAAA,MAAM,IAAI,YAAA,CAAa,wCAAA,EAA0C,UAAU,CAAA;AAAA,EAC7E;AAEA,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,KAAA,CAAM,GAAA,CAAI,WAAW,CAAA;AAEzC,EAAA,MAAM,QAAA,GAAW,MAAM,MAAA,CAAO,CAAC,GAAG,CAAA,KAAM,CAAA,GAAI,CAAA,CAAE,GAAA,EAAK,CAAC,CAAA;AACpD,EAAA,MAAM,QAAA,GAAW,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,EAAG,MAAM,CAAA,IAAK,CAAA,CAAE,QAAA,IAAY,CAAA,CAAA,EAAI,CAAC,CAAA;AAChE,EAAA,MAAM,QAAA,GAAW,MAAM,MAAA,CAAO,CAAC,GAAG,CAAA,KAAM,CAAA,GAAI,CAAA,CAAE,GAAA,EAAK,CAAC,CAAA;AACpD,EAAA,MAAM,QAAQ,QAAA,GAAW,QAAA;AACzB,EAAA,MAAM,UAAA,GAAa,MAAM,UAAA,IAAc,CAAA;AACvC,EAAA,MAAM,aAAa,KAAA,GAAQ,UAAA;AAE3B,EAAA,MAAM,MAAA,GAAwB;AAAA,IAC5B,QAAA;AAAA,IACA,QAAA;AAAA,IACA,QAAA;AAAA,IACA,KAAA;AAAA,IACA,UAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,OAAO;AAAA,IACL,QAAQ,KAAA,CAAM,MAAA;AAAA,IACd,MAAA,EAAQ,MAAM,MAAA,IAAU,OAAA;AAAA,IACxB,UAAU,KAAA,CAAM,QAAA;AAAA,IAChB,QAAQ,KAAA,CAAM,MAAA;AAAA,IACd,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,KAAA;AAAA,IACA,MAAA;AAAA,IACA,UAAA,EAAY,aAAa,KAAK,CAAA;AAAA,IAC9B,UAAU,KAAA,CAAM,QAAA;AAAA,IAChB,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,OAAO,KAAA,CAAM,KAAA;AAAA,IACb,MAAM,KAAA,CAAM;AAAA,GACd;AACF;AAWO,SAAS,aAAA,CACd,GAAA,EACA,MAAA,EACA,IAAA,EACS;AACT,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,MAAM,CAAA,IAAK,UAAU,CAAA,EAAG;AAC3C,IAAA,MAAM,IAAI,YAAA,CAAa,iCAAA,EAAmC,gBAAgB,CAAA;AAAA,EAC5E;AACA,EAAA,MAAM,UAAA,GAAa,GAAA,CAAI,MAAA,CAAO,UAAA,GAAa,MAAA;AAC3C,EAAA,IAAI,UAAA,GAAa,GAAA,CAAI,MAAA,CAAO,KAAA,EAAO;AACjC,IAAA,MAAM,IAAI,YAAA;AAAA,MACR,gCAAA;AAAA,MACA;AAAA,KACF;AAAA,EACF;AACA,EAAA,MAAM,UAAA,GAAa,GAAA,CAAI,MAAA,CAAO,KAAA,GAAQ,UAAA;AACtC,EAAA,MAAM,MAAA,GAAwB,UAAA,KAAe,CAAA,GAAI,MAAA,GAAS,SAAA;AAC1D,EAAA,OAAO;AAAA,IACL,GAAG,GAAA;AAAA,IACH,MAAA;AAAA,IACA,QAAQ,EAAE,GAAG,GAAA,CAAI,MAAA,EAAQ,YAAY,UAAA,EAAW;AAAA,IAChD,IAAA,EACE,IAAA,EAAM,EAAA,KAAO,MAAA,GACT,EAAE,GAAI,GAAA,CAAI,IAAA,IAAQ,EAAC,EAAI,aAAA,EAAe,IAAA,CAAK,EAAA,KAC3C,GAAA,CAAI;AAAA,GACZ;AACF;AAOO,SAAS,SAAS,GAAA,EAAuB;AAC9C,EAAA,IAAI,GAAA,CAAI,WAAW,MAAA,EAAQ;AACzB,IAAA,MAAM,IAAI,YAAA,CAAa,4BAAA,EAA8B,cAAc,CAAA;AAAA,EACrE;AACA,EAAA,OAAO,EAAE,GAAG,GAAA,EAAK,MAAA,EAAQ,MAAA,EAAO;AAClC;AASO,SAAS,UAAA,CAAW,KAAc,EAAA,EAAsB;AAC7D,EAAA,IAAI,GAAA,CAAI,WAAW,MAAA,EAAQ;AACzB,IAAA,MAAM,IAAI,YAAA,CAAa,gCAAA,EAAkC,cAAc,CAAA;AAAA,EACzE;AACA,EAAA,IAAI,GAAA,CAAI,WAAW,MAAA,EAAQ;AACzB,IAAA,MAAM,IAAI,YAAA,CAAa,6BAAA,EAA+B,MAAM,CAAA;AAAA,EAC9D;AACA,EAAA,OAAO;AAAA,IACL,GAAG,GAAA;AAAA,IACH,MAAA,EAAQ,QAAA;AAAA,IACR,QAAA,EAAU,MAAM,GAAA,CAAI;AAAA,GACtB;AACF;AAMO,SAAS,SAAA,CAAU,GAAA,EAAc,GAAA,GAAc,IAAA,CAAK,KAAI,EAAY;AACzE,EAAA,OACE,GAAA,CAAI,UAAU,MAAA,IACd,GAAA,CAAI,QAAQ,GAAA,IACZ,GAAA,CAAI,OAAO,UAAA,GAAa,CAAA;AAE5B;AAWO,SAAS,aAAA,CACd,KACA,IAAA,EACQ;AACR,EAAA,MAAM,MAAA,GAAS,MAAM,MAAA,IAAU,KAAA;AAC/B,EAAA,MAAM,OAAO,IAAA,EAAM,IAAA,IAAA,iBAAQ,IAAI,IAAA,IAAO,WAAA,EAAY;AAClD,EAAA,MAAM,GAAA,GAAM,MAAM,GAAA,IAAO,CAAA;AACzB,EAAA,MAAM,GAAA,GAAM,MAAM,SAAA,IAAa,GAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,OAAO,IAAA,CAAK,KAAA,CAAM,GAAG,CAAC,CAAA,CAAE,QAAA,CAAS,GAAA,EAAK,GAAG,CAAA;AACrD,EAAA,OAAO,CAAA,EAAG,MAAM,CAAA,EAAG,GAAG,GAAG,IAAI,CAAA,EAAG,GAAG,CAAA,EAAG,GAAG,CAAA,CAAA;AAC3C;AASO,SAAS,WAAW,GAAA,EAGzB;AACA,EAAA,MAAM,UAAU,CAAC,aAAA,EAAe,KAAA,EAAO,MAAA,EAAQ,SAAS,YAAY,CAAA;AACpE,EAAA,MAAM,IAAA,GAAO,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,CAAA,KAAM;AAAA,IAChC,CAAA,CAAE,WAAA;AAAA,IACF,CAAA,CAAE,GAAA;AAAA,IACF,CAAA,CAAE,SAAA;AAAA,IAAA,CACD,CAAA,CAAE,WAAW,CAAA,IAAK,GAAA;AAAA,IACnB,CAAA,CAAE;AAAA,GACH,CAAA;AACD,EAAA,OAAO,EAAE,SAAS,IAAA,EAAK;AACzB","file":"index.cjs","sourcesContent":["/**\n * @lacspace/invoice\n *\n * Invoice MODEL + numbering + tax rollup. Pure, immutable, isomorphic and\n * zero-dependency. All money is expressed in integer **minor units** (e.g.\n * cents / paisa) so nothing is ever lost to floating-point drift.\n *\n * This package is model-only: it computes a structured, serializable invoice\n * and can emit a normalized row table ready to feed a renderer such as\n * `@lacspace/pdf` or `@lacspace/xlsx` โ but it imports NEITHER of them.\n */\n\n// ---------------------------------------------------------------------------\n// Model\n// ---------------------------------------------------------------------------\n\n/** A billing party โ the seller or the buyer on an invoice. */\nexport interface Party {\n name: string;\n address?: string;\n email?: string;\n taxId?: string;\n meta?: Record<string, unknown>;\n}\n\n/**\n * Raw input for a single invoice line. Amounts are integer minor units;\n * `taxRate` is a fraction (e.g. `0.13` for 13%).\n */\nexport interface InvoiceLineInput {\n description: string;\n qty: number;\n unitPrice: number;\n taxRate?: number;\n discount?: number;\n sku?: string;\n}\n\n/**\n * A computed invoice line.\n * net = qty * unitPrice - discount\n * tax = round(net * taxRate)\n * total = net + tax\n */\nexport interface InvoiceLine extends InvoiceLineInput {\n net: number;\n tax: number;\n total: number;\n}\n\n/** One row of the tax summary: all net/tax that share a single rate. */\nexport interface TaxSummaryRow {\n rate: number;\n net: number;\n tax: number;\n}\n\n/** Invoice-level money rollup, all integer minor units. */\nexport interface InvoiceTotals {\n subtotal: number;\n discount: number;\n taxTotal: number;\n total: number;\n amountPaid: number;\n balanceDue: number;\n}\n\nexport type InvoiceStatus =\n | \"draft\"\n | \"issued\"\n | \"paid\"\n | \"partial\"\n | \"void\"\n | \"overdue\";\n\n/** A complete, serializable invoice. */\nexport interface Invoice {\n number: string;\n status: InvoiceStatus;\n currency: string;\n seller: Party;\n buyer: Party;\n lines: InvoiceLine[];\n totals: InvoiceTotals;\n taxSummary: TaxSummaryRow[];\n issuedAt?: number;\n dueAt?: number;\n notes?: string;\n meta?: Record<string, unknown>;\n}\n\n/** Error thrown for any invalid invoice operation. */\nexport class InvoiceError extends Error {\n code?: string;\n constructor(message: string, code?: string) {\n super(message);\n this.name = \"InvoiceError\";\n this.code = code;\n // Restore prototype chain for downlevel ES targets.\n Object.setPrototypeOf(this, InvoiceError.prototype);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Round to the nearest integer minor unit (half away from zero). */\nfunction roundMinor(n: number): number {\n return n < 0 ? -Math.round(-n) : Math.round(n);\n}\n\nfunction computeLine(input: InvoiceLineInput): InvoiceLine {\n if (!Number.isFinite(input.qty) || input.qty < 0) {\n throw new InvoiceError(\n `Line \"${input.description}\" has a negative or invalid qty`,\n \"invalid_qty\",\n );\n }\n const discount = input.discount ?? 0;\n const taxRate = input.taxRate ?? 0;\n const net = input.qty * input.unitPrice - discount;\n const tax = roundMinor(net * taxRate);\n return {\n ...input,\n net,\n tax,\n total: net + tax,\n };\n}\n\n/**\n * Group computed lines into tax-summary rows by rate.\n *\n * Rounding order: each LINE's tax is rounded first (in `computeLine`), then the\n * already-rounded per-line tax amounts are summed into the summary row. Net is\n * likewise summed from per-line nets. This keeps the tax summary consistent\n * with `totals.taxTotal` (both are sums of the same rounded per-line taxes).\n */\nfunction summarizeTax(lines: InvoiceLine[]): TaxSummaryRow[] {\n const byRate = new Map<number, TaxSummaryRow>();\n for (const line of lines) {\n const rate = line.taxRate ?? 0;\n const row = byRate.get(rate) ?? { rate, net: 0, tax: 0 };\n row.net += line.net;\n row.tax += line.tax;\n byRate.set(rate, row);\n }\n // Deterministic ascending order by rate.\n return [...byRate.values()].sort((a, b) => a.rate - b.rate);\n}\n\n// ---------------------------------------------------------------------------\n// Build & compute\n// ---------------------------------------------------------------------------\n\n/**\n * Build a fully computed invoice from raw input.\n *\n * Computes each line (net / tax / total), the invoice totals, and the tax\n * summary grouped by rate. Integer minor units throughout.\n *\n * @throws InvoiceError when there are no lines.\n */\nexport function createInvoice(input: {\n number: string;\n currency: string;\n seller: Party;\n buyer: Party;\n lines: InvoiceLineInput[];\n status?: InvoiceStatus;\n issuedAt?: number;\n dueAt?: number;\n amountPaid?: number;\n notes?: string;\n meta?: Record<string, unknown>;\n}): Invoice {\n if (!input.lines || input.lines.length === 0) {\n throw new InvoiceError(\"An invoice must have at least one line\", \"no_lines\");\n }\n\n const lines = input.lines.map(computeLine);\n\n const subtotal = lines.reduce((s, l) => s + l.net, 0);\n const discount = lines.reduce((s, l) => s + (l.discount ?? 0), 0);\n const taxTotal = lines.reduce((s, l) => s + l.tax, 0);\n const total = subtotal + taxTotal;\n const amountPaid = input.amountPaid ?? 0;\n const balanceDue = total - amountPaid;\n\n const totals: InvoiceTotals = {\n subtotal,\n discount,\n taxTotal,\n total,\n amountPaid,\n balanceDue,\n };\n\n return {\n number: input.number,\n status: input.status ?? \"draft\",\n currency: input.currency,\n seller: input.seller,\n buyer: input.buyer,\n lines,\n totals,\n taxSummary: summarizeTax(lines),\n issuedAt: input.issuedAt,\n dueAt: input.dueAt,\n notes: input.notes,\n meta: input.meta,\n };\n}\n\n/**\n * Record a payment against an invoice. Immutable โ returns a new invoice.\n *\n * Increases `amountPaid`, recomputes `balanceDue`, and sets `status` to\n * `\"paid\"` (balance 0) or `\"partial\"` (balance > 0).\n *\n * @throws InvoiceError when `amount <= 0` (\"invalid_amount\") or the payment\n * would take total paid past the invoice total (\"overpayment\").\n */\nexport function recordPayment(\n inv: Invoice,\n amount: number,\n opts?: { at?: number },\n): Invoice {\n if (!Number.isFinite(amount) || amount <= 0) {\n throw new InvoiceError(\"Payment amount must be positive\", \"invalid_amount\");\n }\n const amountPaid = inv.totals.amountPaid + amount;\n if (amountPaid > inv.totals.total) {\n throw new InvoiceError(\n \"Payment exceeds the amount due\",\n \"overpayment\",\n );\n }\n const balanceDue = inv.totals.total - amountPaid;\n const status: InvoiceStatus = balanceDue === 0 ? \"paid\" : \"partial\";\n return {\n ...inv,\n status,\n totals: { ...inv.totals, amountPaid, balanceDue },\n meta:\n opts?.at !== undefined\n ? { ...(inv.meta ?? {}), lastPaymentAt: opts.at }\n : inv.meta,\n };\n}\n\n/**\n * Transition an invoice to `\"void\"`. Immutable.\n *\n * @throws InvoiceError when the invoice is already paid (\"already_paid\").\n */\nexport function markVoid(inv: Invoice): Invoice {\n if (inv.status === \"paid\") {\n throw new InvoiceError(\"Cannot void a paid invoice\", \"already_paid\");\n }\n return { ...inv, status: \"void\" };\n}\n\n/**\n * Transition an invoice to `\"issued\"` (optionally stamping `issuedAt`).\n * Immutable.\n *\n * @throws InvoiceError when the invoice is already paid (\"already_paid\") or\n * void (\"void\").\n */\nexport function markIssued(inv: Invoice, at?: number): Invoice {\n if (inv.status === \"paid\") {\n throw new InvoiceError(\"Cannot re-issue a paid invoice\", \"already_paid\");\n }\n if (inv.status === \"void\") {\n throw new InvoiceError(\"Cannot issue a void invoice\", \"void\");\n }\n return {\n ...inv,\n status: \"issued\",\n issuedAt: at ?? inv.issuedAt,\n };\n}\n\n/**\n * Whether an invoice is overdue: it has a `dueAt` in the past and a positive\n * balance due.\n */\nexport function isOverdue(inv: Invoice, now: number = Date.now()): boolean {\n return (\n inv.dueAt !== undefined &&\n inv.dueAt < now &&\n inv.totals.balanceDue > 0\n );\n}\n\n// ---------------------------------------------------------------------------\n// Numbering & render helper\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic sequential invoice number.\n *\n * Default shape: `\"INV-2026-000123\"` (prefix `\"INV\"`, current year, pad 6).\n */\nexport function invoiceNumber(\n seq: number,\n opts?: { prefix?: string; year?: number; pad?: number; separator?: string },\n): string {\n const prefix = opts?.prefix ?? \"INV\";\n const year = opts?.year ?? new Date().getFullYear();\n const pad = opts?.pad ?? 6;\n const sep = opts?.separator ?? \"-\";\n const num = String(Math.trunc(seq)).padStart(pad, \"0\");\n return `${prefix}${sep}${year}${sep}${num}`;\n}\n\n/**\n * Emit a normalized table from an invoice's lines, ready to hand to a renderer\n * such as `@lacspace/xlsx` or a PDF table builder. Amounts stay in integer\n * minor units โ formatting is the renderer's job. No renderer is imported.\n *\n * Columns: `Description`, `Qty`, `Unit`, `Tax %`, `Line total`.\n */\nexport function renderRows(inv: Invoice): {\n columns: string[];\n rows: (string | number)[][];\n} {\n const columns = [\"Description\", \"Qty\", \"Unit\", \"Tax %\", \"Line total\"];\n const rows = inv.lines.map((l) => [\n l.description,\n l.qty,\n l.unitPrice,\n (l.taxRate ?? 0) * 100,\n l.total,\n ]);\n return { columns, rows };\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/invoice
|
|
3
|
+
*
|
|
4
|
+
* Invoice MODEL + numbering + tax rollup. Pure, immutable, isomorphic and
|
|
5
|
+
* zero-dependency. All money is expressed in integer **minor units** (e.g.
|
|
6
|
+
* cents / paisa) so nothing is ever lost to floating-point drift.
|
|
7
|
+
*
|
|
8
|
+
* This package is model-only: it computes a structured, serializable invoice
|
|
9
|
+
* and can emit a normalized row table ready to feed a renderer such as
|
|
10
|
+
* `@lacspace/pdf` or `@lacspace/xlsx` โ but it imports NEITHER of them.
|
|
11
|
+
*/
|
|
12
|
+
/** A billing party โ the seller or the buyer on an invoice. */
|
|
13
|
+
interface Party {
|
|
14
|
+
name: string;
|
|
15
|
+
address?: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
taxId?: string;
|
|
18
|
+
meta?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Raw input for a single invoice line. Amounts are integer minor units;
|
|
22
|
+
* `taxRate` is a fraction (e.g. `0.13` for 13%).
|
|
23
|
+
*/
|
|
24
|
+
interface InvoiceLineInput {
|
|
25
|
+
description: string;
|
|
26
|
+
qty: number;
|
|
27
|
+
unitPrice: number;
|
|
28
|
+
taxRate?: number;
|
|
29
|
+
discount?: number;
|
|
30
|
+
sku?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A computed invoice line.
|
|
34
|
+
* net = qty * unitPrice - discount
|
|
35
|
+
* tax = round(net * taxRate)
|
|
36
|
+
* total = net + tax
|
|
37
|
+
*/
|
|
38
|
+
interface InvoiceLine extends InvoiceLineInput {
|
|
39
|
+
net: number;
|
|
40
|
+
tax: number;
|
|
41
|
+
total: number;
|
|
42
|
+
}
|
|
43
|
+
/** One row of the tax summary: all net/tax that share a single rate. */
|
|
44
|
+
interface TaxSummaryRow {
|
|
45
|
+
rate: number;
|
|
46
|
+
net: number;
|
|
47
|
+
tax: number;
|
|
48
|
+
}
|
|
49
|
+
/** Invoice-level money rollup, all integer minor units. */
|
|
50
|
+
interface InvoiceTotals {
|
|
51
|
+
subtotal: number;
|
|
52
|
+
discount: number;
|
|
53
|
+
taxTotal: number;
|
|
54
|
+
total: number;
|
|
55
|
+
amountPaid: number;
|
|
56
|
+
balanceDue: number;
|
|
57
|
+
}
|
|
58
|
+
type InvoiceStatus = "draft" | "issued" | "paid" | "partial" | "void" | "overdue";
|
|
59
|
+
/** A complete, serializable invoice. */
|
|
60
|
+
interface Invoice {
|
|
61
|
+
number: string;
|
|
62
|
+
status: InvoiceStatus;
|
|
63
|
+
currency: string;
|
|
64
|
+
seller: Party;
|
|
65
|
+
buyer: Party;
|
|
66
|
+
lines: InvoiceLine[];
|
|
67
|
+
totals: InvoiceTotals;
|
|
68
|
+
taxSummary: TaxSummaryRow[];
|
|
69
|
+
issuedAt?: number;
|
|
70
|
+
dueAt?: number;
|
|
71
|
+
notes?: string;
|
|
72
|
+
meta?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
/** Error thrown for any invalid invoice operation. */
|
|
75
|
+
declare class InvoiceError extends Error {
|
|
76
|
+
code?: string;
|
|
77
|
+
constructor(message: string, code?: string);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build a fully computed invoice from raw input.
|
|
81
|
+
*
|
|
82
|
+
* Computes each line (net / tax / total), the invoice totals, and the tax
|
|
83
|
+
* summary grouped by rate. Integer minor units throughout.
|
|
84
|
+
*
|
|
85
|
+
* @throws InvoiceError when there are no lines.
|
|
86
|
+
*/
|
|
87
|
+
declare function createInvoice(input: {
|
|
88
|
+
number: string;
|
|
89
|
+
currency: string;
|
|
90
|
+
seller: Party;
|
|
91
|
+
buyer: Party;
|
|
92
|
+
lines: InvoiceLineInput[];
|
|
93
|
+
status?: InvoiceStatus;
|
|
94
|
+
issuedAt?: number;
|
|
95
|
+
dueAt?: number;
|
|
96
|
+
amountPaid?: number;
|
|
97
|
+
notes?: string;
|
|
98
|
+
meta?: Record<string, unknown>;
|
|
99
|
+
}): Invoice;
|
|
100
|
+
/**
|
|
101
|
+
* Record a payment against an invoice. Immutable โ returns a new invoice.
|
|
102
|
+
*
|
|
103
|
+
* Increases `amountPaid`, recomputes `balanceDue`, and sets `status` to
|
|
104
|
+
* `"paid"` (balance 0) or `"partial"` (balance > 0).
|
|
105
|
+
*
|
|
106
|
+
* @throws InvoiceError when `amount <= 0` ("invalid_amount") or the payment
|
|
107
|
+
* would take total paid past the invoice total ("overpayment").
|
|
108
|
+
*/
|
|
109
|
+
declare function recordPayment(inv: Invoice, amount: number, opts?: {
|
|
110
|
+
at?: number;
|
|
111
|
+
}): Invoice;
|
|
112
|
+
/**
|
|
113
|
+
* Transition an invoice to `"void"`. Immutable.
|
|
114
|
+
*
|
|
115
|
+
* @throws InvoiceError when the invoice is already paid ("already_paid").
|
|
116
|
+
*/
|
|
117
|
+
declare function markVoid(inv: Invoice): Invoice;
|
|
118
|
+
/**
|
|
119
|
+
* Transition an invoice to `"issued"` (optionally stamping `issuedAt`).
|
|
120
|
+
* Immutable.
|
|
121
|
+
*
|
|
122
|
+
* @throws InvoiceError when the invoice is already paid ("already_paid") or
|
|
123
|
+
* void ("void").
|
|
124
|
+
*/
|
|
125
|
+
declare function markIssued(inv: Invoice, at?: number): Invoice;
|
|
126
|
+
/**
|
|
127
|
+
* Whether an invoice is overdue: it has a `dueAt` in the past and a positive
|
|
128
|
+
* balance due.
|
|
129
|
+
*/
|
|
130
|
+
declare function isOverdue(inv: Invoice, now?: number): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Generate a deterministic sequential invoice number.
|
|
133
|
+
*
|
|
134
|
+
* Default shape: `"INV-2026-000123"` (prefix `"INV"`, current year, pad 6).
|
|
135
|
+
*/
|
|
136
|
+
declare function invoiceNumber(seq: number, opts?: {
|
|
137
|
+
prefix?: string;
|
|
138
|
+
year?: number;
|
|
139
|
+
pad?: number;
|
|
140
|
+
separator?: string;
|
|
141
|
+
}): string;
|
|
142
|
+
/**
|
|
143
|
+
* Emit a normalized table from an invoice's lines, ready to hand to a renderer
|
|
144
|
+
* such as `@lacspace/xlsx` or a PDF table builder. Amounts stay in integer
|
|
145
|
+
* minor units โ formatting is the renderer's job. No renderer is imported.
|
|
146
|
+
*
|
|
147
|
+
* Columns: `Description`, `Qty`, `Unit`, `Tax %`, `Line total`.
|
|
148
|
+
*/
|
|
149
|
+
declare function renderRows(inv: Invoice): {
|
|
150
|
+
columns: string[];
|
|
151
|
+
rows: (string | number)[][];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export { type Invoice, InvoiceError, type InvoiceLine, type InvoiceLineInput, type InvoiceStatus, type InvoiceTotals, type Party, type TaxSummaryRow, createInvoice, invoiceNumber, isOverdue, markIssued, markVoid, recordPayment, renderRows };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lacspace/invoice
|
|
3
|
+
*
|
|
4
|
+
* Invoice MODEL + numbering + tax rollup. Pure, immutable, isomorphic and
|
|
5
|
+
* zero-dependency. All money is expressed in integer **minor units** (e.g.
|
|
6
|
+
* cents / paisa) so nothing is ever lost to floating-point drift.
|
|
7
|
+
*
|
|
8
|
+
* This package is model-only: it computes a structured, serializable invoice
|
|
9
|
+
* and can emit a normalized row table ready to feed a renderer such as
|
|
10
|
+
* `@lacspace/pdf` or `@lacspace/xlsx` โ but it imports NEITHER of them.
|
|
11
|
+
*/
|
|
12
|
+
/** A billing party โ the seller or the buyer on an invoice. */
|
|
13
|
+
interface Party {
|
|
14
|
+
name: string;
|
|
15
|
+
address?: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
taxId?: string;
|
|
18
|
+
meta?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Raw input for a single invoice line. Amounts are integer minor units;
|
|
22
|
+
* `taxRate` is a fraction (e.g. `0.13` for 13%).
|
|
23
|
+
*/
|
|
24
|
+
interface InvoiceLineInput {
|
|
25
|
+
description: string;
|
|
26
|
+
qty: number;
|
|
27
|
+
unitPrice: number;
|
|
28
|
+
taxRate?: number;
|
|
29
|
+
discount?: number;
|
|
30
|
+
sku?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A computed invoice line.
|
|
34
|
+
* net = qty * unitPrice - discount
|
|
35
|
+
* tax = round(net * taxRate)
|
|
36
|
+
* total = net + tax
|
|
37
|
+
*/
|
|
38
|
+
interface InvoiceLine extends InvoiceLineInput {
|
|
39
|
+
net: number;
|
|
40
|
+
tax: number;
|
|
41
|
+
total: number;
|
|
42
|
+
}
|
|
43
|
+
/** One row of the tax summary: all net/tax that share a single rate. */
|
|
44
|
+
interface TaxSummaryRow {
|
|
45
|
+
rate: number;
|
|
46
|
+
net: number;
|
|
47
|
+
tax: number;
|
|
48
|
+
}
|
|
49
|
+
/** Invoice-level money rollup, all integer minor units. */
|
|
50
|
+
interface InvoiceTotals {
|
|
51
|
+
subtotal: number;
|
|
52
|
+
discount: number;
|
|
53
|
+
taxTotal: number;
|
|
54
|
+
total: number;
|
|
55
|
+
amountPaid: number;
|
|
56
|
+
balanceDue: number;
|
|
57
|
+
}
|
|
58
|
+
type InvoiceStatus = "draft" | "issued" | "paid" | "partial" | "void" | "overdue";
|
|
59
|
+
/** A complete, serializable invoice. */
|
|
60
|
+
interface Invoice {
|
|
61
|
+
number: string;
|
|
62
|
+
status: InvoiceStatus;
|
|
63
|
+
currency: string;
|
|
64
|
+
seller: Party;
|
|
65
|
+
buyer: Party;
|
|
66
|
+
lines: InvoiceLine[];
|
|
67
|
+
totals: InvoiceTotals;
|
|
68
|
+
taxSummary: TaxSummaryRow[];
|
|
69
|
+
issuedAt?: number;
|
|
70
|
+
dueAt?: number;
|
|
71
|
+
notes?: string;
|
|
72
|
+
meta?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
/** Error thrown for any invalid invoice operation. */
|
|
75
|
+
declare class InvoiceError extends Error {
|
|
76
|
+
code?: string;
|
|
77
|
+
constructor(message: string, code?: string);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build a fully computed invoice from raw input.
|
|
81
|
+
*
|
|
82
|
+
* Computes each line (net / tax / total), the invoice totals, and the tax
|
|
83
|
+
* summary grouped by rate. Integer minor units throughout.
|
|
84
|
+
*
|
|
85
|
+
* @throws InvoiceError when there are no lines.
|
|
86
|
+
*/
|
|
87
|
+
declare function createInvoice(input: {
|
|
88
|
+
number: string;
|
|
89
|
+
currency: string;
|
|
90
|
+
seller: Party;
|
|
91
|
+
buyer: Party;
|
|
92
|
+
lines: InvoiceLineInput[];
|
|
93
|
+
status?: InvoiceStatus;
|
|
94
|
+
issuedAt?: number;
|
|
95
|
+
dueAt?: number;
|
|
96
|
+
amountPaid?: number;
|
|
97
|
+
notes?: string;
|
|
98
|
+
meta?: Record<string, unknown>;
|
|
99
|
+
}): Invoice;
|
|
100
|
+
/**
|
|
101
|
+
* Record a payment against an invoice. Immutable โ returns a new invoice.
|
|
102
|
+
*
|
|
103
|
+
* Increases `amountPaid`, recomputes `balanceDue`, and sets `status` to
|
|
104
|
+
* `"paid"` (balance 0) or `"partial"` (balance > 0).
|
|
105
|
+
*
|
|
106
|
+
* @throws InvoiceError when `amount <= 0` ("invalid_amount") or the payment
|
|
107
|
+
* would take total paid past the invoice total ("overpayment").
|
|
108
|
+
*/
|
|
109
|
+
declare function recordPayment(inv: Invoice, amount: number, opts?: {
|
|
110
|
+
at?: number;
|
|
111
|
+
}): Invoice;
|
|
112
|
+
/**
|
|
113
|
+
* Transition an invoice to `"void"`. Immutable.
|
|
114
|
+
*
|
|
115
|
+
* @throws InvoiceError when the invoice is already paid ("already_paid").
|
|
116
|
+
*/
|
|
117
|
+
declare function markVoid(inv: Invoice): Invoice;
|
|
118
|
+
/**
|
|
119
|
+
* Transition an invoice to `"issued"` (optionally stamping `issuedAt`).
|
|
120
|
+
* Immutable.
|
|
121
|
+
*
|
|
122
|
+
* @throws InvoiceError when the invoice is already paid ("already_paid") or
|
|
123
|
+
* void ("void").
|
|
124
|
+
*/
|
|
125
|
+
declare function markIssued(inv: Invoice, at?: number): Invoice;
|
|
126
|
+
/**
|
|
127
|
+
* Whether an invoice is overdue: it has a `dueAt` in the past and a positive
|
|
128
|
+
* balance due.
|
|
129
|
+
*/
|
|
130
|
+
declare function isOverdue(inv: Invoice, now?: number): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Generate a deterministic sequential invoice number.
|
|
133
|
+
*
|
|
134
|
+
* Default shape: `"INV-2026-000123"` (prefix `"INV"`, current year, pad 6).
|
|
135
|
+
*/
|
|
136
|
+
declare function invoiceNumber(seq: number, opts?: {
|
|
137
|
+
prefix?: string;
|
|
138
|
+
year?: number;
|
|
139
|
+
pad?: number;
|
|
140
|
+
separator?: string;
|
|
141
|
+
}): string;
|
|
142
|
+
/**
|
|
143
|
+
* Emit a normalized table from an invoice's lines, ready to hand to a renderer
|
|
144
|
+
* such as `@lacspace/xlsx` or a PDF table builder. Amounts stay in integer
|
|
145
|
+
* minor units โ formatting is the renderer's job. No renderer is imported.
|
|
146
|
+
*
|
|
147
|
+
* Columns: `Description`, `Qty`, `Unit`, `Tax %`, `Line total`.
|
|
148
|
+
*/
|
|
149
|
+
declare function renderRows(inv: Invoice): {
|
|
150
|
+
columns: string[];
|
|
151
|
+
rows: (string | number)[][];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export { type Invoice, InvoiceError, type InvoiceLine, type InvoiceLineInput, type InvoiceStatus, type InvoiceTotals, type Party, type TaxSummaryRow, createInvoice, invoiceNumber, isOverdue, markIssued, markVoid, recordPayment, renderRows };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var InvoiceError = class _InvoiceError extends Error {
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "InvoiceError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
Object.setPrototypeOf(this, _InvoiceError.prototype);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function roundMinor(n) {
|
|
11
|
+
return n < 0 ? -Math.round(-n) : Math.round(n);
|
|
12
|
+
}
|
|
13
|
+
function computeLine(input) {
|
|
14
|
+
if (!Number.isFinite(input.qty) || input.qty < 0) {
|
|
15
|
+
throw new InvoiceError(
|
|
16
|
+
`Line "${input.description}" has a negative or invalid qty`,
|
|
17
|
+
"invalid_qty"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const discount = input.discount ?? 0;
|
|
21
|
+
const taxRate = input.taxRate ?? 0;
|
|
22
|
+
const net = input.qty * input.unitPrice - discount;
|
|
23
|
+
const tax = roundMinor(net * taxRate);
|
|
24
|
+
return {
|
|
25
|
+
...input,
|
|
26
|
+
net,
|
|
27
|
+
tax,
|
|
28
|
+
total: net + tax
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function summarizeTax(lines) {
|
|
32
|
+
const byRate = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const rate = line.taxRate ?? 0;
|
|
35
|
+
const row = byRate.get(rate) ?? { rate, net: 0, tax: 0 };
|
|
36
|
+
row.net += line.net;
|
|
37
|
+
row.tax += line.tax;
|
|
38
|
+
byRate.set(rate, row);
|
|
39
|
+
}
|
|
40
|
+
return [...byRate.values()].sort((a, b) => a.rate - b.rate);
|
|
41
|
+
}
|
|
42
|
+
function createInvoice(input) {
|
|
43
|
+
if (!input.lines || input.lines.length === 0) {
|
|
44
|
+
throw new InvoiceError("An invoice must have at least one line", "no_lines");
|
|
45
|
+
}
|
|
46
|
+
const lines = input.lines.map(computeLine);
|
|
47
|
+
const subtotal = lines.reduce((s, l) => s + l.net, 0);
|
|
48
|
+
const discount = lines.reduce((s, l) => s + (l.discount ?? 0), 0);
|
|
49
|
+
const taxTotal = lines.reduce((s, l) => s + l.tax, 0);
|
|
50
|
+
const total = subtotal + taxTotal;
|
|
51
|
+
const amountPaid = input.amountPaid ?? 0;
|
|
52
|
+
const balanceDue = total - amountPaid;
|
|
53
|
+
const totals = {
|
|
54
|
+
subtotal,
|
|
55
|
+
discount,
|
|
56
|
+
taxTotal,
|
|
57
|
+
total,
|
|
58
|
+
amountPaid,
|
|
59
|
+
balanceDue
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
number: input.number,
|
|
63
|
+
status: input.status ?? "draft",
|
|
64
|
+
currency: input.currency,
|
|
65
|
+
seller: input.seller,
|
|
66
|
+
buyer: input.buyer,
|
|
67
|
+
lines,
|
|
68
|
+
totals,
|
|
69
|
+
taxSummary: summarizeTax(lines),
|
|
70
|
+
issuedAt: input.issuedAt,
|
|
71
|
+
dueAt: input.dueAt,
|
|
72
|
+
notes: input.notes,
|
|
73
|
+
meta: input.meta
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function recordPayment(inv, amount, opts) {
|
|
77
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
78
|
+
throw new InvoiceError("Payment amount must be positive", "invalid_amount");
|
|
79
|
+
}
|
|
80
|
+
const amountPaid = inv.totals.amountPaid + amount;
|
|
81
|
+
if (amountPaid > inv.totals.total) {
|
|
82
|
+
throw new InvoiceError(
|
|
83
|
+
"Payment exceeds the amount due",
|
|
84
|
+
"overpayment"
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const balanceDue = inv.totals.total - amountPaid;
|
|
88
|
+
const status = balanceDue === 0 ? "paid" : "partial";
|
|
89
|
+
return {
|
|
90
|
+
...inv,
|
|
91
|
+
status,
|
|
92
|
+
totals: { ...inv.totals, amountPaid, balanceDue },
|
|
93
|
+
meta: opts?.at !== void 0 ? { ...inv.meta ?? {}, lastPaymentAt: opts.at } : inv.meta
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function markVoid(inv) {
|
|
97
|
+
if (inv.status === "paid") {
|
|
98
|
+
throw new InvoiceError("Cannot void a paid invoice", "already_paid");
|
|
99
|
+
}
|
|
100
|
+
return { ...inv, status: "void" };
|
|
101
|
+
}
|
|
102
|
+
function markIssued(inv, at) {
|
|
103
|
+
if (inv.status === "paid") {
|
|
104
|
+
throw new InvoiceError("Cannot re-issue a paid invoice", "already_paid");
|
|
105
|
+
}
|
|
106
|
+
if (inv.status === "void") {
|
|
107
|
+
throw new InvoiceError("Cannot issue a void invoice", "void");
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
...inv,
|
|
111
|
+
status: "issued",
|
|
112
|
+
issuedAt: at ?? inv.issuedAt
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function isOverdue(inv, now = Date.now()) {
|
|
116
|
+
return inv.dueAt !== void 0 && inv.dueAt < now && inv.totals.balanceDue > 0;
|
|
117
|
+
}
|
|
118
|
+
function invoiceNumber(seq, opts) {
|
|
119
|
+
const prefix = opts?.prefix ?? "INV";
|
|
120
|
+
const year = opts?.year ?? (/* @__PURE__ */ new Date()).getFullYear();
|
|
121
|
+
const pad = opts?.pad ?? 6;
|
|
122
|
+
const sep = opts?.separator ?? "-";
|
|
123
|
+
const num = String(Math.trunc(seq)).padStart(pad, "0");
|
|
124
|
+
return `${prefix}${sep}${year}${sep}${num}`;
|
|
125
|
+
}
|
|
126
|
+
function renderRows(inv) {
|
|
127
|
+
const columns = ["Description", "Qty", "Unit", "Tax %", "Line total"];
|
|
128
|
+
const rows = inv.lines.map((l) => [
|
|
129
|
+
l.description,
|
|
130
|
+
l.qty,
|
|
131
|
+
l.unitPrice,
|
|
132
|
+
(l.taxRate ?? 0) * 100,
|
|
133
|
+
l.total
|
|
134
|
+
]);
|
|
135
|
+
return { columns, rows };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { InvoiceError, createInvoice, invoiceNumber, isOverdue, markIssued, markVoid, recordPayment, renderRows };
|
|
139
|
+
//# sourceMappingURL=index.js.map
|
|
140
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/invoice\n *\n * Invoice MODEL + numbering + tax rollup. Pure, immutable, isomorphic and\n * zero-dependency. All money is expressed in integer **minor units** (e.g.\n * cents / paisa) so nothing is ever lost to floating-point drift.\n *\n * This package is model-only: it computes a structured, serializable invoice\n * and can emit a normalized row table ready to feed a renderer such as\n * `@lacspace/pdf` or `@lacspace/xlsx` โ but it imports NEITHER of them.\n */\n\n// ---------------------------------------------------------------------------\n// Model\n// ---------------------------------------------------------------------------\n\n/** A billing party โ the seller or the buyer on an invoice. */\nexport interface Party {\n name: string;\n address?: string;\n email?: string;\n taxId?: string;\n meta?: Record<string, unknown>;\n}\n\n/**\n * Raw input for a single invoice line. Amounts are integer minor units;\n * `taxRate` is a fraction (e.g. `0.13` for 13%).\n */\nexport interface InvoiceLineInput {\n description: string;\n qty: number;\n unitPrice: number;\n taxRate?: number;\n discount?: number;\n sku?: string;\n}\n\n/**\n * A computed invoice line.\n * net = qty * unitPrice - discount\n * tax = round(net * taxRate)\n * total = net + tax\n */\nexport interface InvoiceLine extends InvoiceLineInput {\n net: number;\n tax: number;\n total: number;\n}\n\n/** One row of the tax summary: all net/tax that share a single rate. */\nexport interface TaxSummaryRow {\n rate: number;\n net: number;\n tax: number;\n}\n\n/** Invoice-level money rollup, all integer minor units. */\nexport interface InvoiceTotals {\n subtotal: number;\n discount: number;\n taxTotal: number;\n total: number;\n amountPaid: number;\n balanceDue: number;\n}\n\nexport type InvoiceStatus =\n | \"draft\"\n | \"issued\"\n | \"paid\"\n | \"partial\"\n | \"void\"\n | \"overdue\";\n\n/** A complete, serializable invoice. */\nexport interface Invoice {\n number: string;\n status: InvoiceStatus;\n currency: string;\n seller: Party;\n buyer: Party;\n lines: InvoiceLine[];\n totals: InvoiceTotals;\n taxSummary: TaxSummaryRow[];\n issuedAt?: number;\n dueAt?: number;\n notes?: string;\n meta?: Record<string, unknown>;\n}\n\n/** Error thrown for any invalid invoice operation. */\nexport class InvoiceError extends Error {\n code?: string;\n constructor(message: string, code?: string) {\n super(message);\n this.name = \"InvoiceError\";\n this.code = code;\n // Restore prototype chain for downlevel ES targets.\n Object.setPrototypeOf(this, InvoiceError.prototype);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Round to the nearest integer minor unit (half away from zero). */\nfunction roundMinor(n: number): number {\n return n < 0 ? -Math.round(-n) : Math.round(n);\n}\n\nfunction computeLine(input: InvoiceLineInput): InvoiceLine {\n if (!Number.isFinite(input.qty) || input.qty < 0) {\n throw new InvoiceError(\n `Line \"${input.description}\" has a negative or invalid qty`,\n \"invalid_qty\",\n );\n }\n const discount = input.discount ?? 0;\n const taxRate = input.taxRate ?? 0;\n const net = input.qty * input.unitPrice - discount;\n const tax = roundMinor(net * taxRate);\n return {\n ...input,\n net,\n tax,\n total: net + tax,\n };\n}\n\n/**\n * Group computed lines into tax-summary rows by rate.\n *\n * Rounding order: each LINE's tax is rounded first (in `computeLine`), then the\n * already-rounded per-line tax amounts are summed into the summary row. Net is\n * likewise summed from per-line nets. This keeps the tax summary consistent\n * with `totals.taxTotal` (both are sums of the same rounded per-line taxes).\n */\nfunction summarizeTax(lines: InvoiceLine[]): TaxSummaryRow[] {\n const byRate = new Map<number, TaxSummaryRow>();\n for (const line of lines) {\n const rate = line.taxRate ?? 0;\n const row = byRate.get(rate) ?? { rate, net: 0, tax: 0 };\n row.net += line.net;\n row.tax += line.tax;\n byRate.set(rate, row);\n }\n // Deterministic ascending order by rate.\n return [...byRate.values()].sort((a, b) => a.rate - b.rate);\n}\n\n// ---------------------------------------------------------------------------\n// Build & compute\n// ---------------------------------------------------------------------------\n\n/**\n * Build a fully computed invoice from raw input.\n *\n * Computes each line (net / tax / total), the invoice totals, and the tax\n * summary grouped by rate. Integer minor units throughout.\n *\n * @throws InvoiceError when there are no lines.\n */\nexport function createInvoice(input: {\n number: string;\n currency: string;\n seller: Party;\n buyer: Party;\n lines: InvoiceLineInput[];\n status?: InvoiceStatus;\n issuedAt?: number;\n dueAt?: number;\n amountPaid?: number;\n notes?: string;\n meta?: Record<string, unknown>;\n}): Invoice {\n if (!input.lines || input.lines.length === 0) {\n throw new InvoiceError(\"An invoice must have at least one line\", \"no_lines\");\n }\n\n const lines = input.lines.map(computeLine);\n\n const subtotal = lines.reduce((s, l) => s + l.net, 0);\n const discount = lines.reduce((s, l) => s + (l.discount ?? 0), 0);\n const taxTotal = lines.reduce((s, l) => s + l.tax, 0);\n const total = subtotal + taxTotal;\n const amountPaid = input.amountPaid ?? 0;\n const balanceDue = total - amountPaid;\n\n const totals: InvoiceTotals = {\n subtotal,\n discount,\n taxTotal,\n total,\n amountPaid,\n balanceDue,\n };\n\n return {\n number: input.number,\n status: input.status ?? \"draft\",\n currency: input.currency,\n seller: input.seller,\n buyer: input.buyer,\n lines,\n totals,\n taxSummary: summarizeTax(lines),\n issuedAt: input.issuedAt,\n dueAt: input.dueAt,\n notes: input.notes,\n meta: input.meta,\n };\n}\n\n/**\n * Record a payment against an invoice. Immutable โ returns a new invoice.\n *\n * Increases `amountPaid`, recomputes `balanceDue`, and sets `status` to\n * `\"paid\"` (balance 0) or `\"partial\"` (balance > 0).\n *\n * @throws InvoiceError when `amount <= 0` (\"invalid_amount\") or the payment\n * would take total paid past the invoice total (\"overpayment\").\n */\nexport function recordPayment(\n inv: Invoice,\n amount: number,\n opts?: { at?: number },\n): Invoice {\n if (!Number.isFinite(amount) || amount <= 0) {\n throw new InvoiceError(\"Payment amount must be positive\", \"invalid_amount\");\n }\n const amountPaid = inv.totals.amountPaid + amount;\n if (amountPaid > inv.totals.total) {\n throw new InvoiceError(\n \"Payment exceeds the amount due\",\n \"overpayment\",\n );\n }\n const balanceDue = inv.totals.total - amountPaid;\n const status: InvoiceStatus = balanceDue === 0 ? \"paid\" : \"partial\";\n return {\n ...inv,\n status,\n totals: { ...inv.totals, amountPaid, balanceDue },\n meta:\n opts?.at !== undefined\n ? { ...(inv.meta ?? {}), lastPaymentAt: opts.at }\n : inv.meta,\n };\n}\n\n/**\n * Transition an invoice to `\"void\"`. Immutable.\n *\n * @throws InvoiceError when the invoice is already paid (\"already_paid\").\n */\nexport function markVoid(inv: Invoice): Invoice {\n if (inv.status === \"paid\") {\n throw new InvoiceError(\"Cannot void a paid invoice\", \"already_paid\");\n }\n return { ...inv, status: \"void\" };\n}\n\n/**\n * Transition an invoice to `\"issued\"` (optionally stamping `issuedAt`).\n * Immutable.\n *\n * @throws InvoiceError when the invoice is already paid (\"already_paid\") or\n * void (\"void\").\n */\nexport function markIssued(inv: Invoice, at?: number): Invoice {\n if (inv.status === \"paid\") {\n throw new InvoiceError(\"Cannot re-issue a paid invoice\", \"already_paid\");\n }\n if (inv.status === \"void\") {\n throw new InvoiceError(\"Cannot issue a void invoice\", \"void\");\n }\n return {\n ...inv,\n status: \"issued\",\n issuedAt: at ?? inv.issuedAt,\n };\n}\n\n/**\n * Whether an invoice is overdue: it has a `dueAt` in the past and a positive\n * balance due.\n */\nexport function isOverdue(inv: Invoice, now: number = Date.now()): boolean {\n return (\n inv.dueAt !== undefined &&\n inv.dueAt < now &&\n inv.totals.balanceDue > 0\n );\n}\n\n// ---------------------------------------------------------------------------\n// Numbering & render helper\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic sequential invoice number.\n *\n * Default shape: `\"INV-2026-000123\"` (prefix `\"INV\"`, current year, pad 6).\n */\nexport function invoiceNumber(\n seq: number,\n opts?: { prefix?: string; year?: number; pad?: number; separator?: string },\n): string {\n const prefix = opts?.prefix ?? \"INV\";\n const year = opts?.year ?? new Date().getFullYear();\n const pad = opts?.pad ?? 6;\n const sep = opts?.separator ?? \"-\";\n const num = String(Math.trunc(seq)).padStart(pad, \"0\");\n return `${prefix}${sep}${year}${sep}${num}`;\n}\n\n/**\n * Emit a normalized table from an invoice's lines, ready to hand to a renderer\n * such as `@lacspace/xlsx` or a PDF table builder. Amounts stay in integer\n * minor units โ formatting is the renderer's job. No renderer is imported.\n *\n * Columns: `Description`, `Qty`, `Unit`, `Tax %`, `Line total`.\n */\nexport function renderRows(inv: Invoice): {\n columns: string[];\n rows: (string | number)[][];\n} {\n const columns = [\"Description\", \"Qty\", \"Unit\", \"Tax %\", \"Line total\"];\n const rows = inv.lines.map((l) => [\n l.description,\n l.qty,\n l.unitPrice,\n (l.taxRate ?? 0) * 100,\n l.total,\n ]);\n return { columns, rows };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lacspace/invoice",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Invoice model, numbering and tax-rollup engine โ per-line and total calculation, tax grouped by rate, sequential invoice numbers, payment tracking and a render-ready row structure for PDF/XLSX. Integer minor units, zero-dependency, isomorphic.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"invoice",
|
|
31
|
+
"invoicing",
|
|
32
|
+
"billing",
|
|
33
|
+
"tax",
|
|
34
|
+
"invoice-number",
|
|
35
|
+
"accounting",
|
|
36
|
+
"ecommerce",
|
|
37
|
+
"vat",
|
|
38
|
+
"minor-units",
|
|
39
|
+
"isomorphic",
|
|
40
|
+
"typescript"
|
|
41
|
+
],
|
|
42
|
+
"author": "Lacspace <contact@lacspace.com>",
|
|
43
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
44
|
+
"homepage": "https://developer.lacspace.com/packages/invoice",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/lacspace/npm-packages.git",
|
|
48
|
+
"directory": "invoice"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/lacspace/npm-packages/issues"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|