mollie-ecommerce 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -9,6 +9,25 @@ Entries from 0.5.0 onwards are generated by
9
9
  unreleased changes live as files in [`.changeset/`](.changeset) until then.
10
10
  Earlier entries were written by hand.
11
11
 
12
+ ## 0.6.0
13
+
14
+ ### Minor Changes
15
+
16
+ - db922a7: Add `createWebhookHandler` on a new `mollie-ecommerce/http` entry point: a
17
+ `Request → Response` handler for the Mollie webhook, holding the logic every
18
+ consumer would otherwise write identically.
19
+
20
+ It parses Mollie's form body, maps failures to the status code Mollie acts on
21
+ (2xx for a payment that is not yours, 503 for a Mollie or storage outage so the
22
+ redelivery recovers it, 500 for a bug), and retries the `version_conflict` a
23
+ concurrent duplicate delivery causes. Your side plugs in through `onOrder` —
24
+ enqueue only, Mollie is waiting — and `onError`, which receives the chosen
25
+ status so alerting can tell "Mollie will retry" from "this is on the floor".
26
+
27
+ `commerce.handleWebhook(paymentId)` is unchanged and remains the operation; the
28
+ handler is a convenience over it, on its own entry so the main one stays free of
29
+ HTTP. `req`/`res` frameworks keep calling `handleWebhook` directly.
30
+
12
31
  ## 0.5.0
13
32
 
14
33
  ### Minor Changes
package/README.md CHANGED
@@ -20,14 +20,15 @@ bun add mollie-ecommerce
20
20
  Ships as ESM with type declarations, so Node 20+, Bun, Deno and bundlers all
21
21
  work. Two runtime dependencies: the Mollie SDK and [zod](https://zod.dev).
22
22
 
23
- One exception: `mollie-ecommerce/conformance` — the contract test suite for
24
- your storage — builds on `bun:test`, so that entry point needs
25
- [Bun](https://bun.com).
23
+ Two more entry points, both optional: `mollie-ecommerce/http` for the Mollie
24
+ webhook handler, and `mollie-ecommerce/conformance` for the contract test suite
25
+ for your storage. The conformance entry builds on `bun:test`, so it needs
26
+ [Bun](https://bun.com); the main and `http` entries do not.
26
27
 
27
28
  ```bash
28
29
  # Working on the library itself
29
30
  bun install
30
- bun run check # typecheck + 161 tests
31
+ bun run check # typecheck + 172 tests
31
32
  bun run build # bundle dist/ with tsdown
32
33
  bun run example # the full lifecycle, no Mollie account needed
33
34
  ```
@@ -194,6 +195,11 @@ order up by payment id, then does the same work as `syncOrder`. Returns `null`
194
195
  for a payment this shop doesn't know — respond 2xx anyway, or Mollie retries
195
196
  forever. The state always comes from the API, never from the request body.
196
197
 
198
+ This is the commerce operation; `createWebhookHandler` from
199
+ `mollie-ecommerce/http` turns it into an HTTP handler with the status codes and
200
+ the duplicate-delivery retry already handled. See
201
+ [the webhook](#2-the-mollie-webhook).
202
+
197
203
  ### `refund(input) → { order, refund }`
198
204
 
199
205
  Refunds a paid order: in full, by amount, or by line. Moves money only — see
@@ -359,27 +365,63 @@ function customerMessage(error: CommerceError): string {
359
365
 
360
366
  Mollie posts `id=tr_...` form-encoded and nothing else — no status, so the
361
367
  state has to come from the API, which is exactly what `handleWebhook` does.
362
- Two rules matter: **always answer 2xx** (any other status makes Mollie retry,
363
- forever for a payment you will never recognize), and **answer fast** — do your
364
- own slow work (emails, invoices, ERP calls) after responding.
368
+ `mollie-ecommerce/http` wraps it in a `Request → Response` handler that carries
369
+ the parts every integration has to get right the same way:
365
370
 
366
371
  ```ts
372
+ import { createWebhookHandler } from "mollie-ecommerce/http";
367
373
  import { commerce } from "./shop.ts";
374
+ import { queue } from "./queue.ts";
375
+
376
+ export const postMollieWebhook = createWebhookHandler({
377
+ commerce,
378
+ // Enqueue only — Mollie is waiting. Emails, invoices and ERP calls happen
379
+ // after the response, in your worker.
380
+ onOrder: (order) => {
381
+ if (order.payment.state === "paid") {
382
+ queue.push({ type: "order.paid", orderId: order.id });
383
+ }
384
+ },
385
+ onError: (error, { paymentId, status }) =>
386
+ log.error({ err: error, paymentId, willRetry: status >= 500 }, "mollie webhook"),
387
+ });
388
+ ```
368
389
 
369
- export async function postMollieWebhook(request: Request): Promise<Response> {
370
- const form = await request.formData();
371
- const paymentId = String(form.get("id") ?? "");
372
- if (!paymentId) return new Response("missing id", { status: 400 });
390
+ It is a plain `(Request) => Promise<Response>`, so routing stays yours:
373
391
 
374
- const order = await commerce.handleWebhook(paymentId);
375
- if (!order) return new Response("ok"); // not ours — still 200
376
- if (order.payment.state === "paid" && order.delivery.state === "ready") {
377
- queue.push({ type: "order.paid", orderId: order.id }); // email, invoice, picking slip
378
- }
379
- return new Response("ok");
380
- }
392
+ ```ts
393
+ Bun.serve({ routes: { "/mollie-webhook": { POST: postMollieWebhook } } });
394
+ app.post("/mollie-webhook", (c) => postMollieWebhook(c.req.raw)); // Hono
395
+ export { postMollieWebhook as POST }; // Next.js route file
381
396
  ```
382
397
 
398
+ For a `req`/`res` framework like Express, skip the handler and call
399
+ `commerce.handleWebhook(paymentId)` directly — it stays the operation, and the
400
+ handler is only a convenience over it.
401
+
402
+ **What the handler decides for you.** To Mollie, 2xx means "absorbed, stop" and
403
+ anything else means "retry", so the status is not a formality — it is how a
404
+ payment either converges or gets silently dropped:
405
+
406
+ | Situation | Answer | Why |
407
+ | --- | --- | --- |
408
+ | no `id` in the body | 200 | not Mollie, or a probe; retrying conjures nothing |
409
+ | payment unknown here | 200 | a non-2xx retries forever for an order that will never exist |
410
+ | `order_not_found` | 200 | the order vanished mid-flight; no state left to converge on |
411
+ | `gateway_error`, `storage_error` | 503 | transient — the redelivery *is* the recovery |
412
+ | `version_conflict` | retried, then 503 | a duplicate delivery won the write race |
413
+ | `storage_contract`, anything else | 500 | a bug; Mollie's remaining retries recover it once fixed |
414
+
415
+ That last distinction is the one worth internalising even if you route the
416
+ webhook yourself: answering 2xx to a database blip tells Mollie the payment was
417
+ handled and you will never hear about it again.
418
+
419
+ Two more Mollie facts the handler folds in. **There is nothing to verify** —
420
+ Mollie does not sign webhooks, which is why the state is re-read from the API
421
+ instead; a forged body cannot move an order. And **refunds and chargebacks
422
+ arrive on this same route**, carrying the same payment id, so one route is all
423
+ you need.
424
+
383
425
  Mollie cannot reach `localhost`, so tunnel the URL in development
384
426
  (`bunx untun tunnel http://localhost:3000`, ngrok, or Cloudflare Tunnel) and
385
427
  pass it as `webhookUrl`.
@@ -1109,14 +1151,20 @@ how you find out whether yours still conforms.
1109
1151
  - **Digital orders self-deliver.** No physical lines means `delivered` on payment. Disable with `autoDeliverDigital: false`.
1110
1152
  - **Refund state is optimistic.** Mollie returns `pending`; `syncOrder` reconciles `amountRefunded` from the payment.
1111
1153
  - **The webhook URL must be publicly reachable.** Mollie cannot call `localhost`; tunnel it in development.
1154
+ - **Webhook status codes are load-bearing.** 2xx means "absorbed", anything else means "retry", so a transient failure answered 2xx drops the payment. `createWebhookHandler` maps them for you.
1112
1155
  - **No expiry sweeper.** Mollie expires unpaid payments and calls your webhook. To free stock sooner, run `findOrders({ paymentState: ["created", "open", "pending"] })` on a schedule and call `syncOrder`.
1113
1156
 
1114
1157
  ## Contributing
1115
1158
 
1116
- Bug reports, storage adapters, and documentation fixes are all welcome — see
1117
- [CONTRIBUTING.md](CONTRIBUTING.md). Security issues go through
1159
+ Bug reports, storage implementations, and documentation fixes are all welcome —
1160
+ see [CONTRIBUTING.md](CONTRIBUTING.md). Security issues go through
1118
1161
  [SECURITY.md](SECURITY.md), not the public tracker.
1119
1162
 
1163
+ Releases are automated: every change carries a
1164
+ [changeset](https://github.com/changesets/changesets), those collect into a
1165
+ `release: version packages` PR, and merging it publishes to npm — but only once
1166
+ CI is green on `main`.
1167
+
1120
1168
  ## License
1121
1169
 
1122
1170
  [MIT](LICENSE)
@@ -0,0 +1,381 @@
1
+ import { A as Money, S as OrderLine, T as Sku, d as OrderQuery, f as PaymentGateway, g as StockShortage, o as Clock, t as CommerceStorage, u as IdGenerator, w as Refund, x as Order, y as Customer } from "./storage-handlers-Ce9E1a0L.js";
2
+ type CommerceErrorCode = "sku_not_found" | "sku_inactive" | "out_of_stock" | "order_not_found" | "empty_cart" | "currency_mismatch" | "not_payable" | "not_refundable" | "refund_too_large" | "invalid_delivery_transition" | "gateway_error" |
3
+ /** Someone else updated the order first; reload and retry. */
4
+ "version_conflict" | "duplicate_order" | "storage_error" |
5
+ /** A storage handler broke its contract — see `checkedStorage`. */
6
+ "storage_contract";
7
+ declare class CommerceError extends Error {
8
+ readonly code: CommerceErrorCode;
9
+ readonly details?: Record<string, unknown> | undefined;
10
+ constructor(code: CommerceErrorCode, message: string, details?: Record<string, unknown> | undefined,
11
+ /** Standard `Error` options, so `{ cause }` survives for your logs. */
12
+ options?: ErrorOptions);
13
+ }
14
+ /**
15
+ * Errors a storage handler is expected to raise, pre-shaped so your handlers
16
+ * speak the same vocabulary the rest of the library does — and so your
17
+ * application's error mapping only ever has to know `CommerceError.code`.
18
+ *
19
+ * ```ts
20
+ * async mutateOrder(orderId, apply) {
21
+ * const order = await db.loadOrder(orderId);
22
+ * if (!order) throw storageError.orderNotFound(orderId);
23
+ * // …
24
+ * if (!written) throw storageError.versionConflict(orderId, order.version);
25
+ * }
26
+ * ```
27
+ *
28
+ * Throwing anything else is fine too: it travels out through whichever
29
+ * `commerce.*` method was running, and `onStorageError` can translate it.
30
+ */
31
+ declare const storageError: {
32
+ /**
33
+ * Not enough stock. `createOrder` may either return
34
+ * `{ status: "out_of_stock", shortages }` or throw this — whichever suits
35
+ * your implementation, since aborting a transaction usually means throwing.
36
+ *
37
+ * @param shortages What fell short, as `reserveStock` reported it.
38
+ */
39
+ outOfStock(shortages: StockShortage[]): CommerceError;
40
+ /**
41
+ * The order a handler was asked for does not exist. `mutateOrder` must throw
42
+ * this; the read handlers return `null` instead.
43
+ *
44
+ * @param orderId The id that was not found.
45
+ */
46
+ orderNotFound(orderId: string): CommerceError;
47
+ /**
48
+ * Someone else wrote the order first, so this write was refused rather than
49
+ * allowed to overwrite. Callers are expected to re-read and retry.
50
+ *
51
+ * @param orderId The contested order.
52
+ * @param expected The version the write was based on.
53
+ * @param actual The version found in storage, if you know it.
54
+ */
55
+ versionConflict(orderId: string, expected?: number, actual?: number): CommerceError;
56
+ /**
57
+ * An order already exists with that id, payment id, or idempotency key —
58
+ * typically a unique-index violation from `createOrder`.
59
+ *
60
+ * @param orderId The order being inserted.
61
+ * @param cause The driver error, kept for your logs.
62
+ */
63
+ duplicateOrder(orderId: string, cause?: unknown): CommerceError;
64
+ /**
65
+ * Storage failed for a reason the library has no opinion about: a dropped
66
+ * connection, a timeout, a disk error.
67
+ *
68
+ * @param message What went wrong, for your logs.
69
+ * @param cause The underlying error.
70
+ */
71
+ failed(message: string, cause?: unknown): CommerceError;
72
+ };
73
+ /** Which handler failed, and what it was working on. */
74
+ type StorageErrorContext = {
75
+ handler: keyof CommerceStorage;
76
+ /** Present when the call targeted one order. */
77
+ orderId?: string;
78
+ /** Present for `loadSkus`. */
79
+ skuIds?: string[];
80
+ /** Present for the lookups that take a key. */
81
+ key?: string;
82
+ };
83
+ /**
84
+ * Handles an error thrown inside a storage handler.
85
+ *
86
+ * - Return a {@link CommerceError} to **translate** it — a driver's unique
87
+ * violation into `duplicate_order`, a deadlock into `version_conflict` — and
88
+ * that is what callers of `commerce.*` will see.
89
+ * - Return nothing to **observe only**: log it, count it, and let the original
90
+ * error through untouched.
91
+ * - Throw to replace it with anything you like.
92
+ *
93
+ * It never swallows: there is no way to make a failed write look like a
94
+ * success, because the commerce layer would then persist nothing and carry on.
95
+ */
96
+ type StorageErrorHandler = (error: unknown, context: StorageErrorContext) => CommerceError | void | Promise<CommerceError | void>;
97
+ type CommerceOptions = {
98
+ /**
99
+ * Payment provider. `createMollieGateway()` for production,
100
+ * `new FakePaymentGateway()` for tests and local development.
101
+ */
102
+ gateway: PaymentGateway;
103
+ /**
104
+ * The storage handlers to fulfil. See {@link CommerceStorage} — seven
105
+ * functions, with no transactions or queries on this side of the boundary.
106
+ *
107
+ * Every argument and return value is validated against the contract (see
108
+ * `checkedStorage`), so a handler that drifts fails immediately and says
109
+ * which field was wrong.
110
+ */
111
+ storage: CommerceStorage;
112
+ /**
113
+ * Called when a storage handler throws, before the error reaches your
114
+ * caller. Return a {@link CommerceError} to translate it, return nothing to
115
+ * just observe it, or throw your own. Contract violations
116
+ * (`storage_contract`) pass through here as well.
117
+ *
118
+ * ```ts
119
+ * onStorageError: (error, { handler, orderId }) => {
120
+ * log.error({ err: error, handler, orderId }, "storage failed");
121
+ * if (isDeadlock(error)) return new CommerceError("version_conflict", "retry", { orderId });
122
+ * }
123
+ * ```
124
+ */
125
+ onStorageError?: StorageErrorHandler;
126
+ /**
127
+ * Where Mollie returns the customer after paying. Required by Mollie for
128
+ * most payment methods; override per checkout if it varies.
129
+ */
130
+ redirectUrl?: string;
131
+ /** Where Mollie returns the customer if they abandon the payment. */
132
+ cancelUrl?: string;
133
+ /**
134
+ * Public URL Mollie posts status changes to. Without it you get no status
135
+ * updates and must poll {@link Commerce.syncOrder} yourself. Cannot be
136
+ * `localhost`.
137
+ */
138
+ webhookUrl?: string;
139
+ /**
140
+ * Move delivery straight to `delivered` when an order with no physical lines
141
+ * is paid. Default `true`.
142
+ */
143
+ autoDeliverDigital?: boolean;
144
+ /**
145
+ * Decrement SKU stock at checkout and give it back if the payment dies.
146
+ * Default `true`. Set `false` if you track stock elsewhere.
147
+ */
148
+ reserveStock?: boolean;
149
+ /** Override the clock, e.g. to freeze timestamps in tests. */
150
+ clock?: Clock;
151
+ /** Override id generation. Receives a prefix such as `"ord"`. */
152
+ generateId?: IdGenerator;
153
+ };
154
+ type CartItem = {
155
+ /** The SKU being bought — one purchasable variant. */
156
+ skuId: string;
157
+ /** Defaults to 1. Repeated SKUs are merged into one line. */
158
+ quantity?: number;
159
+ };
160
+ type CheckoutInput = {
161
+ /** At least one item. Prices, stock, and currency are validated. */
162
+ items: CartItem[];
163
+ customer?: Customer;
164
+ /**
165
+ * Payment description shown on the customer's statement. Defaults to a
166
+ * generated summary; Mollie truncates it per payment method.
167
+ */
168
+ description?: string;
169
+ /** Overrides {@link CommerceOptions.redirectUrl} for this order. */
170
+ redirectUrl?: string;
171
+ /** Overrides {@link CommerceOptions.cancelUrl} for this order. */
172
+ cancelUrl?: string;
173
+ /** Overrides {@link CommerceOptions.webhookUrl} for this order. */
174
+ webhookUrl?: string;
175
+ /**
176
+ * Makes checkout replay-safe: calling again with the same key returns the
177
+ * original order instead of charging twice. Also sent to Mollie.
178
+ */
179
+ idempotencyKey?: string;
180
+ };
181
+ type CheckoutResult = {
182
+ order: Order;
183
+ /** Redirect the customer here to pay. Absent only if Mollie returns none. */
184
+ checkoutUrl?: string;
185
+ /** True when an existing order was returned for a repeated idempotency key. */
186
+ replayed: boolean;
187
+ };
188
+ type RefundInput = {
189
+ orderId: string;
190
+ /**
191
+ * Exact amount to refund. Mutually exclusive with `lines`; omit both to
192
+ * refund everything still refundable.
193
+ */
194
+ amount?: Money;
195
+ /**
196
+ * Refund by order line instead of by amount. Quantity defaults to the whole
197
+ * line. Only the money is affected — stock is not restored.
198
+ */
199
+ lines?: {
200
+ skuId: string;
201
+ quantity?: number;
202
+ }[];
203
+ /** Forwarded to Mollie as the refund description, and stored on the order. */
204
+ reason?: string;
205
+ /** Prevents a retried request from refunding twice. */
206
+ idempotencyKey?: string;
207
+ };
208
+ type RefundResult = {
209
+ /** The order after the refund was applied. */
210
+ order: Order;
211
+ /** The refund that was just created. */
212
+ refund: Refund;
213
+ };
214
+ type ShipInput = {
215
+ orderId: string;
216
+ carrier?: string;
217
+ trackingCode?: string;
218
+ };
219
+ type ReturnInput = {
220
+ orderId: string;
221
+ /** Put the goods back into stock. Does not move money — refund separately. */
222
+ restock?: boolean;
223
+ };
224
+ /**
225
+ * Builds the commerce API: checkout, payment reconciliation, refunds, and the
226
+ * delivery lifecycle, over any storage and any payment gateway.
227
+ *
228
+ * ```ts
229
+ * const commerce = createCommerce({
230
+ * gateway: createMollieGateway({ apiKey: process.env.MOLLIE_API_KEY }),
231
+ * db: new SqliteDatabase({ filename: "./shop.sqlite" }),
232
+ * redirectUrl: "https://shop.example/thanks",
233
+ * webhookUrl: "https://shop.example/mollie-webhook",
234
+ * });
235
+ * ```
236
+ *
237
+ * Every method throws {@link CommerceError} with a machine-readable `code`.
238
+ * No gateway call happens inside a storage write handler, so the transactions
239
+ * you open there stay short.
240
+ */
241
+ declare function createCommerce(options: CommerceOptions): {
242
+ /**
243
+ * Starts an order: prices the cart from the catalog, reserves stock, and
244
+ * creates a Mollie payment. Send the customer to `checkoutUrl`.
245
+ *
246
+ * The order is persisted before the payment is created, so a gateway
247
+ * failure leaves a `failed` order rather than a lost cart — and its stock
248
+ * reservation is released.
249
+ *
250
+ * @param input.items Cart items; `[{ skuId, quantity? }]`, quantity defaults to 1.
251
+ * @param input.customer Optional `{ email?, locale? }`.
252
+ * @param input.description Payment description; defaults to a generated summary.
253
+ * @param input.redirectUrl Overrides the configured redirect URL.
254
+ * @param input.cancelUrl Overrides the configured cancel URL.
255
+ * @param input.webhookUrl Overrides the configured webhook URL.
256
+ * @param input.idempotencyKey Replaying this key returns the first order instead of charging again.
257
+ * @returns `{ order, checkoutUrl, replayed }`.
258
+ * @throws `empty_cart` no items, or a quantity below 1.
259
+ * @throws `sku_not_found` an item references an unknown SKU.
260
+ * @throws `sku_inactive` a SKU has `active: false`.
261
+ * @throws `out_of_stock` insufficient stock; nothing is reserved.
262
+ * @throws `currency_mismatch` the cart mixes currencies.
263
+ */
264
+ checkout: (input: CheckoutInput) => Promise<CheckoutResult>;
265
+ /**
266
+ * Re-reads the payment from Mollie and folds it into the order: payment
267
+ * state, refunded amount, and the delivery/stock consequences. Idempotent.
268
+ *
269
+ * Use it when you cannot receive webhooks, when the customer returns to
270
+ * your redirect URL, or to reconcile on a schedule.
271
+ *
272
+ * @param orderId The order to refresh.
273
+ * @returns The updated order; unchanged if no payment exists yet.
274
+ * @throws `order_not_found` no such order.
275
+ * @throws `gateway_error` Mollie could not be reached.
276
+ */
277
+ syncOrder: (orderId: string) => Promise<Order>;
278
+ /**
279
+ * Handles a Mollie webhook, which carries only a payment id and no status.
280
+ * Looks the order up, then does the same work as {@link syncOrder}.
281
+ *
282
+ * Respond 2xx even for `null`: a non-2xx makes Mollie retry a payment you
283
+ * will never recognize.
284
+ *
285
+ * @param paymentId The `id` Mollie posted (`tr_...`).
286
+ * @returns The updated order, or `null` if the payment is unknown here.
287
+ */
288
+ handleWebhook: (paymentId: string) => Promise<Order | null>;
289
+ /**
290
+ * Refunds a paid order, in full, by amount, or by line. Mollie usually
291
+ * reports refunds as `pending`; {@link syncOrder} reconciles the final
292
+ * state. Does not restock — see {@link markReturned}.
293
+ *
294
+ * @param input.orderId The order to refund.
295
+ * @param input.amount Exact amount; mutually exclusive with `lines`.
296
+ * @param input.lines Lines to refund as `[{ skuId, quantity? }]`; quantity defaults to the whole line.
297
+ * @param input.reason Refund description, forwarded to Mollie.
298
+ * @param input.idempotencyKey Prevents a retry from refunding twice.
299
+ * @returns `{ order, refund }`.
300
+ * @throws `order_not_found` no such order.
301
+ * @throws `not_refundable` the order is not paid, or the amount is not positive.
302
+ * @throws `refund_too_large` more than `total − amountRefunded`, or more units than the line holds.
303
+ * @throws `currency_mismatch` the amount's currency is not the order's.
304
+ */
305
+ refund: (input: RefundInput) => Promise<RefundResult>;
306
+ /**
307
+ * Marks the order ready to fulfil. Paying already does this, so you only
308
+ * need it when `reserveStock`/payment flows are bypassed.
309
+ *
310
+ * @param orderId The order to advance.
311
+ * @returns The updated order.
312
+ * @throws `invalid_delivery_transition` not reachable from the current state.
313
+ */
314
+ markReady: (orderId: string) => Promise<Order>;
315
+ /**
316
+ * Marks the order shipped and records the carrier and tracking code.
317
+ *
318
+ * @param input.orderId The order being shipped.
319
+ * @param input.carrier Carrier name, e.g. `"PostNL"`. Keeps the existing value if omitted.
320
+ * @param input.trackingCode Tracking code. Keeps the existing value if omitted.
321
+ * @returns The updated order.
322
+ * @throws `invalid_delivery_transition` the order is not `ready`.
323
+ */
324
+ markShipped: (input: ShipInput) => Promise<Order>;
325
+ /**
326
+ * Marks the order delivered. Digital-only orders reach this automatically
327
+ * when paid, unless `autoDeliverDigital` is off.
328
+ *
329
+ * @param orderId The delivered order.
330
+ * @returns The updated order.
331
+ * @throws `invalid_delivery_transition` the order is not `ready` or `shipped`.
332
+ */
333
+ markDelivered: (orderId: string) => Promise<Order>;
334
+ /**
335
+ * Records a return. Moves no money — call {@link refund} for that.
336
+ *
337
+ * @param input.orderId The returned order.
338
+ * @param input.restock `true` to put the goods back into stock.
339
+ * @returns The updated order.
340
+ * @throws `invalid_delivery_transition` the order was never shipped or delivered.
341
+ */
342
+ markReturned: (input: ReturnInput) => Promise<Order>;
343
+ /**
344
+ * Cancels an unpaid order: cancels the Mollie payment when it is still
345
+ * cancelable, releases the stock, and cancels the delivery.
346
+ *
347
+ * @param orderId The order to cancel.
348
+ * @returns The updated order.
349
+ * @throws `order_not_found` no such order.
350
+ * @throws `not_payable` the order is already paid — refund it instead.
351
+ */
352
+ cancelOrder: (orderId: string) => Promise<Order>;
353
+ /**
354
+ * Reads one order, including its lines, refunds, and both state machines.
355
+ *
356
+ * @param orderId The order to read.
357
+ * @returns The order, or `null` if it does not exist.
358
+ */
359
+ getOrder: (orderId: string) => Promise<Order | null>;
360
+ /**
361
+ * Lists orders, newest-created last.
362
+ *
363
+ * @param query Optional filters: `paymentState`, `deliveryState`,
364
+ * `customerEmail`, `createdAfter`, `createdBefore`, `limit`, `offset`.
365
+ * State filters accept one value or an array.
366
+ * @returns Matching orders.
367
+ */
368
+ findOrders: (query?: OrderQuery) => Promise<Order[]>;
369
+ /**
370
+ * The storage handlers in use, as resolved from `storage` or `db`. Exposed
371
+ * for wrapping and testing; catalog writes go to your own storage, not
372
+ * through here.
373
+ */
374
+ storage: CommerceStorage;
375
+ };
376
+ type Commerce = ReturnType<typeof createCommerce>;
377
+ /** Freezes a catalog SKU into an order line at the given quantity. */
378
+ declare function lineFromSku(sku: Sku, quantity: number): OrderLine;
379
+ /** Default payment description. Mollie truncates, so keep it identifiable. */
380
+ declare function describeOrder(order: Order): string;
381
+ export { storageError as _, CommerceOptions as a, ReturnInput as c, describeOrder as d, lineFromSku as f, CommerceErrorCode as g, CommerceError as h, Commerce as i, ShipInput as l, StorageErrorHandler as m, CheckoutInput as n, RefundInput as o, StorageErrorContext as p, CheckoutResult as r, RefundResult as s, CartItem as t, createCommerce as u };
@@ -1,4 +1,5 @@
1
- import { E as money, t as checkedStorage, v as CommerceError } from "./storage-guards-Bct0v7l9.js";
1
+ import { t as CommerceError } from "./errors-DJU7-Bnk.js";
2
+ import { t as checkedStorage, w as money } from "./storage-guards-C3g_KlQm.js";
2
3
  import { beforeEach, expect, test } from "bun:test";
3
4
  // src/conformance.ts
4
5
  /**
@@ -0,0 +1,86 @@
1
+ // src/errors.ts
2
+ var CommerceError = class extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details, options) {
6
+ super(message, options);
7
+ this.code = code;
8
+ this.details = details;
9
+ this.name = "CommerceError";
10
+ }
11
+ };
12
+ /**
13
+ * Errors a storage handler is expected to raise, pre-shaped so your handlers
14
+ * speak the same vocabulary the rest of the library does — and so your
15
+ * application's error mapping only ever has to know `CommerceError.code`.
16
+ *
17
+ * ```ts
18
+ * async mutateOrder(orderId, apply) {
19
+ * const order = await db.loadOrder(orderId);
20
+ * if (!order) throw storageError.orderNotFound(orderId);
21
+ * // …
22
+ * if (!written) throw storageError.versionConflict(orderId, order.version);
23
+ * }
24
+ * ```
25
+ *
26
+ * Throwing anything else is fine too: it travels out through whichever
27
+ * `commerce.*` method was running, and `onStorageError` can translate it.
28
+ */
29
+ const storageError = {
30
+ /**
31
+ * Not enough stock. `createOrder` may either return
32
+ * `{ status: "out_of_stock", shortages }` or throw this — whichever suits
33
+ * your implementation, since aborting a transaction usually means throwing.
34
+ *
35
+ * @param shortages What fell short, as `reserveStock` reported it.
36
+ */
37
+ outOfStock(shortages) {
38
+ const first = shortages[0];
39
+ return new CommerceError("out_of_stock", first ? `sku ${first.skuId} is out of stock` : "out of stock", { shortages });
40
+ },
41
+ /**
42
+ * The order a handler was asked for does not exist. `mutateOrder` must throw
43
+ * this; the read handlers return `null` instead.
44
+ *
45
+ * @param orderId The id that was not found.
46
+ */
47
+ orderNotFound(orderId) {
48
+ return new CommerceError("order_not_found", `no order ${orderId}`, { orderId });
49
+ },
50
+ /**
51
+ * Someone else wrote the order first, so this write was refused rather than
52
+ * allowed to overwrite. Callers are expected to re-read and retry.
53
+ *
54
+ * @param orderId The contested order.
55
+ * @param expected The version the write was based on.
56
+ * @param actual The version found in storage, if you know it.
57
+ */
58
+ versionConflict(orderId, expected, actual) {
59
+ return new CommerceError("version_conflict", `order ${orderId} was modified concurrently${expected === void 0 ? "" : actual === void 0 ? ` (expected version ${expected})` : ` (expected version ${expected}, found ${actual})`}`, {
60
+ orderId,
61
+ expected,
62
+ actual
63
+ });
64
+ },
65
+ /**
66
+ * An order already exists with that id, payment id, or idempotency key —
67
+ * typically a unique-index violation from `createOrder`.
68
+ *
69
+ * @param orderId The order being inserted.
70
+ * @param cause The driver error, kept for your logs.
71
+ */
72
+ duplicateOrder(orderId, cause) {
73
+ return new CommerceError("duplicate_order", `order ${orderId} already exists`, { orderId }, { cause });
74
+ },
75
+ /**
76
+ * Storage failed for a reason the library has no opinion about: a dropped
77
+ * connection, a timeout, a disk error.
78
+ *
79
+ * @param message What went wrong, for your logs.
80
+ * @param cause The underlying error.
81
+ */
82
+ failed(message, cause) {
83
+ return new CommerceError("storage_error", message, void 0, { cause });
84
+ }
85
+ };
86
+ export { storageError as n, CommerceError as t };