@cogenta/commerce 0.2.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 +373 -0
- package/dist/admin/permissions.d.ts +57 -0
- package/dist/admin/permissions.d.ts.map +1 -0
- package/dist/admin/permissions.js +87 -0
- package/dist/admin/permissions.js.map +1 -0
- package/dist/admin/router.d.ts +53 -0
- package/dist/admin/router.d.ts.map +1 -0
- package/dist/admin/router.js +476 -0
- package/dist/admin/router.js.map +1 -0
- package/dist/cart/store.d.ts +91 -0
- package/dist/cart/store.d.ts.map +1 -0
- package/dist/cart/store.js +342 -0
- package/dist/cart/store.js.map +1 -0
- package/dist/cart/totals.d.ts +73 -0
- package/dist/cart/totals.d.ts.map +1 -0
- package/dist/cart/totals.js +65 -0
- package/dist/cart/totals.js.map +1 -0
- package/dist/catalog/store.d.ts +53 -0
- package/dist/catalog/store.d.ts.map +1 -0
- package/dist/catalog/store.js +358 -0
- package/dist/catalog/store.js.map +1 -0
- package/dist/catalog/types.d.ts +95 -0
- package/dist/catalog/types.d.ts.map +1 -0
- package/dist/catalog/types.js +5 -0
- package/dist/catalog/types.js.map +1 -0
- package/dist/coupon/store.d.ts +103 -0
- package/dist/coupon/store.d.ts.map +1 -0
- package/dist/coupon/store.js +181 -0
- package/dist/coupon/store.js.map +1 -0
- package/dist/customer/store.d.ts +41 -0
- package/dist/customer/store.d.ts.map +1 -0
- package/dist/customer/store.js +103 -0
- package/dist/customer/store.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/invoice/pdf.d.ts +67 -0
- package/dist/invoice/pdf.d.ts.map +1 -0
- package/dist/invoice/pdf.js +449 -0
- package/dist/invoice/pdf.js.map +1 -0
- package/dist/invoice/store.d.ts +96 -0
- package/dist/invoice/store.d.ts.map +1 -0
- package/dist/invoice/store.js +221 -0
- package/dist/invoice/store.js.map +1 -0
- package/dist/money.d.ts +55 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +146 -0
- package/dist/money.js.map +1 -0
- package/dist/order/store.d.ts +75 -0
- package/dist/order/store.d.ts.map +1 -0
- package/dist/order/store.js +301 -0
- package/dist/order/store.js.map +1 -0
- package/dist/order/types.d.ts +67 -0
- package/dist/order/types.d.ts.map +1 -0
- package/dist/order/types.js +71 -0
- package/dist/order/types.js.map +1 -0
- package/dist/payment/manual.d.ts +27 -0
- package/dist/payment/manual.d.ts.map +1 -0
- package/dist/payment/manual.js +105 -0
- package/dist/payment/manual.js.map +1 -0
- package/dist/payment/registry.d.ts +23 -0
- package/dist/payment/registry.d.ts.map +1 -0
- package/dist/payment/registry.js +30 -0
- package/dist/payment/registry.js.map +1 -0
- package/dist/payment/store.d.ts +38 -0
- package/dist/payment/store.d.ts.map +1 -0
- package/dist/payment/store.js +253 -0
- package/dist/payment/store.js.map +1 -0
- package/dist/payment/stripe.d.ts +10 -0
- package/dist/payment/stripe.d.ts.map +1 -0
- package/dist/payment/stripe.js +419 -0
- package/dist/payment/stripe.js.map +1 -0
- package/dist/payment/types.d.ts +124 -0
- package/dist/payment/types.d.ts.map +1 -0
- package/dist/payment/types.js +18 -0
- package/dist/payment/types.js.map +1 -0
- package/dist/rows.d.ts +26 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +87 -0
- package/dist/rows.js.map +1 -0
- package/dist/shipping/store.d.ts +88 -0
- package/dist/shipping/store.d.ts.map +1 -0
- package/dist/shipping/store.js +165 -0
- package/dist/shipping/store.js.map +1 -0
- package/dist/subscription/store.d.ts +106 -0
- package/dist/subscription/store.d.ts.map +1 -0
- package/dist/subscription/store.js +310 -0
- package/dist/subscription/store.js.map +1 -0
- package/dist/tables.d.ts +41 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +383 -0
- package/dist/tables.js.map +1 -0
- package/dist/tax/store.d.ts +68 -0
- package/dist/tax/store.d.ts.map +1 -0
- package/dist/tax/store.js +126 -0
- package/dist/tax/store.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { CogentaError, identifier, newId, sql, } from '@cogenta/core';
|
|
2
|
+
import { formatMoney } from '../money.js';
|
|
3
|
+
import { toInt, toText } from '../rows.js';
|
|
4
|
+
import { TABLES } from '../tables.js';
|
|
5
|
+
import { renderInvoicePdf } from './pdf.js';
|
|
6
|
+
function decode(row) {
|
|
7
|
+
return {
|
|
8
|
+
id: toText(row.id, 'invoice.id'),
|
|
9
|
+
orderId: toText(row.order_id, 'invoice.order_id'),
|
|
10
|
+
series: toText(row.series, 'invoice.series'),
|
|
11
|
+
seq: toInt(row.seq, 'invoice.seq'),
|
|
12
|
+
number: toText(row.number, 'invoice.number'),
|
|
13
|
+
issuedAt: toText(row.issued_at, 'invoice.issued_at'),
|
|
14
|
+
currency: toText(row.currency, 'invoice.currency'),
|
|
15
|
+
totalMinor: toInt(row.total_minor, 'invoice.total_minor'),
|
|
16
|
+
document: JSON.parse(toText(row.document, 'invoice.document')),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** `2026-000042`. Zero-padded so the numbers sort as strings too. */
|
|
20
|
+
export function formatInvoiceNumber(series, seq) {
|
|
21
|
+
return `${series}-${String(seq).padStart(6, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* How many attempts at claiming the next number before giving up.
|
|
25
|
+
*
|
|
26
|
+
* The claim is a compare-and-set, so a loser retries with the value it just
|
|
27
|
+
* read. Under real contention two or three attempts is already unusual; ten is
|
|
28
|
+
* a number nobody reaches without something else being wrong, and failing
|
|
29
|
+
* loudly then is better than spinning.
|
|
30
|
+
*/
|
|
31
|
+
const CLAIM_ATTEMPTS = 10;
|
|
32
|
+
export function createInvoiceStore(db, dependencies, now = Date.now) {
|
|
33
|
+
const d = db.dialect;
|
|
34
|
+
const invoices = identifier(TABLES.invoices, d);
|
|
35
|
+
const sequences = identifier(TABLES.invoiceSequences, d);
|
|
36
|
+
/**
|
|
37
|
+
* Takes the next number in a series, atomically.
|
|
38
|
+
*
|
|
39
|
+
* `update … set next_seq = next_seq + 1 where series = ? and next_seq = ?`
|
|
40
|
+
* — a compare-and-set whose `rowsAffected` decides the race, the same idiom
|
|
41
|
+
* that makes stock safe and a password reset token single use. Two invoices
|
|
42
|
+
* issued in the same millisecond get consecutive numbers, never the same
|
|
43
|
+
* one.
|
|
44
|
+
*
|
|
45
|
+
* Emphatically not `count(*) + 1`: that hands out a duplicate under any
|
|
46
|
+
* concurrency at all, and re-issues a number that a deleted row used to
|
|
47
|
+
* hold. A number, once handed out, is spent.
|
|
48
|
+
*/
|
|
49
|
+
async function claimNumber(tx, series) {
|
|
50
|
+
for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt += 1) {
|
|
51
|
+
const current = await tx.query(sql `select next_seq from ${sequences} where series = ${series}`);
|
|
52
|
+
const row = current.rows[0];
|
|
53
|
+
if (row === undefined) {
|
|
54
|
+
// First invoice of the series. A duplicate primary key here means
|
|
55
|
+
// somebody else created it in between, so the loop reads it and
|
|
56
|
+
// competes for the increment like everyone else.
|
|
57
|
+
try {
|
|
58
|
+
await tx.query(sql `insert into ${sequences} (series, next_seq) values (${series}, ${2})`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const next = toInt(row.next_seq, 'invoice_sequence.next_seq');
|
|
66
|
+
const claimed = await tx.query(sql `
|
|
67
|
+
update ${sequences} set next_seq = ${next + 1}
|
|
68
|
+
where series = ${series} and next_seq = ${next}`);
|
|
69
|
+
if (claimed.rowsAffected > 0)
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
72
|
+
throw new CogentaError({
|
|
73
|
+
code: 'COMMERCE_INVOICE_SEQUENCE_CONFLICT',
|
|
74
|
+
message: `Could not take the next number in invoice series "${series}".`,
|
|
75
|
+
hint: 'The sequence is under unusually heavy contention. Retry the issue.',
|
|
76
|
+
details: { series, attempts: CLAIM_ATTEMPTS },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function read(id, executor = db) {
|
|
80
|
+
const result = await executor.query(sql `select * from ${invoices} where id = ${id}`);
|
|
81
|
+
const row = result.rows[0];
|
|
82
|
+
return row === undefined ? null : decode(row);
|
|
83
|
+
}
|
|
84
|
+
function documentFor(order, number, issuedAt, buyer) {
|
|
85
|
+
const snapshot = {
|
|
86
|
+
number,
|
|
87
|
+
issuedAt,
|
|
88
|
+
orderReference: order.reference,
|
|
89
|
+
seller: dependencies.seller.address,
|
|
90
|
+
buyer: buyer.length > 0 ? buyer : [order.email],
|
|
91
|
+
currency: order.currency,
|
|
92
|
+
lines: order.lines.map((line) => ({
|
|
93
|
+
sku: line.sku,
|
|
94
|
+
title: line.title,
|
|
95
|
+
quantity: line.quantity,
|
|
96
|
+
unitPriceMinor: line.unitPriceMinor,
|
|
97
|
+
taxRateBp: line.taxRateBp,
|
|
98
|
+
totalMinor: line.totalMinor,
|
|
99
|
+
})),
|
|
100
|
+
subtotalMinor: order.subtotalMinor,
|
|
101
|
+
discountMinor: order.discountMinor,
|
|
102
|
+
shippingMinor: order.shippingMinor,
|
|
103
|
+
taxMinor: order.taxMinor,
|
|
104
|
+
totalMinor: order.totalMinor,
|
|
105
|
+
footer: dependencies.seller.footer ?? null,
|
|
106
|
+
};
|
|
107
|
+
return snapshot;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
issue: async (input) => {
|
|
111
|
+
const order = await dependencies.orders.read(input.orderId);
|
|
112
|
+
if (order === null) {
|
|
113
|
+
throw new CogentaError({
|
|
114
|
+
code: 'COMMERCE_ORDER_NOT_FOUND',
|
|
115
|
+
message: 'This order does not exist.',
|
|
116
|
+
hint: 'Check the order reference.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const existing = await db.query(sql `select * from ${invoices} where order_id = ${input.orderId}`);
|
|
120
|
+
if (existing.rows[0] !== undefined) {
|
|
121
|
+
throw new CogentaError({
|
|
122
|
+
code: 'COMMERCE_INVOICE_ALREADY_ISSUED',
|
|
123
|
+
message: 'This order has already been invoiced.',
|
|
124
|
+
hint: 'An invoice is issued once. To correct one, issue a credit note against it.',
|
|
125
|
+
details: { orderId: input.orderId },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const issuedAt = new Date(now()).toISOString();
|
|
129
|
+
const series = input.series ?? issuedAt.slice(0, 4);
|
|
130
|
+
const id = newId(now);
|
|
131
|
+
return db.transaction(async (tx) => {
|
|
132
|
+
// The number is taken inside the same transaction that writes the
|
|
133
|
+
// invoice. If the write fails, the increment rolls back with it and
|
|
134
|
+
// the number is not burned — which is what "gapless" requires.
|
|
135
|
+
const seq = await claimNumber(tx, series);
|
|
136
|
+
const number = formatInvoiceNumber(series, seq);
|
|
137
|
+
const document = documentFor(order, number, issuedAt, input.buyerAddress ?? []);
|
|
138
|
+
await tx.query(sql `
|
|
139
|
+
insert into ${invoices} (id, order_id, series, seq, number, issued_at, currency, total_minor, document)
|
|
140
|
+
values (${id}, ${order.id}, ${series}, ${seq}, ${number}, ${issuedAt},
|
|
141
|
+
${order.currency}, ${order.totalMinor}, ${JSON.stringify(document)})`);
|
|
142
|
+
await dependencies.orders.record(order.id, 'invoiced', { note: `Invoice ${number} issued.`, actorId: input.actorId ?? null }, tx);
|
|
143
|
+
const stored = await read(id, tx);
|
|
144
|
+
if (stored === null) {
|
|
145
|
+
throw new CogentaError({
|
|
146
|
+
code: 'COMMERCE_INVOICE_NOT_FOUND',
|
|
147
|
+
message: 'The invoice was not stored.',
|
|
148
|
+
hint: 'Check that the commerce tables exist (ensureCommerceTables).',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return stored;
|
|
152
|
+
}, { immediate: true });
|
|
153
|
+
},
|
|
154
|
+
read,
|
|
155
|
+
readByOrder: async (orderId) => {
|
|
156
|
+
const result = await db.query(sql `select * from ${invoices} where order_id = ${orderId}`);
|
|
157
|
+
const row = result.rows[0];
|
|
158
|
+
return row === undefined ? null : decode(row);
|
|
159
|
+
},
|
|
160
|
+
readByNumber: async (number) => {
|
|
161
|
+
const result = await db.query(sql `select * from ${invoices} where number = ${number}`);
|
|
162
|
+
const row = result.rows[0];
|
|
163
|
+
return row === undefined ? null : decode(row);
|
|
164
|
+
},
|
|
165
|
+
list: async (options) => {
|
|
166
|
+
const result = options?.series === undefined
|
|
167
|
+
? await db.query(sql `select * from ${invoices} order by series asc, seq asc`)
|
|
168
|
+
: await db.query(sql `select * from ${invoices} where series = ${options.series} order by seq asc`);
|
|
169
|
+
return result.rows.slice(0, options?.limit ?? 500).map(decode);
|
|
170
|
+
},
|
|
171
|
+
pdf: async (id) => {
|
|
172
|
+
const invoice = await read(id);
|
|
173
|
+
if (invoice === null) {
|
|
174
|
+
throw new CogentaError({
|
|
175
|
+
code: 'COMMERCE_INVOICE_NOT_FOUND',
|
|
176
|
+
message: 'This invoice does not exist.',
|
|
177
|
+
hint: 'Check the invoice number.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return renderInvoicePdf(pdfDocumentFor(invoice.document));
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The stored snapshot, laid out for the PDF writer.
|
|
186
|
+
*
|
|
187
|
+
* A separate step on purpose: the snapshot is the record and the PDF is a
|
|
188
|
+
* rendering of it. Regenerating a PDF five years later from the same snapshot
|
|
189
|
+
* produces the same bytes, because nothing here reads a clock or a live row.
|
|
190
|
+
*/
|
|
191
|
+
export function pdfDocumentFor(document) {
|
|
192
|
+
const money = (amountMinor) => formatMoney({ amountMinor, currency: document.currency });
|
|
193
|
+
const totals = [['Subtotal', money(document.subtotalMinor)]];
|
|
194
|
+
if (document.discountMinor > 0)
|
|
195
|
+
totals.push(['Discount', `-${money(document.discountMinor)}`]);
|
|
196
|
+
if (document.shippingMinor > 0)
|
|
197
|
+
totals.push(['Shipping', money(document.shippingMinor)]);
|
|
198
|
+
if (document.taxMinor > 0)
|
|
199
|
+
totals.push(['Tax', money(document.taxMinor)]);
|
|
200
|
+
totals.push(['Total', money(document.totalMinor)]);
|
|
201
|
+
return {
|
|
202
|
+
title: 'Invoice',
|
|
203
|
+
number: document.number,
|
|
204
|
+
issuedAt: document.issuedAt.slice(0, 10),
|
|
205
|
+
seller: document.seller,
|
|
206
|
+
buyer: document.buyer,
|
|
207
|
+
columns: ['SKU', 'Description', 'Qty', 'Unit', 'Amount'],
|
|
208
|
+
lines: document.lines.map((line) => ({
|
|
209
|
+
cells: [
|
|
210
|
+
line.sku,
|
|
211
|
+
line.title,
|
|
212
|
+
String(line.quantity),
|
|
213
|
+
money(line.unitPriceMinor),
|
|
214
|
+
money(line.totalMinor),
|
|
215
|
+
],
|
|
216
|
+
})),
|
|
217
|
+
totals,
|
|
218
|
+
...(document.footer === null ? {} : { footer: document.footer }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/invoice/store.ts"],"names":[],"mappings":"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"}
|
package/dist/money.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money, in the smallest unit of its currency, as an integer. Always.
|
|
3
|
+
*
|
|
4
|
+
* The three mandatory dialects (ADR-0006) do not agree on decimals: Postgres
|
|
5
|
+
* has exact `numeric`, MySQL has `decimal`, and SQLite has only `REAL` — a
|
|
6
|
+
* binary float where `0.1 + 0.2` is not `0.3`. A column that means something
|
|
7
|
+
* different on one of the three supported databases is a bug waiting for its
|
|
8
|
+
* first lost cent, so there is no decimal column anywhere in this package.
|
|
9
|
+
*
|
|
10
|
+
* One integer of minor units means the same thing everywhere, and
|
|
11
|
+
* `Number.MAX_SAFE_INTEGER` holds ninety thousand billion cents — no shop this
|
|
12
|
+
* CMS serves comes near it.
|
|
13
|
+
*/
|
|
14
|
+
export interface Money {
|
|
15
|
+
/** Cents, pence, øre… Never a major unit, never a float. */
|
|
16
|
+
readonly amountMinor: number;
|
|
17
|
+
/** ISO 4217, upper case. */
|
|
18
|
+
readonly currency: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function minorUnitExponent(currency: string): number;
|
|
21
|
+
export declare function normaliseCurrency(currency: string): string;
|
|
22
|
+
export declare function assertCurrency(currency: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Refuses anything that is not a whole, finite, non-negative number of minor
|
|
25
|
+
* units — including the float that arrives when someone multiplies a price by
|
|
26
|
+
* a tax rate and forgets to round.
|
|
27
|
+
*/
|
|
28
|
+
export declare function assertMinor(amount: number, what: string): number;
|
|
29
|
+
/** Two amounts of the same currency, or a refusal. Never a silent conversion. */
|
|
30
|
+
export declare function assertSameCurrency(left: string, right: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* A percentage of an amount, in basis points, rounded half up.
|
|
33
|
+
*
|
|
34
|
+
* Basis points rather than a float rate: `0.2` is not representable in binary,
|
|
35
|
+
* so a 20 % VAT expressed as a float already carries an error before the
|
|
36
|
+
* multiplication. `2000` basis points does not.
|
|
37
|
+
*
|
|
38
|
+
* Half-up rather than banker's rounding: it is what every invoice a European
|
|
39
|
+
* tax authority has ever accepted does, and matching the paper is worth more
|
|
40
|
+
* here than a marginally better statistical distribution.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyBasisPoints(amountMinor: number, basisPoints: number): number;
|
|
43
|
+
/**
|
|
44
|
+
* Splits an amount over n shares so the parts add up to exactly the whole.
|
|
45
|
+
*
|
|
46
|
+
* Used when a cart-level discount has to be attributed to lines (an invoice
|
|
47
|
+
* must show per-line tax, and tax is computed after discount). Dividing and
|
|
48
|
+
* rounding each share independently loses or invents up to n-1 minor units;
|
|
49
|
+
* the remainder is therefore handed out one unit at a time to the largest
|
|
50
|
+
* weights first, which is both fair and exact.
|
|
51
|
+
*/
|
|
52
|
+
export declare function distribute(amountMinor: number, weights: readonly number[]): number[];
|
|
53
|
+
/** For an invoice line or an admin screen. Never for arithmetic. */
|
|
54
|
+
export declare function formatMoney(money: Money, locale?: string): string;
|
|
55
|
+
//# sourceMappingURL=money.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money.d.ts","sourceRoot":"","sources":["../src/money.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,KAAK;IACpB,4DAA4D;IAC5D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,4BAA4B;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AA2BD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAUvD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAuBhE;AAED,iFAAiF;AACjF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAWtE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAUjF;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAmBpF;AAED,oEAAoE;AACpE,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,SAAU,GAAG,MAAM,CAUlE"}
|
package/dist/money.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { CogentaError } from '@cogenta/core';
|
|
2
|
+
/**
|
|
3
|
+
* Currencies whose minor unit is not a hundredth.
|
|
4
|
+
*
|
|
5
|
+
* Only needed for display and for the amount handed to a payment gateway;
|
|
6
|
+
* arithmetic never looks at it. Deliberately short — this is the list of
|
|
7
|
+
* exceptions, not a currency database, and an unlisted currency gets the
|
|
8
|
+
* two-digit default that fits the overwhelming majority.
|
|
9
|
+
*/
|
|
10
|
+
const EXPONENTS = new Map([
|
|
11
|
+
['JPY', 0],
|
|
12
|
+
['KRW', 0],
|
|
13
|
+
['VND', 0],
|
|
14
|
+
['CLP', 0],
|
|
15
|
+
['ISK', 0],
|
|
16
|
+
['XAF', 0],
|
|
17
|
+
['XOF', 0],
|
|
18
|
+
['XPF', 0],
|
|
19
|
+
['BHD', 3],
|
|
20
|
+
['IQD', 3],
|
|
21
|
+
['JOD', 3],
|
|
22
|
+
['KWD', 3],
|
|
23
|
+
['OMR', 3],
|
|
24
|
+
['TND', 3],
|
|
25
|
+
]);
|
|
26
|
+
export function minorUnitExponent(currency) {
|
|
27
|
+
return EXPONENTS.get(normaliseCurrency(currency)) ?? 2;
|
|
28
|
+
}
|
|
29
|
+
export function normaliseCurrency(currency) {
|
|
30
|
+
return currency.trim().toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
export function assertCurrency(currency) {
|
|
33
|
+
const code = normaliseCurrency(currency);
|
|
34
|
+
if (!/^[A-Z]{3}$/u.test(code)) {
|
|
35
|
+
throw new CogentaError({
|
|
36
|
+
code: 'COMMERCE_CURRENCY_INVALID',
|
|
37
|
+
message: `"${currency}" is not a currency code.`,
|
|
38
|
+
hint: 'Use a three-letter ISO 4217 code, such as EUR, USD or JPY.',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return code;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Refuses anything that is not a whole, finite, non-negative number of minor
|
|
45
|
+
* units — including the float that arrives when someone multiplies a price by
|
|
46
|
+
* a tax rate and forgets to round.
|
|
47
|
+
*/
|
|
48
|
+
export function assertMinor(amount, what) {
|
|
49
|
+
if (!Number.isFinite(amount) || !Number.isInteger(amount)) {
|
|
50
|
+
throw new CogentaError({
|
|
51
|
+
code: 'COMMERCE_AMOUNT_INVALID',
|
|
52
|
+
message: `${what} must be a whole number of minor units, got ${String(amount)}.`,
|
|
53
|
+
hint: 'Money is stored in cents (or the smallest unit of the currency), never as a decimal.',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (amount < 0) {
|
|
57
|
+
throw new CogentaError({
|
|
58
|
+
code: 'COMMERCE_AMOUNT_INVALID',
|
|
59
|
+
message: `${what} cannot be negative (${String(amount)}).`,
|
|
60
|
+
hint: 'A refund is a separate record with its own sign, not a negative amount on the original one.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (amount > Number.MAX_SAFE_INTEGER) {
|
|
64
|
+
throw new CogentaError({
|
|
65
|
+
code: 'COMMERCE_AMOUNT_INVALID',
|
|
66
|
+
message: `${what} is larger than integer arithmetic can carry exactly.`,
|
|
67
|
+
hint: 'Check the amount: this is almost always a unit mistake (a major unit passed as a minor one).',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return amount;
|
|
71
|
+
}
|
|
72
|
+
/** Two amounts of the same currency, or a refusal. Never a silent conversion. */
|
|
73
|
+
export function assertSameCurrency(left, right) {
|
|
74
|
+
const a = normaliseCurrency(left);
|
|
75
|
+
const b = normaliseCurrency(right);
|
|
76
|
+
if (a !== b) {
|
|
77
|
+
throw new CogentaError({
|
|
78
|
+
code: 'COMMERCE_CURRENCY_MISMATCH',
|
|
79
|
+
message: `Cannot combine ${a} with ${b}.`,
|
|
80
|
+
hint: 'A cart, an order and every line in it are in one currency. There is no exchange rate in this package.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return a;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A percentage of an amount, in basis points, rounded half up.
|
|
87
|
+
*
|
|
88
|
+
* Basis points rather than a float rate: `0.2` is not representable in binary,
|
|
89
|
+
* so a 20 % VAT expressed as a float already carries an error before the
|
|
90
|
+
* multiplication. `2000` basis points does not.
|
|
91
|
+
*
|
|
92
|
+
* Half-up rather than banker's rounding: it is what every invoice a European
|
|
93
|
+
* tax authority has ever accepted does, and matching the paper is worth more
|
|
94
|
+
* here than a marginally better statistical distribution.
|
|
95
|
+
*/
|
|
96
|
+
export function applyBasisPoints(amountMinor, basisPoints) {
|
|
97
|
+
assertMinor(amountMinor, 'amount');
|
|
98
|
+
if (!Number.isInteger(basisPoints) || basisPoints < 0) {
|
|
99
|
+
throw new CogentaError({
|
|
100
|
+
code: 'COMMERCE_AMOUNT_INVALID',
|
|
101
|
+
message: `A rate must be a whole number of basis points, got ${String(basisPoints)}.`,
|
|
102
|
+
hint: '20 % is 2000 basis points. 5.5 % is 550.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return Math.floor((amountMinor * basisPoints + 5000) / 10_000);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Splits an amount over n shares so the parts add up to exactly the whole.
|
|
109
|
+
*
|
|
110
|
+
* Used when a cart-level discount has to be attributed to lines (an invoice
|
|
111
|
+
* must show per-line tax, and tax is computed after discount). Dividing and
|
|
112
|
+
* rounding each share independently loses or invents up to n-1 minor units;
|
|
113
|
+
* the remainder is therefore handed out one unit at a time to the largest
|
|
114
|
+
* weights first, which is both fair and exact.
|
|
115
|
+
*/
|
|
116
|
+
export function distribute(amountMinor, weights) {
|
|
117
|
+
assertMinor(amountMinor, 'amount');
|
|
118
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
119
|
+
if (weights.length === 0 || total <= 0)
|
|
120
|
+
return weights.map(() => 0);
|
|
121
|
+
const shares = weights.map((weight) => Math.floor((amountMinor * weight) / total));
|
|
122
|
+
let remainder = amountMinor - shares.reduce((sum, share) => sum + share, 0);
|
|
123
|
+
const order = weights
|
|
124
|
+
.map((weight, index) => ({ weight, index }))
|
|
125
|
+
.sort((left, right) => right.weight - left.weight || left.index - right.index);
|
|
126
|
+
for (const { index } of order) {
|
|
127
|
+
if (remainder <= 0)
|
|
128
|
+
break;
|
|
129
|
+
shares[index] = (shares[index] ?? 0) + 1;
|
|
130
|
+
remainder -= 1;
|
|
131
|
+
}
|
|
132
|
+
return shares;
|
|
133
|
+
}
|
|
134
|
+
/** For an invoice line or an admin screen. Never for arithmetic. */
|
|
135
|
+
export function formatMoney(money, locale = 'en-US') {
|
|
136
|
+
const currency = assertCurrency(money.currency);
|
|
137
|
+
const exponent = minorUnitExponent(currency);
|
|
138
|
+
const major = money.amountMinor / 10 ** exponent;
|
|
139
|
+
return new Intl.NumberFormat(locale, {
|
|
140
|
+
style: 'currency',
|
|
141
|
+
currency,
|
|
142
|
+
minimumFractionDigits: exponent,
|
|
143
|
+
maximumFractionDigits: exponent,
|
|
144
|
+
}).format(major);
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=money.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money.js","sourceRoot":"","sources":["../src/money.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type DatabaseHandle, type SqlExecutor } from '@cogenta/core';
|
|
2
|
+
import type { CartStore } from '../cart/store.js';
|
|
3
|
+
import type { CatalogStore } from '../catalog/store.js';
|
|
4
|
+
import type { StockShortfall } from '../catalog/types.js';
|
|
5
|
+
import type { CouponStore } from '../coupon/store.js';
|
|
6
|
+
import type { CustomerStore } from '../customer/store.js';
|
|
7
|
+
import type { Order, OrderEvent, OrderEventKind, OrderStatus } from './types.js';
|
|
8
|
+
export interface PlaceOrderInput {
|
|
9
|
+
readonly cartId: string;
|
|
10
|
+
readonly email: string;
|
|
11
|
+
readonly customerName?: string | null;
|
|
12
|
+
readonly actorId?: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Placing an order either produced one, or explains exactly what stopped it.
|
|
16
|
+
*
|
|
17
|
+
* A thrown error would be wrong for the two ordinary cases — a line went out
|
|
18
|
+
* of stock, a coupon ran out while the shopper was typing their address. Those
|
|
19
|
+
* are things that happen in a shop every day and the checkout has to render
|
|
20
|
+
* them, not log them.
|
|
21
|
+
*/
|
|
22
|
+
export type PlaceOrderOutcome = {
|
|
23
|
+
readonly kind: 'placed';
|
|
24
|
+
readonly order: Order;
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: 'out_of_stock';
|
|
27
|
+
readonly shortfalls: readonly StockShortfall[];
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'coupon_refused';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'empty';
|
|
33
|
+
};
|
|
34
|
+
export interface OrderStoreDependencies {
|
|
35
|
+
readonly catalog: CatalogStore;
|
|
36
|
+
readonly carts: CartStore;
|
|
37
|
+
readonly customers: CustomerStore;
|
|
38
|
+
readonly coupons: CouponStore;
|
|
39
|
+
}
|
|
40
|
+
export interface OrderListOptions {
|
|
41
|
+
readonly status?: OrderStatus;
|
|
42
|
+
readonly customerId?: string;
|
|
43
|
+
readonly limit?: number;
|
|
44
|
+
readonly offset?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface OrderStore {
|
|
47
|
+
/**
|
|
48
|
+
* Turns a priced cart into an order, in **one** transaction.
|
|
49
|
+
*
|
|
50
|
+
* Everything that must be true together is done together: stock is taken,
|
|
51
|
+
* the coupon redemption is claimed, the order and its lines are written, the
|
|
52
|
+
* cart is closed, and the first history event is recorded. If any one of
|
|
53
|
+
* them fails, none of them happened — no order with unreserved stock, no
|
|
54
|
+
* stock taken for an order that was never written, no coupon burned for a
|
|
55
|
+
* sale that did not occur.
|
|
56
|
+
*/
|
|
57
|
+
place(input: PlaceOrderInput): Promise<PlaceOrderOutcome>;
|
|
58
|
+
read(id: string): Promise<Order | null>;
|
|
59
|
+
readByReference(reference: string): Promise<Order | null>;
|
|
60
|
+
list(options?: OrderListOptions): Promise<readonly Order[]>;
|
|
61
|
+
/** Moves the order along, refusing an impossible move. Returns the new order. */
|
|
62
|
+
transition(id: string, to: OrderStatus, options?: {
|
|
63
|
+
readonly actorId?: string | null;
|
|
64
|
+
readonly note?: string;
|
|
65
|
+
}): Promise<Order>;
|
|
66
|
+
history(id: string): Promise<readonly OrderEvent[]>;
|
|
67
|
+
/** Appends to the history without changing the status. */
|
|
68
|
+
record(id: string, kind: OrderEventKind, options?: {
|
|
69
|
+
readonly note?: string;
|
|
70
|
+
readonly actorId?: string | null;
|
|
71
|
+
}, tx?: SqlExecutor): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
export declare function referenceFrom(id: string): string;
|
|
74
|
+
export declare function createOrderStore(db: DatabaseHandle, dependencies: OrderStoreDependencies, now?: () => number): OrderStore;
|
|
75
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/order/store.ts"],"names":[],"mappings":"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"}
|