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 +19 -0
- package/README.md +68 -20
- package/dist/commerce-D26DAG-Y.d.ts +381 -0
- package/dist/conformance.js +2 -1
- package/dist/errors-DJU7-Bnk.js +86 -0
- package/dist/http.d.ts +95 -0
- package/dist/http.js +122 -0
- package/dist/index.d.ts +2 -380
- package/dist/index.js +2 -1
- package/dist/{storage-guards-Bct0v7l9.js → storage-guards-C3g_KlQm.js} +4 -87
- package/package.json +13 -7
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 +
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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 };
|
package/dist/conformance.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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 };
|