@vibedout/commerce 0.0.0-stage → 0.2.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vibedout
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,411 @@
1
- # Temporary Holding Version
1
+ # @vibedout/commerce
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The contracts a store is built from on Cloudflare Workers, as types and pure functions. A **line source** prices what a buyer asks for, on
4
+ the server, and may hold places of its own (a booking slot). A **stock port** holds, sells and returns units. A **payments port** starts a
5
+ payment and may cancel one. Tax, shipping rates and discounts are optional ports. Around them: checks for what the browser sends and for
6
+ what a source answers, a cart priced across several sources, totals in minor units that refuse a mixed currency, a discount split over the
7
+ lines to the centavo, the payment request an order becomes, and test helpers (a harness for your line source, an in-memory stock and
8
+ in-memory slots, a fake payments port, and the suites a stock, a holding source and a payments provider pass).
9
+
10
+ - **The server sets the price.** The browser says *which thing and how many* (a `LineAsk`); the line source says what it costs (a
11
+ `ResolvedLine`). `validateAsks` drops everything else the browser sends, and no option is read as a price, except an amount the buyer
12
+ chooses inside bounds the server sets (a donation; below).
13
+ - **Whole numbers.** Every amount is a safe integer of minor units (centavos) with an uppercase ISO 4217 currency. No float crosses an API
14
+ here, and two currencies are never added. The money type and the overflow-safe sum are `@vibedout/money`'s, bundled by the build.
15
+ - **A feature does not import another feature.** Each feature that sells, holds stock or takes money *provides* one of these ports and
16
+ registers it; the one that needs it looks it up by name. This package is only the types and the pure functions; where a provider is
17
+ registered and found is the registry in the project (`src/worker/lib/ports.ts` of the pattern pack: `linePort(key)`, `stockPort()`,
18
+ `paymentsPort()`, `taxPort()`, `shippingRatesPort()`, `discountPort()`).
19
+ - **Workers-first, no runtime dependencies.** ESM only, with types. Every module is its own subpath.
20
+
21
+ | Import | What |
22
+ |---|---|
23
+ | `@vibedout/commerce` | everything below except the test helpers |
24
+ | `@vibedout/commerce/types` | the port interfaces and the data they carry (`LineSource`, `HoldingLineSource`, `ResolvedLine`, `CartAsk`, `CartLine`, `StockPort`, `PaymentsPort`, `CancelPaymentResult`, `TaxPort`, `ShippingRatesPort`, `DiscountPort`, `PortProviders`), `MAX_HOLD_MS` |
25
+ | `@vibedout/commerce/lines` | `validateAsks`, `validateCartAsks`, `resolveLines`, `resolveCart`, `checkResolvedLine`, `checkResolvedLines`, `partitionLines`, `blockedReason`, `holdLinesFrom`, `holdsCapacity` |
26
+ | `@vibedout/commerce/totals` | `computeTotals`, `assertTotals` |
27
+ | `@vibedout/commerce/money` | `isMinorUnits`, `isCurrencyCode`, `lineTotalMinor`, `sumMinor`, `subtractMinor`, `basisPointsOf`, `taxIncludedIn`, `allocateMinor`, `MAX_RATE_BP`, `ROUNDINGS` |
28
+ | `@vibedout/commerce/payments` | `checkStartPayment`, `paymentRequestFrom` |
29
+ | `@vibedout/commerce/ports` | `PORT_METHODS`, `PORT_NAMES`, `checkPortProvider` |
30
+ | `@vibedout/commerce/discounts` | `spreadDiscount`, `lineDiscountsFrom`, `checkLineDiscounts` |
31
+ | `@vibedout/commerce/snapshot` | `SNAPSHOT_KEYS`, `snapshotText` |
32
+ | `@vibedout/commerce/errors` | `CommerceError`, `CommerceProblem`, `PROBLEM_CODES` |
33
+ | `@vibedout/commerce/testing` | `runLineSourceConformance`, `checkLineSource`, `runStockConformance`, `createMemoryStock`, `runLineHoldConformance`, `createMemorySlots`, `runPaymentsConformance`, `createFakePayments`, `runTaxConformance`, `createFlatTax`, `runDiscountConformance`, `createFlatDiscount`, `testContext` |
34
+
35
+ ## Provide a line source
36
+
37
+ A feature that sells something (a catalog, a booking service, a menu, a payment link) answers one question: *what does this cost, and can it
38
+ be bought?*
39
+
40
+ ```ts
41
+ import type { LineSource, ResolvedLine } from "@vibedout/commerce";
42
+
43
+ export const variantLines: LineSource = {
44
+ async resolve(ctx, asks) {
45
+ const rows = await loadVariants(ctx.db, asks.map((ask) => ask.ref)); // one query for the whole batch
46
+ return asks.map((ask): ResolvedLine => {
47
+ const row = rows.get(ask.ref);
48
+ if (!row) {
49
+ // A ref that does not exist is an answer, not an exception.
50
+ return { ref: ask.ref, title: ask.ref, quantity: ask.quantity, unitAmountMinor: 0, currency: "MXN", availability: "not_found", taxIncluded: true, fulfilment: "none", snapshot: {} };
51
+ }
52
+ return {
53
+ ref: ask.ref,
54
+ title: row.title,
55
+ quantity: ask.quantity, // echoed, never changed
56
+ unitAmountMinor: row.priceMinor, // the server's price; ask.options never sets it
57
+ currency: row.currency,
58
+ availability: row.published ? "available" : "not_orderable",
59
+ maxQuantity: 10,
60
+ taxCode: "01010101",
61
+ taxIncluded: true,
62
+ fulfilment: "ship",
63
+ stock: { subjectType: "catalog.variant", subjectId: row.id }, // inventory holds it
64
+ snapshot: { sku: row.sku, name: row.title }, // frozen into the order line
65
+ };
66
+ });
67
+ },
68
+ };
69
+ ```
70
+
71
+ The rules of a source, which `checkLineSource` and `runLineSourceConformance` test:
72
+
73
+ - **One line per ask, in the order asked**, for a ref that does not exist too (`availability: "not_found"`, price 0). Two asks for one ref
74
+ are two lines. The `ref` and `quantity` are echoed.
75
+ - `availability` is `available`, `sold_out` (a real item with none left: keep its title and price), `not_orderable` (not for sale) or
76
+ `not_found`. `maxQuantity` is the most one order may take, at least 1.
77
+ - A price is a whole number of minor units, zero or more. It comes from your data, never from `ask.options` (the one exception is a source
78
+ whose buyer chooses the amount inside bounds the server sets, below), and the same ask gives the same line.
79
+ - `snapshot` is a plain object that survives JSON unchanged, at most 2 KiB. It is copied into every order line.
80
+ - A source reads its own tables and never edits the asks it was given.
81
+ - A stock subject (`stock`) is `{ subjectType, subjectId }` with a dotted lowercase type (`catalog.variant`); inventory knows an item by it.
82
+
83
+ **A buyer who chooses the amount** (a donation, a tip) is the one case where the price comes from the ask: the amount travels in an option as
84
+ a string of digits, the source refuses anything that is not a whole number inside its bounds (`12.5`, `1e3`, a padded or signed number, `0x10`,
85
+ digits of another script) and answers `maxQuantity: 1`, so a quantity cannot multiply the amount past the bound. The line is `available` at
86
+ exactly that amount, or `not_orderable`. Say so to the harness with `buyerPrice` (below).
87
+
88
+ ## Read a buyer's asks, resolve them, total them
89
+
90
+ ```ts
91
+ import { computeTotals, partitionLines, resolveLines, validateAsks } from "@vibedout/commerce";
92
+
93
+ const asks = validateAsks(body.lines); // { ok, asks } | { ok: false, problems }: only ref, quantity and options survive
94
+ if (!asks.ok) return c.json({ problems: asks.problems }, 422);
95
+
96
+ const resolved = await resolveLines(source, ctx, asks.asks); // the answer is checked: a wrong source stops here, not in the total
97
+ if (!resolved.ok) throw new Error(`line source: ${resolved.problems[0]?.message}`);
98
+
99
+ const { chargeable, blocked } = partitionLines(resolved.lines); // blocked: sold_out, not_orderable, not_found, over_max_quantity
100
+ const totals = computeTotals({ lines: chargeable, discounts: discount.applied, shipping: chosenRate, tax });
101
+ if (!totals.ok) return c.json({ problems: totals.problems }, 422);
102
+ totals.totals.totalMinor; // subtotal - discounts + shipping + added tax
103
+ ```
104
+
105
+ - `validateAsks` allows 1 to 50 lines, quantities 1 to 99 and refs of 1 to 120 printable characters (no control, zero-width or
106
+ direction-changing characters, nothing padded), and refuses the same ref with the same options twice. Pass limits as the second argument
107
+ (a ref is at most 1024 characters whatever the limit says).
108
+ - `resolveLines` hands the source frozen copies of the asks (a source that sorts them in place throws instead of corrupting your order) and
109
+ does not catch a source that throws: that is a fault, not a problem with the answer.
110
+ - `computeTotals` refuses (it returns `problems`, it does not throw) an empty order, a line that cannot be bought, two currencies, **a
111
+ shipping rate, a tax or a discount in a currency other than the lines'** (each states its own: `shipping: rate` passes a `ShippingRate` as it
112
+ is), an amount that is not whole minor units or too large for a number, discounts larger than the subtotal, and a tax breakdown that does not add
113
+ up to its headline. `tax.includedMinor` is the tax already inside the prices (for the invoice); it is not added again. `assertTotals` throws a
114
+ `CommerceError` carrying the same problems. Each priced line keeps its `fulfilment`, `stock` subject and `snapshot` for the order.
115
+ - Every problem is `{ code, path, message }`: `code` is for your route to map, `message` is English for a log.
116
+
117
+ ## A cart of several sources
118
+
119
+ A cart holds things several features sell (a tag from the catalog, a session from booking). Each line names its source; `resolveCart` asks
120
+ each source once, for its own lines, and gives the lines back in the cart's order, each with its `source`:
121
+
122
+ ```ts
123
+ import { computeTotals, type LineSource, partitionLines, resolveCart, validateCartAsks } from "@vibedout/commerce";
124
+
125
+ const findSource = (key: string) => {
126
+ const found = linePort<LineSource>(key); // the project's registry
127
+ return found.ok ? found.port : undefined;
128
+ };
129
+ const read = validateCartAsks(cartLines, { find: findSource }); // only source, ref, quantity and options survive; a source nobody registered is refused
130
+ if (!read.ok) return c.json({ problems: read.problems }, 422);
131
+ const cart = await resolveCart(findSource, ctx, read.asks);
132
+ if (!cart.ok) throw new Error(`line source: ${cart.problems[0]?.message}`); // a source answered wrong: the path is the cart's line
133
+ const { chargeable, blocked } = partitionLines(cart.lines); // a line whose source is gone is blocked as not_found
134
+ const totals = computeTotals({ lines: chargeable });
135
+ ```
136
+
137
+ - Each source gets only its asks (without the `source` key), as frozen copies, and its answer is checked as `resolveLines` checks it. The
138
+ sources are asked at the same time; one that throws is not caught.
139
+ - A line whose source is not there (a feature that was removed, a key nobody registered) comes back `not_found` at a price of 0 in no
140
+ currency (`XXX`), as a ref no source has: the cart shows it as no longer sold and totals the rest.
141
+ - A source that answers wrong makes the whole answer `{ ok: false, problems }`, listed source by source: a problem with one line points at the
142
+ cart's line (`lines[3].unitAmountMinor`), one with a source's whole answer at `lines`, and each message starts with the source's key.
143
+ - It does not check the asks: check them where they come in, with `validateCartAsks` (below). A line not sold here shows its ref as its title.
144
+ - `validateCartAsks(input, { find?, ...limits })` reads a cart's lines before any price is asked and any place is held. It gives back exactly
145
+ `source`, `ref`, `quantity` and `options`, in the order sent, and refuses a `source` that is not a key (`invalid_source`: dotted lowercase words,
146
+ at most 80 characters), with `find` a key nobody registered (`unknown_source`), the same source, ref and options twice (`duplicate_ref`: the same
147
+ ref under two sources, or with other options, is two lines) and whatever `validateAsks` refuses for a ref, a quantity or the options, at the
148
+ same paths (`asks[2].quantity`), with the same limits.
149
+
150
+ ## The stock port
151
+
152
+ Provided by the `inventory` feature, at most one per project.
153
+
154
+ ```ts
155
+ import { holdLinesFrom, type StockPort } from "@vibedout/commerce";
156
+
157
+ const stock: StockPort = { availability, hold, commit, release };
158
+
159
+ const held = await stock.hold(ctx, {
160
+ holdId: order.id, // the same id again is the same hold
161
+ lines: holdLinesFrom(totals.totals.lines), // the lines that name a stock subject: hold with this, not with the `ref`
162
+ expiresAt: ctx.now + 30 * 60_000, // the units come back by themselves at this time
163
+ });
164
+ if (!held.ok) {
165
+ held.code; // "insufficient_stock" (with `short`: what was asked and what there is), "hold_conflict", "hold_closed" or "invalid_request"
166
+ }
167
+ await stock.commit(ctx, order.id); // the payment succeeded; a late payment still commits and says `late: true` (and `oversold: true` below zero)
168
+ await stock.release(ctx, order.id); // the payment failed or was cancelled; releasing a committed hold is `invalid_state`
169
+ await stock.fulfil?.(ctx, order.id); // optional (0.1.1): the sold units left the shelf; `on hand` and `committed` both go down, once
170
+ await stock.uncommit?.(ctx, order.id); // optional (0.2.1): the sale was undone before they left (a full refund); the units can be bought again
171
+ ```
172
+
173
+ A hold is every line or none, and the last unit goes to exactly one of two buyers. The same `holdId` with the same lines is the same hold: it
174
+ holds nothing twice, and a later `expiresAt` extends it (the date of an OXXO voucher is known only after the payment starts) but never
175
+ shortens it. A `holdId` whose hold is over (released, expired or committed) answers `hold_closed` with its `status`: a new attempt takes a new
176
+ id. A hold lasts at most 14 days (`MAX_HOLD_MS`), so a time in the wrong unit is refused and not kept for years. A subject inventory does not
177
+ know is untracked, always available: that is why a hold is made from the lines' `stock`, never from a `ref`. The clock is `ctx.now`, never the
178
+ database's. **`runStockConformance` is the suite a provider must pass**: run it against your implementation and the in-memory one passes the
179
+ same tests.
180
+
181
+ `fulfil` is optional (0.1.1), so a provider written for 0.1.0 is still one; `OPTIONAL_PORT_METHODS` lists it, and `checkPortProvider` checks
182
+ that it is a function when it is there. It is for a committed hold whose units have left (shipped, handed over, delivered): the units stop
183
+ counting both as sold and as on hand, so what can be bought does not move. It moves a hold once (a second call answers `changed: false`), the
184
+ hold stays `committed` and carries `fulfilledAt`, and a hold that is not committed is `invalid_state` with its status. A caller checks that
185
+ the method is there (`stock.fulfil?.(...)`); a provider that tracks no units on hand leaves it out and runs the suite with
186
+ `{ skipFulfil: true }`.
187
+
188
+ `uncommit` is optional (0.2.1) and is the other way out of `committed`: the sale was undone before the units left the shelf (an order was
189
+ refunded in full and never fulfilled). A committed hold gives its units back to what can be bought, `on hand` does not move, and the hold ends
190
+ as `released`. Once per hold: a second call, and a hold that is already released or expired, answer `changed: false`. A hold that is still `held`
191
+ (never paid; that is `release`) or was fulfilled (its units left) is `invalid_state` with its status and nothing moves. The hold's id stays
192
+ closed to a new hold, and a payment that still arrived for it would commit it again as a late one, so a caller uncommits an order that
193
+ cannot be paid any more. A provider that does not count units leaves it out and runs the suite with `{ skipUncommit: true }`.
194
+
195
+ ## A source with places of its own
196
+
197
+ A thing whose capacity inventory does not count (a booking slot and its seats, a class and its places) is held by its own line source:
198
+ `hold`, `commit` and `release` on `LineSource`, optional and always the three together. They follow the stock port's rules, by the ask
199
+ instead of a stock subject: every ask or none, the same `holdId` is the same hold (a later `expiresAt` extends it, never shortens it), the
200
+ last place goes to one buyer, the clock is `ctx.now`, the places come back by themselves at `expiresAt`, a payment after that still commits
201
+ and says `late` (and `oversold` when the place went to someone else), and a committed hold cannot be released. While a hold lasts, `resolve`
202
+ counts its places as taken. A ref the source does not have is never held (`insufficient_stock`, 0 available).
203
+
204
+ ```ts
205
+ import { holdsCapacity, type LineSource } from "@vibedout/commerce";
206
+
207
+ if (holdsCapacity(source)) {
208
+ const held = await source.hold(ctx, { holdId: order.id, lines: asks, expiresAt: ctx.now + 30 * 60_000 }); // beside the stock port's hold
209
+ if (!held.ok) held.code; // "insufficient_stock" (with `short`: the ref, what was asked and what is left), "hold_conflict", "hold_closed" or "invalid_request"
210
+ }
211
+ // on the payment's success: source.commit(ctx, order.id); on its failure for good, a cancel or an expiry: source.release(ctx, order.id)
212
+ ```
213
+
214
+ A checkout holds such a source's asks with the order's id, beside the stock port's hold of the lines that name a stock subject, and commits or
215
+ releases both. `runLineHoldConformance` is the suite such a source passes, and `createMemorySlots` the in-memory one that passes it.
216
+
217
+ ## The payments port
218
+
219
+ Provided by the `payments` feature, at most one per project.
220
+
221
+ ```ts
222
+ import { computeTotals, paymentRequestFrom } from "@vibedout/commerce";
223
+
224
+ const request = paymentRequestFrom(totals.totals, {
225
+ reference: order.id, // set by the server; the provider's idempotency key derives from it
226
+ successUrl: `${origin}/orders/${order.id}`,
227
+ cancelUrl: `${origin}/cart`,
228
+ payer: { email },
229
+ expiresAt: ctx.now + 30 * 60_000,
230
+ });
231
+ const started = await paymentsPort.start(ctx, request);
232
+ if (started.ok) redirect(started.redirectUrl);
233
+ else started.code; // "invalid_request", "not_configured" or "provider_error" (with `retryable`)
234
+ ```
235
+
236
+ `paymentRequestFrom` turns totals into lines plus `shipping`, `tax` and `discount` adjustments that add up to the total to the centavo. A
237
+ payments port runs `checkStartPayment(input)` first and answers `invalid_request` when it returns problems; a total of zero is
238
+ `free_order`: the order is paid with no provider. The discounts come off the items only: together they may not be more than the lines, even
239
+ when the shipping and the tax would pay for them (a provider shows a discount on the item lines, and no line goes below zero).
240
+
241
+ `cancel` is optional (0.2.0): the order behind a reference will not be paid (staff cancelled it, or its time ran out), so the provider's page
242
+ stops taking money and a voucher stops being payable.
243
+
244
+ ```ts
245
+ const cancelled = await paymentsPort.cancel?.(ctx, order.id);
246
+ if (cancelled && !cancelled.ok) cancelled.code; // "unknown_payment", "already_paid" (refund it instead), "not_cancellable", "not_configured", "provider_error"
247
+ ```
248
+
249
+ It stops a payment once (a second call answers `changed: false`), and starting the reference again is refused. A payment already on its way
250
+ (a payer who pressed "pay" at the same moment, a voucher being paid at the counter) can still arrive as `payment.succeeded`: handle it as
251
+ the late payment it is. Without `cancel` the page stays payable until its `expiresAt`. `runPaymentsConformance` is the suite a payments
252
+ provider passes (`{ skipCancel: true }` for one without `cancel`).
253
+
254
+ ## Tax, shipping rates and discounts
255
+
256
+ Optional ports (`TaxPort.compute`, `ShippingRatesPort.quote`, `DiscountPort.apply`), at most one each. Without them an order adds no tax,
257
+ no shipping and no discount: a line with `taxIncluded: false` and no tax provider is charged no tax, so a shop installs a tax feature or
258
+ prices with tax included. A shipping rate is chosen by `id`: the server quotes again before it charges, so the amount is never the browser's.
259
+ Every amount a provider returns states its currency (`TaxResult.currency`, `AppliedDiscount.currency`, `ShippingRate.currency`), and
260
+ `computeTotals` refuses one that is not the order's. Rates in a tax breakdown are basis points (16% is `1600`). A discount that covers several
261
+ lines is worked out either on the subtotal or line by line, and the two differ by a centavo (three lines of 1.05 at 10% are 0.33 one by one and 0.32 together):
262
+ the provider chooses, and one that works line by line, rounding each line half-up (`pricing` does), says which lines it came off in `AppliedDiscount.lines`
263
+ (below), so a caller that spreads an order's discount itself (`allocateMinor`) uses those amounts when they are there.
264
+
265
+ **What a discount did to each line (0.2.2).** A tax is worked on what each line costs after its discount, and an invoice states a discount per
266
+ line, but the discount port answers amounts for the order. A discount may say which lines it came off (`AppliedDiscount.lines`, the position in
267
+ `DiscountInput.lines` and the amount, adding up to the discount's amount); `lineDiscountsFrom(lines, applied)` adds them up per line, or answers
268
+ `undefined` when a discount does not say (or says something `checkLineDiscounts` refuses), and then `spreadDiscount(lines, discountMinor)` spreads the
269
+ total in proportion to the line totals. The result goes to the tax port as `TaxInput.lineDiscountsMinor`; a tax provider that does not read it spreads
270
+ `discountMinor` itself. This matters when the lines are taxed at different rates and a discount applies to one of them.
271
+
272
+ ```ts
273
+ import { lineDiscountsFrom, spreadDiscount } from "@vibedout/commerce";
274
+
275
+ const perLine = lineDiscountsFrom(lines, applied) ?? spreadDiscount(lines, discountMinor); // [400, 50]
276
+ await tax.compute(ctx, { currency, lines, discountMinor, lineDiscountsMinor: perLine, shippingMinor, destination });
277
+ ```
278
+
279
+ **What a source writes for the features that price and tax its lines (0.2.2).** The snapshot is free-form, but three keys are read by other
280
+ features: `sku` (a discount rule names a SKU), `productId` (a rule covers every variant of a product) and `taxClass` (`standard`, `zero`, `exempt`:
281
+ a tax provider maps it to a rate; not the SAT product key, which is `ResolvedLine.taxCode`). A source that sells goods writes them; a reader uses
282
+ `snapshotText(line, "sku")`, which answers nothing for a missing or malformed value, and falls back to its default.
283
+
284
+ A title is checked by `isDisplayText(value, max)` (0.2.2): one to `max` characters, no space at either end, no control character, line break or invisible override.
285
+ `checkResolvedLine` and `computeTotals` refuse a line or a discount whose title fails it, so a feature that lets a person type such a title validates with it
286
+ when it is typed, and the cart never refuses what an admin accepted.
287
+
288
+ A provider that applies a rate does it in whole numbers, with the helpers and not `amount * 0.16`:
289
+
290
+ ```ts
291
+ import { basisPointsOf, subtractMinor, taxIncludedIn } from "@vibedout/commerce";
292
+
293
+ basisPointsOf(10_000, 1600); // 1600: 16% of 100.00
294
+ basisPointsOf(10_050, 100); // 101: 1% of 100.50 is 1.005, half up (`(1.005).toFixed(2)` says "1.00")
295
+ taxIncludedIn(11_600, 1600); // 1600: the IVA already inside a shelf price of 116.00
296
+ subtractMinor(11_600, 1600); // 10_000: the net price
297
+ basisPointsOf(5, 5000, "down"); // 2: `half-up` is the default; `down` and `up` are for a rule that says so
298
+ ```
299
+
300
+ The product is computed with BigInt, so the largest amount a number holds is exact; a rate is a whole number of basis points from 0 to 100000
301
+ (`MAX_RATE_BP`: a discount is at most 10000, a tax can be more, as IEPS on tobacco at 160%), and a result a number cannot hold is refused.
302
+
303
+ An invoice that states each line's discount (a CFDI does) splits the order's discount over the lines with `allocateMinor`, the line totals
304
+ as the weights:
305
+
306
+ ```ts
307
+ import { allocateMinor } from "@vibedout/commerce";
308
+
309
+ allocateMinor(1000, [3000, 1000]); // [750, 250]: 10.00 off a 30.00 and a 10.00 line
310
+ allocateMinor(100, [1, 1, 1]); // [34, 33, 33]: the centavo left goes to the largest fraction, the earlier line on a tie
311
+ ```
312
+
313
+ The shares add up to the total exactly; no share is more than its line when the discount is not more than the lines, and a line of 0 gets 0.
314
+ `@vibedout/payments` spreads a discount over a provider's page by the same rule, so the invoice and the page agree line by line.
315
+
316
+ ## Providers in a project
317
+
318
+ `PortProviders` maps each port name to its interface, so a feature types its export with it:
319
+
320
+ ```ts
321
+ import type { PortProviders } from "@vibedout/commerce";
322
+
323
+ export const inventoryStock: PortProviders["stock"] = { availability, hold, commit, release };
324
+ ```
325
+
326
+ `checkPortProvider("stock", inventoryStock)` lists the methods an export lacks (for a provider assembled by a factory, or written in plain
327
+ JavaScript), an optional one that is not a function, and the rest of a set it has only part of (`hold` without `commit` and `release`);
328
+ `PORT_METHODS` and `OPTIONAL_PORT_METHODS` are those lists as data. In a project that lists this package, the generated `registerPorts` call
329
+ checks each provider against its port with `satisfies PortProviders["stock"]`, so a mistyped provider fails `pnpm typecheck`.
330
+
331
+ ## Testing
332
+
333
+ ```ts
334
+ import { describe, expect, it } from "vitest";
335
+ import { runLineSourceConformance } from "@vibedout/commerce/testing";
336
+ import { variantLines } from "./line-source";
337
+
338
+ runLineSourceConformance(
339
+ variantLines,
340
+ {
341
+ ctx, // a PortContext with the project's env.DB when the source reads D1; the default has a database that throws
342
+ known: [{ ref: "var_1", unitAmountMinor: 4900, currency: "MXN" }],
343
+ unavailable: [{ ref: "var_9", availability: "not_orderable" }],
344
+ },
345
+ { describe, it, expect },
346
+ );
347
+ ```
348
+
349
+ A source whose buyer chooses the amount inside bounds the server sets (a donation, a tip) says so with `buyerPrice: { ref, option, min, max }`.
350
+ The harness then checks that the bounds hold and that only a whole number of minor units inside them is priced (`12.5`, `1e3`, a padded or
351
+ signed number and one outside the bounds are refused), that the line is one unit (`maxQuantity: 1`), and still refuses a price in every other
352
+ option name it tries. It tries `price`, `amount`, `unitAmountMinor`, `unit_price`, `total`, `currency` and `discount`; a source that reads
353
+ another option name for something else (`pledge`) lists it in `extraPriceOptions`, so the harness tries it as a price too.
354
+
355
+ One test for each thing a checkout relies on: no asks, no lines; a valid, available line at the right price; lines in the order asked
356
+ whatever else is in the batch; a ref that does not exist and hostile refs (`__proto__`, `constructor`, `' OR 1=1 --`) answered `not_found`
357
+ with no exception; sold out items reported as such; a price sent in `options` ignored; read-only asks accepted; the same ask giving the same
358
+ line; two asks for one ref staying two lines. `checkLineSource(source, fixtures)` returns the findings without a test runner.
359
+
360
+ | Helper | What |
361
+ |---|---|
362
+ | `runStockConformance(make, tools, options?)` | the suite a stock provider passes; `make(seed)` returns a provider holding that seed (`{ "type:id": { tracking: "count", onHand: 3 } }`); `{ skipFulfil: true }` and `{ skipUncommit: true }` leave out the cases of the optional `fulfil` and `uncommit` |
363
+ | `createMemoryStock(seed)` | a stock in memory with the same semantics, `fulfil` and `uncommit` included, for testing a checkout; `counts()` shows what it holds |
364
+ | `runLineHoldConformance(make, tools, options?)` | the suite a line source that holds its own places passes; `make(places)` returns one with those places (`{ slot_a: 3 }`) |
365
+ | `createMemorySlots(places, { unitAmountMinor? })` | a line source with places of its own, in memory (its holds are the in-memory stock's), for testing a checkout |
366
+ | `runPaymentsConformance(make, tools, options?)` | the suite a payments provider passes: it checks what it charges (the total, a discount over the items, a free order), one payment per reference, and `cancel`; `{ skipCancel: true }` leaves the cancel cases out, `referencePrefix` keeps references apart in a shared database |
367
+ | `createFakePayments()` | a payments port that runs `checkStartPayment`, is idempotent by `reference`, records what it started and cancels once; `failNext(...)` makes the next call fail, `markPaid(reference)` stands in for the provider's event |
368
+ | `runTaxConformance(make, tools, options?)` | the suite a tax provider passes, whatever the country: the answer is in the order's currency and whole minor units, a breakdown adds up so the totals take it, a price that includes the tax adds nothing on top, a discount never raises the tax, a free line carries none, the same input gives the same answer and is left as it was; the rates are the provider's own golden files |
369
+ | `createFlatTax({ rateBp?, code? })` | one rate for everything, worked per line on what is paid (the discount of each line when `lineDiscountsMinor` is given), shipping taxed on top |
370
+ | `runDiscountConformance(make, tools, { scenarios, plain? })` | the suite a discount provider passes: you give it the orders its rules apply to; amounts are whole, titled, in the order's currency and at most the subtotal, the lines a discount names are real and add up, every typed code is answered once with a reason from the list, the same input gives the same answer |
371
+ | `createFlatDiscount({ percentBp?, fromQuantity?, title? })` | a percent off every line of at least a quantity, with the lines named; it knows no code |
372
+ | `testContext(overrides)` | a `PortContext` with a fixed clock (`TEST_NOW`) and a `db` whose every method throws |
373
+
374
+ ## Problem codes
375
+
376
+ `PROBLEM_CODES` lists them. By where they come from:
377
+
378
+ | From | Codes |
379
+ |---|---|
380
+ | the buyer's asks | `invalid_asks`, `invalid_ref`, `invalid_quantity`, `invalid_options`, `duplicate_ref` |
381
+ | a source's answer | `invalid_lines`, `line_count_mismatch`, `line_ref_mismatch`, `line_quantity_mismatch`, `invalid_line`, `invalid_title`, `invalid_price`, `invalid_currency`, `invalid_availability`, `invalid_max_quantity`, `invalid_fulfilment`, `invalid_stock_subject`, `invalid_snapshot` |
382
+ | a line that cannot be bought now | `sold_out`, `not_orderable`, `not_found`, `over_max_quantity` |
383
+ | totals | `empty_order`, `currency_mismatch`, `out_of_range`, `invalid_adjustment`, `discount_exceeds_subtotal`, `tax_breakdown_mismatch` |
384
+ | a payment request | `invalid_payment`, `payment_not_reconciled`, `free_order` |
385
+ | a port provider | `invalid_port` |
386
+
387
+ ## Changelog
388
+
389
+ - 0.2.3: `validateCartAsks`, the check a cart's `complete` runs on its lines before any price is asked and any place is held (`{ source, ref,
390
+ quantity, options? }` per line: the source is a key, with `find` a registered one, the same source, ref and options twice are refused, the rest
391
+ as `validateAsks`), with the problem codes `invalid_source` and `unknown_source`. Additive: `validateAsks` and everything else are as they were.
392
+ - 0.2.2: what a discount did to each line (`AppliedDiscount.lines`, `TaxInput.lineDiscountsMinor`, `lineDiscountsFrom`, `spreadDiscount`,
393
+ `checkLineDiscounts`), the snapshot keys other features read (`SNAPSHOT_KEYS`, `snapshotText`), and the suites for the two optional ports that had
394
+ none (`runTaxConformance`, `runDiscountConformance`, with `createFlatTax` and `createFlatDiscount`), and `isDisplayText`, the check a title passes. Additive: a provider and a caller written for 0.2.1
395
+ work unchanged, and a tax provider that ignores `lineDiscountsMinor` is still one.
396
+ - 0.2.1: the stock port's optional `uncommit` (the sale was undone before the units left the shelf: a committed, unfulfilled hold gives its units back
397
+ and ends as `released`, once), with eight conformance cases (`skipUncommit` leaves them out), `OPTIONAL_PORT_METHODS` and the in-memory stock's own
398
+ `uncommit`. Additive: a provider and a caller written for 0.2.0 work unchanged, but a feature whose stock provider has `uncommit` needs this
399
+ version's types to compile.
400
+ - 0.2.0: a line source may hold its own places (`hold`, `commit`, `release`, optional and together; `HoldingLineSource`, `holdsCapacity`,
401
+ `runLineHoldConformance`, `createMemorySlots`, the harness's `holds` check); `resolveCart` prices a cart across sources; the payments port's
402
+ optional `cancel` (`CancelPaymentResult`, the fake's `cancel` and `markPaid`) and `runPaymentsConformance`; `allocateMinor` splits an amount
403
+ over lines; `checkStartPayment` refuses discounts larger than the items even when the shipping and the tax would cover them (before, only
404
+ larger than the whole charge; the message starts with the words the payments feature's own check uses); `Hold`, `HoldResult`,
405
+ `SettleResult`, `BlockedLine` and `partitionLines` take the line type as a parameter (the default is what it was). Additive for a provider or
406
+ a caller written for 0.1.x, with two exceptions: a payment request whose discounts pass its items is now refused, and a line source that has
407
+ some of `hold`, `commit` and `release` but not all three is now reported by `checkPortProvider` and the harness.
408
+ - 0.1.1: the stock port's optional `fulfil` (a committed hold's units leave the shelf: on hand and committed both go down, once), with
409
+ `Hold.fulfilledAt`, `OPTIONAL_PORT_METHODS`, five conformance cases (`skipFulfil` leaves them out) and the in-memory stock's own `fulfil`.
410
+ Additive: a provider and a caller written for 0.1.0 work unchanged.
411
+ - 0.1.0: first release.
@@ -0,0 +1,33 @@
1
+ import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
2
+ import { U as ResolvedLine, r as AppliedDiscount } from "./types-BzlVbRp1.mjs";
3
+ //#region src/discounts.d.ts
4
+ /** What a discount needs of a line: its price and quantity. A `ResolvedLine`, a `PricedLine` and a cart line all fit. */
5
+ type DiscountableLine = Pick<ResolvedLine, "unitAmountMinor" | "quantity">;
6
+ /**
7
+ * `discountMinor` spread over the lines in proportion to their totals, in whole minor units that add up to it exactly (`allocateMinor`): what a
8
+ * tax provider does when the discounts do not say which lines they came off. No line gives more than its total. Throws {@link CommerceError}
9
+ * (`discount_exceeds_subtotal`) when the discount is more than the lines cost together.
10
+ *
11
+ * ```ts
12
+ * spreadDiscount([{ unitAmountMinor: 3000, quantity: 1 }, { unitAmountMinor: 1000, quantity: 1 }], 1000); // [750, 250]
13
+ * ```
14
+ */
15
+ declare function spreadDiscount(lines: readonly DiscountableLine[], discountMinor: number): number[];
16
+ /**
17
+ * The problems with the attributions of `discounts` over `lines`: every discount that takes something off says which lines it came off, each
18
+ * `index` is a line, the amounts are whole and add up to the discount's `amountMinor`, and no line is discounted by more than it costs. A
19
+ * discount of 0 needs nothing. An empty list means {@link lineDiscountsFrom} can answer.
20
+ */
21
+ declare function checkLineDiscounts(lines: readonly DiscountableLine[], discounts: readonly AppliedDiscount[], path?: string): CommerceProblem[];
22
+ /**
23
+ * What the discounts took off each line, in the order of `lines`, or `undefined` when they do not say (a discount of the whole order, or
24
+ * attributions that {@link checkLineDiscounts} refuses): then spread the total with {@link spreadDiscount}. Pass the result as
25
+ * `TaxInput.lineDiscountsMinor`.
26
+ *
27
+ * ```ts
28
+ * const perLine = lineDiscountsFrom(lines, applied) ?? spreadDiscount(lines, discountMinor);
29
+ * ```
30
+ */
31
+ declare function lineDiscountsFrom(lines: readonly DiscountableLine[], discounts: readonly AppliedDiscount[]): number[] | undefined;
32
+ //#endregion
33
+ export { DiscountableLine, checkLineDiscounts, lineDiscountsFrom, spreadDiscount };
@@ -0,0 +1,97 @@
1
+ import { CommerceError, problem } from "./errors.mjs";
2
+ import { i as isRecord } from "./internal-p2FS_A4L.mjs";
3
+ import { o as isMinorUnits, r as allocateMinor, s as lineTotalMinor } from "./money-aFFtGd0E.mjs";
4
+ //#region src/discounts.ts
5
+ /**
6
+ * What a discount did to each line (0.2.2). A discount port answers amounts for the order; a tax is worked on what each line costs after its
7
+ * discount, and an invoice (a CFDI) states a discount per line. These are the two ways to know that amount: the discount says which lines it
8
+ * came off ({@link AppliedDiscount.lines}), or it is spread over the lines in proportion to their totals.
9
+ */
10
+ /**
11
+ * `discountMinor` spread over the lines in proportion to their totals, in whole minor units that add up to it exactly (`allocateMinor`): what a
12
+ * tax provider does when the discounts do not say which lines they came off. No line gives more than its total. Throws {@link CommerceError}
13
+ * (`discount_exceeds_subtotal`) when the discount is more than the lines cost together.
14
+ *
15
+ * ```ts
16
+ * spreadDiscount([{ unitAmountMinor: 3000, quantity: 1 }, { unitAmountMinor: 1000, quantity: 1 }], 1000); // [750, 250]
17
+ * ```
18
+ */
19
+ function spreadDiscount(lines, discountMinor) {
20
+ const totals = lines.map((line) => lineTotalMinor(line.unitAmountMinor, line.quantity));
21
+ if (!isMinorUnits(discountMinor)) throw new CommerceError("out_of_range", "a discount is a whole number of minor units, zero or more", "discountMinor");
22
+ if (discountMinor === 0) return totals.map(() => 0);
23
+ if (totals.length === 0) throw new CommerceError("discount_exceeds_subtotal", "there are no lines to take a discount from", "lines");
24
+ const sum = totals.reduce((total, amount) => total + amount, 0);
25
+ if (discountMinor > sum) throw new CommerceError("discount_exceeds_subtotal", `a discount of ${discountMinor} is more than the lines cost (${sum})`, "discountMinor");
26
+ return allocateMinor(discountMinor, totals);
27
+ }
28
+ /**
29
+ * The problems with the attributions of `discounts` over `lines`: every discount that takes something off says which lines it came off, each
30
+ * `index` is a line, the amounts are whole and add up to the discount's `amountMinor`, and no line is discounted by more than it costs. A
31
+ * discount of 0 needs nothing. An empty list means {@link lineDiscountsFrom} can answer.
32
+ */
33
+ function checkLineDiscounts(lines, discounts, path = "discounts") {
34
+ const out = [];
35
+ const taken = lines.map(() => 0);
36
+ for (const [at, discount] of discounts.entries()) {
37
+ const where = `${path}[${at}]`;
38
+ if (!isRecord(discount) || !isMinorUnits(discount.amountMinor)) {
39
+ out.push(problem("invalid_adjustment", where, "a discount has a whole amount of minor units, zero or more"));
40
+ continue;
41
+ }
42
+ if (discount.amountMinor === 0) continue;
43
+ if (!Array.isArray(discount.lines)) {
44
+ out.push(problem("invalid_adjustment", `${where}.lines`, "a discount that takes something off says which lines it came off"));
45
+ continue;
46
+ }
47
+ let sum = 0;
48
+ const seen = /* @__PURE__ */ new Set();
49
+ const parts = discount.lines;
50
+ for (const [position, part] of parts.entries()) {
51
+ const partPath = `${where}.lines[${position}]`;
52
+ const index = isRecord(part) ? part.index : void 0;
53
+ const amountMinor = isRecord(part) ? part.amountMinor : void 0;
54
+ if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0 || index >= lines.length) {
55
+ out.push(problem("invalid_adjustment", partPath, "index is the position of a line"));
56
+ continue;
57
+ }
58
+ if (!isMinorUnits(amountMinor)) {
59
+ out.push(problem("invalid_adjustment", partPath, "amountMinor is a whole number of minor units, zero or more"));
60
+ continue;
61
+ }
62
+ if (seen.has(index)) {
63
+ out.push(problem("invalid_adjustment", partPath, "a line appears once in a discount"));
64
+ continue;
65
+ }
66
+ seen.add(index);
67
+ sum += amountMinor;
68
+ taken[index] = taken[index] + amountMinor;
69
+ }
70
+ if (sum !== discount.amountMinor) out.push(problem("invalid_adjustment", `${where}.lines`, `the lines add up to ${sum}, the discount is ${discount.amountMinor}`));
71
+ }
72
+ for (const [index, line] of lines.entries()) if (isMinorUnits(line.unitAmountMinor) && Number.isSafeInteger(line.quantity) && line.quantity >= 1) {
73
+ const total = lineTotalMinor(line.unitAmountMinor, line.quantity);
74
+ if (taken[index] > total) out.push(problem("discount_exceeds_subtotal", `${path}.lines[${index}]`, `line ${index} is discounted by ${taken[index]}, it costs ${total}`));
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * What the discounts took off each line, in the order of `lines`, or `undefined` when they do not say (a discount of the whole order, or
80
+ * attributions that {@link checkLineDiscounts} refuses): then spread the total with {@link spreadDiscount}. Pass the result as
81
+ * `TaxInput.lineDiscountsMinor`.
82
+ *
83
+ * ```ts
84
+ * const perLine = lineDiscountsFrom(lines, applied) ?? spreadDiscount(lines, discountMinor);
85
+ * ```
86
+ */
87
+ function lineDiscountsFrom(lines, discounts) {
88
+ if (checkLineDiscounts(lines, discounts).length > 0) return void 0;
89
+ const taken = lines.map(() => 0);
90
+ for (const discount of discounts) {
91
+ if (discount.amountMinor === 0) continue;
92
+ for (const part of discount.lines ?? []) taken[part.index] = taken[part.index] + part.amountMinor;
93
+ }
94
+ return taken;
95
+ }
96
+ //#endregion
97
+ export { checkLineDiscounts, lineDiscountsFrom, spreadDiscount };
@@ -0,0 +1,24 @@
1
+ //#region src/errors.d.ts
2
+ /**
3
+ * What can be wrong, as data. A check of untrusted input (a buyer's asks, the lines a source returns, a payment request) returns a list of
4
+ * {@link CommerceProblem} and never throws; a route maps `code` to its own error. Only the low-level helpers of `./money` throw, with a
5
+ * {@link CommerceError} that carries the same codes.
6
+ */
7
+ declare const PROBLEM_CODES: readonly ["invalid_asks", "invalid_ref", "invalid_quantity", "invalid_options", "duplicate_ref", "invalid_source", "unknown_source", "invalid_lines", "line_count_mismatch", "line_ref_mismatch", "line_quantity_mismatch", "invalid_line", "invalid_title", "invalid_price", "invalid_currency", "invalid_availability", "invalid_max_quantity", "invalid_fulfilment", "invalid_stock_subject", "invalid_snapshot", "sold_out", "not_orderable", "not_found", "over_max_quantity", "empty_order", "currency_mismatch", "out_of_range", "invalid_adjustment", "discount_exceeds_subtotal", "tax_breakdown_mismatch", "invalid_payment", "payment_not_reconciled", "free_order", "invalid_port"];
8
+ type ProblemCode = (typeof PROBLEM_CODES)[number];
9
+ interface CommerceProblem {
10
+ code: ProblemCode;
11
+ /** Where, in the shape of the input: `asks[1].quantity`, `lines[0].unitAmountMinor`, `total`. */
12
+ path: string;
13
+ /** For a log or a developer, in English. A person-facing text is the caller's, chosen by `code`. */
14
+ message: string;
15
+ }
16
+ /** Thrown by the low-level money helpers and by `assertTotals`. `code` is the first problem's; `problems` has at least one entry. */
17
+ declare class CommerceError extends Error {
18
+ readonly code: ProblemCode;
19
+ readonly problems: readonly CommerceProblem[];
20
+ constructor(code: ProblemCode, message: string, path?: string, problems?: readonly CommerceProblem[]);
21
+ }
22
+ declare const problem: (code: ProblemCode, path: string, message: string) => CommerceProblem;
23
+ //#endregion
24
+ export { problem as a, ProblemCode as i, CommerceProblem as n, PROBLEM_CODES as r, CommerceError as t };
@@ -0,0 +1,2 @@
1
+ import { a as problem, i as ProblemCode, n as CommerceProblem, r as PROBLEM_CODES, t as CommerceError } from "./errors-DGzIIflf.mjs";
2
+ export { CommerceError, CommerceProblem, PROBLEM_CODES, ProblemCode, problem };