@cogenta/commerce 0.2.0 → 0.4.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.
Files changed (95) hide show
  1. package/dist/admin/permissions.d.ts +8 -0
  2. package/dist/admin/permissions.d.ts.map +1 -1
  3. package/dist/admin/permissions.js +1 -0
  4. package/dist/admin/permissions.js.map +1 -1
  5. package/dist/admin/router.d.ts +47 -5
  6. package/dist/admin/router.d.ts.map +1 -1
  7. package/dist/admin/router.js +942 -24
  8. package/dist/admin/router.js.map +1 -1
  9. package/dist/cart/store.d.ts +23 -0
  10. package/dist/cart/store.d.ts.map +1 -1
  11. package/dist/cart/store.js +49 -2
  12. package/dist/cart/store.js.map +1 -1
  13. package/dist/catalog/csv.d.ts +29 -0
  14. package/dist/catalog/csv.d.ts.map +1 -0
  15. package/dist/catalog/csv.js +396 -0
  16. package/dist/catalog/csv.js.map +1 -0
  17. package/dist/catalog/store.d.ts +24 -4
  18. package/dist/catalog/store.d.ts.map +1 -1
  19. package/dist/catalog/store.js +238 -11
  20. package/dist/catalog/store.js.map +1 -1
  21. package/dist/catalog/types.d.ts +92 -0
  22. package/dist/catalog/types.d.ts.map +1 -1
  23. package/dist/catalog/types.js +20 -0
  24. package/dist/catalog/types.js.map +1 -1
  25. package/dist/coupon/store.d.ts +63 -5
  26. package/dist/coupon/store.d.ts.map +1 -1
  27. package/dist/coupon/store.js +140 -7
  28. package/dist/coupon/store.js.map +1 -1
  29. package/dist/customer/store.d.ts +17 -0
  30. package/dist/customer/store.d.ts.map +1 -1
  31. package/dist/customer/store.js +26 -0
  32. package/dist/customer/store.js.map +1 -1
  33. package/dist/index.d.ts +18 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +9 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/invoice/credit-note.d.ts +45 -0
  38. package/dist/invoice/credit-note.d.ts.map +1 -0
  39. package/dist/invoice/credit-note.js +109 -0
  40. package/dist/invoice/credit-note.js.map +1 -0
  41. package/dist/invoice/sequence.d.ts +21 -0
  42. package/dist/invoice/sequence.d.ts.map +1 -0
  43. package/dist/invoice/sequence.js +64 -0
  44. package/dist/invoice/sequence.js.map +1 -0
  45. package/dist/invoice/store.d.ts +23 -1
  46. package/dist/invoice/store.d.ts.map +1 -1
  47. package/dist/invoice/store.js +23 -56
  48. package/dist/invoice/store.js.map +1 -1
  49. package/dist/order/csv.d.ts +24 -0
  50. package/dist/order/csv.d.ts.map +1 -0
  51. package/dist/order/csv.js +42 -0
  52. package/dist/order/csv.js.map +1 -0
  53. package/dist/order/notify.d.ts +65 -0
  54. package/dist/order/notify.d.ts.map +1 -0
  55. package/dist/order/notify.js +180 -0
  56. package/dist/order/notify.js.map +1 -0
  57. package/dist/order/store.d.ts +58 -1
  58. package/dist/order/store.d.ts.map +1 -1
  59. package/dist/order/store.js +145 -10
  60. package/dist/order/store.js.map +1 -1
  61. package/dist/order/types.d.ts +35 -1
  62. package/dist/order/types.d.ts.map +1 -1
  63. package/dist/order/types.js +2 -0
  64. package/dist/order/types.js.map +1 -1
  65. package/dist/payment/paypal.d.ts +9 -0
  66. package/dist/payment/paypal.d.ts.map +1 -0
  67. package/dist/payment/paypal.js +583 -0
  68. package/dist/payment/paypal.js.map +1 -0
  69. package/dist/payment/registry.d.ts +15 -6
  70. package/dist/payment/registry.d.ts.map +1 -1
  71. package/dist/payment/registry.js +17 -6
  72. package/dist/payment/registry.js.map +1 -1
  73. package/dist/payment/types.d.ts +6 -0
  74. package/dist/payment/types.d.ts.map +1 -1
  75. package/dist/shipping/store.d.ts +29 -3
  76. package/dist/shipping/store.d.ts.map +1 -1
  77. package/dist/shipping/store.js +68 -4
  78. package/dist/shipping/store.js.map +1 -1
  79. package/dist/subscription/renewal-notifier.d.ts +17 -0
  80. package/dist/subscription/renewal-notifier.d.ts.map +1 -0
  81. package/dist/subscription/renewal-notifier.js +33 -0
  82. package/dist/subscription/renewal-notifier.js.map +1 -0
  83. package/dist/subscription/store.d.ts +126 -3
  84. package/dist/subscription/store.d.ts.map +1 -1
  85. package/dist/subscription/store.js +348 -8
  86. package/dist/subscription/store.js.map +1 -1
  87. package/dist/tables.d.ts +17 -0
  88. package/dist/tables.d.ts.map +1 -1
  89. package/dist/tables.js +273 -0
  90. package/dist/tables.js.map +1 -1
  91. package/dist/tax/store.d.ts +20 -0
  92. package/dist/tax/store.d.ts.map +1 -1
  93. package/dist/tax/store.js +51 -7
  94. package/dist/tax/store.js.map +1 -1
  95. package/package.json +3 -2
@@ -1,7 +1,12 @@
1
1
  import { CogentaError, isCogentaError } from '@cogenta/core';
2
+ import { applyProductsImport, exportProductsCsv, previewProductsImport } from '../catalog/csv.js';
2
3
  import { COUPON_KINDS } from '../coupon/store.js';
4
+ import { ordersToCsv } from '../order/csv.js';
3
5
  import { ORDER_STATUSES } from '../order/types.js';
4
- import { COMMERCE_ANONYMOUS } from './permissions.js';
6
+ import { SHIPPING_KINDS } from '../shipping/store.js';
7
+ import { SUBSCRIPTION_STATUSES, } from '../subscription/store.js';
8
+ import { taxFor } from '../tax/store.js';
9
+ import { COMMERCE_ANONYMOUS, COMMERCE_PERMISSIONS } from './permissions.js';
5
10
  const STATUS_BY_CODE = {
6
11
  UNAUTHENTICATED: 401,
7
12
  FORBIDDEN: 403,
@@ -14,11 +19,16 @@ const STATUS_BY_CODE = {
14
19
  COMMERCE_INVOICE_NOT_FOUND: 404,
15
20
  COMMERCE_SUBSCRIPTION_NOT_FOUND: 404,
16
21
  COMMERCE_SHIPPING_METHOD_UNKNOWN: 404,
22
+ COMMERCE_TAX_RULE_UNKNOWN: 404,
23
+ COMMERCE_CUSTOMER_NOT_FOUND: 404,
24
+ COMMERCE_CREDIT_NOTE_NOT_FOUND: 404,
17
25
  COMMERCE_PRODUCT_INVALID: 400,
26
+ COMMERCE_TRACKING_INVALID: 400,
18
27
  COMMERCE_SKU_TAKEN: 409,
19
28
  COMMERCE_INVOICE_ALREADY_ISSUED: 409,
20
29
  COMMERCE_CART_CLOSED: 409,
21
30
  COMMERCE_ORDER_TRANSITION_INVALID: 409,
31
+ COMMERCE_ORDER_LOCKED: 409,
22
32
  COMMERCE_INVOICE_SEQUENCE_CONFLICT: 409,
23
33
  COMMERCE_AMOUNT_INVALID: 400,
24
34
  COMMERCE_CURRENCY_INVALID: 400,
@@ -26,12 +36,19 @@ const STATUS_BY_CODE = {
26
36
  COMMERCE_QUANTITY_INVALID: 400,
27
37
  COMMERCE_COUPON_INVALID: 400,
28
38
  COMMERCE_COUPON_EXHAUSTED: 409,
39
+ COMMERCE_COUPON_CUSTOMER_EXHAUSTED: 409,
40
+ COMMERCE_COUPON_NOT_APPLICABLE: 400,
29
41
  COMMERCE_TAX_RULE_INVALID: 400,
30
42
  COMMERCE_SHIPPING_UNAVAILABLE: 400,
31
43
  COMMERCE_SUBSCRIPTION_INVALID: 400,
32
44
  COMMERCE_REFUND_EXCEEDS_PAYMENT: 400,
33
45
  COMMERCE_PAYMENT_UNSUPPORTED: 400,
34
46
  COMMERCE_PAYMENT_SIGNATURE_INVALID: 403,
47
+ // The driver registry's own vocabulary (fiche 34 task 3's "tester la
48
+ // connexion") — a bad gateway on the far side, never this server's fault.
49
+ DRIVER_UNAVAILABLE: 502,
50
+ DRIVER_UNKNOWN: 502,
51
+ DRIVER_INIT_FAILED: 502,
35
52
  };
36
53
  function errorResponse(error) {
37
54
  if (isCogentaError(error)) {
@@ -73,6 +90,100 @@ function readString(body, key) {
73
90
  }
74
91
  return value;
75
92
  }
93
+ function readOptionalString(body, key) {
94
+ const value = body[key];
95
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
96
+ }
97
+ function readOptionalInt(body, key) {
98
+ const value = body[key];
99
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
100
+ }
101
+ function readOptionalBool(body, key) {
102
+ const value = body[key];
103
+ return typeof value === 'boolean' ? value : undefined;
104
+ }
105
+ /**
106
+ * Reads a field that is legitimately nullable — a threshold, a compare-at
107
+ * price, a dimension — and tells "absent" (leave the current value alone)
108
+ * from "explicitly null" (clear it) from "a real value" (set it), the same
109
+ * three-way distinction `CatalogStore.updateVariant` itself already needs
110
+ * (its own `input.x === undefined ? current.x : input.x` guards).
111
+ */
112
+ function optionalField(body, key, parse) {
113
+ if (!(key in body))
114
+ return {};
115
+ return { [key]: parse(body[key]) };
116
+ }
117
+ function nullableInt(value) {
118
+ if (value === null)
119
+ return null;
120
+ if (typeof value === 'number' && Number.isInteger(value))
121
+ return value;
122
+ throw new CogentaError({
123
+ code: 'COMMERCE_QUANTITY_INVALID',
124
+ message: 'This field must be a whole number, or null to clear it.',
125
+ hint: 'Send an integer, or null.',
126
+ });
127
+ }
128
+ function nullableContentRef(value) {
129
+ if (value === null)
130
+ return null;
131
+ if (typeof value === 'object' &&
132
+ value !== null &&
133
+ typeof value.collection === 'string' &&
134
+ typeof value.entryId === 'string') {
135
+ const record = value;
136
+ return { collection: record.collection, entryId: record.entryId };
137
+ }
138
+ throw new CogentaError({
139
+ code: 'COMMERCE_PRODUCT_INVALID',
140
+ message: '"contentRef" must be { collection, entryId }, or null to unlink.',
141
+ hint: 'Send { "collection": "…", "entryId": "…" }, or null.',
142
+ });
143
+ }
144
+ /** A product's gallery — an array of media ids, or absent (left alone). */
145
+ function mediaIdArray(value) {
146
+ if (!Array.isArray(value)) {
147
+ throw new CogentaError({
148
+ code: 'COMMERCE_PRODUCT_INVALID',
149
+ message: '"imageMediaIds" must be an array of media ids.',
150
+ hint: 'Send an array of strings, in the order the gallery should show them.',
151
+ });
152
+ }
153
+ return value.filter((item) => typeof item === 'string');
154
+ }
155
+ /** A variant's own single photo — a media id, or `null` to clear it. */
156
+ function nullableMediaId(value) {
157
+ if (value === null)
158
+ return null;
159
+ if (typeof value === 'string')
160
+ return value;
161
+ throw new CogentaError({
162
+ code: 'COMMERCE_PRODUCT_INVALID',
163
+ message: '"imageMediaId" must be a media id, or null to clear it.',
164
+ hint: 'Send a media library id, or null.',
165
+ });
166
+ }
167
+ function nullableIsoString(value) {
168
+ if (value === null)
169
+ return null;
170
+ if (typeof value === 'string' && value.trim() !== '')
171
+ return value;
172
+ throw new CogentaError({
173
+ code: 'COMMERCE_PRODUCT_INVALID',
174
+ message: 'This field must be an ISO date string, or null to clear it.',
175
+ hint: 'Send a date such as "2026-09-01T00:00:00.000Z", or null.',
176
+ });
177
+ }
178
+ /** A query-string integer, or `undefined` for anything that is not a positive whole number. */
179
+ function parsePositiveInt(value) {
180
+ if (value === undefined)
181
+ return undefined;
182
+ const parsed = Number(value);
183
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
184
+ }
185
+ /** Same cap `@cogenta/api`'s redirect router uses for its own CSV import/export (fiche 51 task 6). */
186
+ const MAX_CSV_ROWS = 5000;
76
187
  function readInt(body, key) {
77
188
  const value = body[key];
78
189
  if (typeof value !== 'number' || !Number.isInteger(value)) {
@@ -84,9 +195,52 @@ function readInt(body, key) {
84
195
  }
85
196
  return value;
86
197
  }
198
+ /**
199
+ * Reads `body.shippingAddress` (fiche 52 task 1), if present. `undefined`
200
+ * means "this request said nothing about the address" (leave it alone on an
201
+ * update); an explicit `null` on the wire is honoured too, by returning
202
+ * `null` — "clear the address" — rather than being swallowed by `undefined`.
203
+ */
204
+ function readShippingAddress(body) {
205
+ const value = body.shippingAddress;
206
+ if (value === undefined)
207
+ return undefined;
208
+ if (value === null)
209
+ return null;
210
+ const address = readObject(value);
211
+ return {
212
+ line1: readString(address, 'line1'),
213
+ city: readString(address, 'city'),
214
+ postalCode: readString(address, 'postalCode'),
215
+ line2: readOptionalString(address, 'line2') ?? null,
216
+ recipient: readOptionalString(address, 'recipient') ?? null,
217
+ phone: readOptionalString(address, 'phone') ?? null,
218
+ };
219
+ }
87
220
  export function createCommerceAdminRouter(options) {
88
221
  const basePath = (options.basePath ?? '/api/commerce').replace(/\/+$/u, '');
89
222
  const { permissions } = options;
223
+ /**
224
+ * A customer's own fiche (fiche 52 task 3): their record plus every order
225
+ * they placed and what they spent on it. `orders.list({ customerId })`
226
+ * already exists and is already permission-agnostic at the store layer, so
227
+ * this is aggregation, not a new query engine.
228
+ */
229
+ async function customerDetail(customer) {
230
+ const orders = await options.orders.list({ customerId: customer.id, limit: 500 });
231
+ const spendable = orders.filter((order) => order.status !== 'cancelled' && order.status !== 'refunded');
232
+ const totalSpentMinor = spendable.reduce((sum, order) => sum + order.totalMinor, 0);
233
+ const subscriptions = options.subscriptions === undefined
234
+ ? []
235
+ : (await options.subscriptions.list()).filter((subscription) => subscription.customerId === customer.id);
236
+ return {
237
+ customer,
238
+ orders,
239
+ totalSpentMinor,
240
+ currency: spendable[0]?.currency ?? orders[0]?.currency ?? null,
241
+ subscriptions,
242
+ };
243
+ }
90
244
  return {
91
245
  handle: async (request, actor = COMMERCE_ANONYMOUS) => {
92
246
  try {
@@ -98,21 +252,48 @@ export function createCommerceAdminRouter(options) {
98
252
  .split('/')
99
253
  .filter((segment) => segment !== '');
100
254
  const method = request.method.toUpperCase();
255
+ // ---- permissions ----------------------------------------------------
256
+ // Read-only, and deliberately not gated behind a write permission: it
257
+ // describes the vocabulary and the role grants this very layer
258
+ // enforces (fiche 19's permission matrix), never anything that could
259
+ // itself move money or edit the catalogue.
260
+ if (segments.length === 1 && segments[0] === 'permissions') {
261
+ if (method === 'GET') {
262
+ permissions.assert('commerce.read', actor);
263
+ return {
264
+ status: 200,
265
+ body: { permissions: COMMERCE_PERMISSIONS, roles: permissions.roles },
266
+ };
267
+ }
268
+ }
101
269
  // ---- products -----------------------------------------------------
102
270
  if (segments[0] === 'products' && segments.length === 1) {
103
271
  if (method === 'GET') {
104
272
  permissions.assert('commerce.read', actor);
105
273
  const query = request.query ?? {};
274
+ // One row over the requested page: `hasMore` is then simply
275
+ // "did the extra row show up", never a second `count(*)` query
276
+ // (fiche 51 task 2 — the same technique `media-client.ts`'s
277
+ // cursor pagination already uses, adapted to this offset-based
278
+ // list rather than inventing a cursor for it).
279
+ const requestedLimit = parsePositiveInt(query.limit) ?? 25;
280
+ const offset = parsePositiveInt(query.offset) ?? 0;
281
+ const sort = query.sort === 'title' || query.sort === 'handle' ? query.sort : 'createdAt';
282
+ const direction = query.direction === 'asc' ? 'asc' : 'desc';
283
+ const page = await options.catalog.listProducts({
284
+ ...(query.status === 'active' || query.status === 'archived'
285
+ ? { status: query.status }
286
+ : {}),
287
+ ...(query.q === undefined || query.q === '' ? {} : { search: query.q }),
288
+ sort,
289
+ direction,
290
+ limit: requestedLimit + 1,
291
+ offset,
292
+ });
293
+ const hasMore = page.length > requestedLimit;
106
294
  return {
107
295
  status: 200,
108
- body: {
109
- products: await options.catalog.listProducts({
110
- ...(query.status === 'active' || query.status === 'archived'
111
- ? { status: query.status }
112
- : {}),
113
- ...(query.q === undefined ? {} : { search: query.q }),
114
- }),
115
- },
296
+ body: { products: hasMore ? page.slice(0, requestedLimit) : page, hasMore },
116
297
  };
117
298
  }
118
299
  if (method === 'POST') {
@@ -123,10 +304,59 @@ export function createCommerceAdminRouter(options) {
123
304
  body: await options.catalog.createProduct({
124
305
  handle: readString(body, 'handle'),
125
306
  title: readString(body, 'title'),
307
+ ...optionalField(body, 'imageMediaIds', mediaIdArray),
126
308
  }),
127
309
  };
128
310
  }
129
311
  }
312
+ // The catalogue's CSV export (fiche 51 task 6) — checked ahead of the
313
+ // generic `/products/:id` route below, the same way `export`/`import`
314
+ // are ordered ahead of `:id` on `@cogenta/api`'s redirect router.
315
+ if (segments[0] === 'products' && segments[1] === 'export' && segments.length === 2) {
316
+ if (method === 'GET') {
317
+ permissions.assert('commerce.read', actor);
318
+ const products = await options.catalog.listProducts({ limit: MAX_CSV_ROWS });
319
+ const variantsByProduct = new Map(await Promise.all(products.map(async (product) => [product.id, await options.catalog.listVariants(product.id)])));
320
+ return {
321
+ status: 200,
322
+ body: {
323
+ csv: exportProductsCsv(products, variantsByProduct),
324
+ filename: 'products.csv',
325
+ },
326
+ };
327
+ }
328
+ }
329
+ if (segments[0] === 'products' && segments[1] === 'import' && segments.length === 2) {
330
+ if (method === 'POST') {
331
+ permissions.assert('commerce.catalog.write', actor);
332
+ const body = readObject(request.body);
333
+ const csv = readString(body, 'csv');
334
+ const apply = body.apply === true;
335
+ return {
336
+ status: 200,
337
+ body: apply
338
+ ? await applyProductsImport(csv, options.catalog, MAX_CSV_ROWS)
339
+ : await previewProductsImport(csv, options.catalog, MAX_CSV_ROWS),
340
+ };
341
+ }
342
+ }
343
+ // The reverse of `contentRef` — what the content editor's own
344
+ // cross-link (fiche 51 task 1) asks to find "is there a commercial
345
+ // record for the entry I am looking at right now".
346
+ if (segments[0] === 'products' && segments[1] === 'by-content' && segments.length === 2) {
347
+ if (method === 'GET') {
348
+ permissions.assert('commerce.read', actor);
349
+ const collection = request.query?.collection;
350
+ const entryId = request.query?.entryId;
351
+ if (collection === undefined || entryId === undefined) {
352
+ return { status: 200, body: { product: null } };
353
+ }
354
+ return {
355
+ status: 200,
356
+ body: { product: await options.catalog.readProductByContentRef(collection, entryId) },
357
+ };
358
+ }
359
+ }
130
360
  if (segments[0] === 'products' && segments.length === 2) {
131
361
  const id = segments[1] ?? '';
132
362
  if (method === 'GET') {
@@ -136,7 +366,11 @@ export function createCommerceAdminRouter(options) {
136
366
  return notFound('product');
137
367
  return {
138
368
  status: 200,
139
- body: { product, variants: await options.catalog.listVariants(id) },
369
+ body: {
370
+ product,
371
+ variants: await options.catalog.listVariants(id),
372
+ terms: await options.catalog.listProductTerms(id),
373
+ },
140
374
  };
141
375
  }
142
376
  if (method === 'PATCH') {
@@ -150,6 +384,11 @@ export function createCommerceAdminRouter(options) {
150
384
  ...(body.status === 'active' || body.status === 'archived'
151
385
  ? { status: body.status }
152
386
  : {}),
387
+ // Fiche 51 task 1: `null` unlinks, an object links, absence
388
+ // leaves it alone — the same three-way distinction the
389
+ // nullable variant fields above already need.
390
+ ...optionalField(body, 'contentRef', nullableContentRef),
391
+ ...optionalField(body, 'imageMediaIds', mediaIdArray),
153
392
  }),
154
393
  };
155
394
  }
@@ -159,7 +398,48 @@ export function createCommerceAdminRouter(options) {
159
398
  return { status: 204, body: null };
160
399
  }
161
400
  }
401
+ // A product's classification against a taxonomy the site declares
402
+ // (fiche 51 task 3, ADR-0022). Governed by `commerce.catalog.write`
403
+ // — the same permission as every other catalogue edit — rather than
404
+ // contract A's `canTerm`: categorising a product is catalogue work,
405
+ // and reusing contract A's term permission here would couple this
406
+ // router to a second, unrelated permission layer for one field.
407
+ if (segments[0] === 'products' && segments[2] === 'terms' && segments.length === 3) {
408
+ if (method === 'PUT') {
409
+ permissions.assert('commerce.catalog.write', actor);
410
+ const body = readObject(request.body);
411
+ const taxonomy = readString(body, 'taxonomy');
412
+ const termIds = Array.isArray(body.termIds)
413
+ ? body.termIds.filter((value) => typeof value === 'string')
414
+ : [];
415
+ return {
416
+ status: 200,
417
+ body: {
418
+ terms: await options.catalog.setProductTerms(segments[1] ?? '', taxonomy, termIds),
419
+ },
420
+ };
421
+ }
422
+ }
162
423
  // ---- variants -----------------------------------------------------
424
+ // Checked ahead of the generic `/variants/:id` route below, the same
425
+ // ordering discipline as `products/export` above.
426
+ if (segments[0] === 'variants' && segments[1] === 'low-stock' && segments.length === 2) {
427
+ if (method === 'GET') {
428
+ permissions.assert('commerce.read', actor);
429
+ return { status: 200, body: { variants: await options.catalog.listLowStock() } };
430
+ }
431
+ }
432
+ if (segments[0] === 'variants' &&
433
+ segments[2] === 'stock-movements' &&
434
+ segments.length === 3) {
435
+ if (method === 'GET') {
436
+ permissions.assert('commerce.read', actor);
437
+ return {
438
+ status: 200,
439
+ body: { movements: await options.catalog.listStockMovements(segments[1] ?? '') },
440
+ };
441
+ }
442
+ }
163
443
  if (segments[0] === 'products' && segments[2] === 'variants' && segments.length === 3) {
164
444
  if (method === 'POST') {
165
445
  permissions.assert('commerce.catalog.write', actor);
@@ -173,6 +453,19 @@ export function createCommerceAdminRouter(options) {
173
453
  priceMinor: readInt(body, 'priceMinor'),
174
454
  currency: readString(body, 'currency'),
175
455
  ...(typeof body.onHand === 'number' ? { onHand: body.onHand } : {}),
456
+ ...(typeof body.allowBackorder === 'boolean'
457
+ ? { allowBackorder: body.allowBackorder }
458
+ : {}),
459
+ ...(typeof body.weightGrams === 'number' ? { weightGrams: body.weightGrams } : {}),
460
+ ...(typeof body.taxCategory === 'string' ? { taxCategory: body.taxCategory } : {}),
461
+ ...optionalField(body, 'lowStockThreshold', nullableInt),
462
+ ...optionalField(body, 'compareAtPriceMinor', nullableInt),
463
+ ...optionalField(body, 'saleStartsAt', nullableIsoString),
464
+ ...optionalField(body, 'saleEndsAt', nullableIsoString),
465
+ ...optionalField(body, 'widthMm', nullableInt),
466
+ ...optionalField(body, 'heightMm', nullableInt),
467
+ ...optionalField(body, 'depthMm', nullableInt),
468
+ ...optionalField(body, 'imageMediaId', nullableMediaId),
176
469
  }),
177
470
  };
178
471
  }
@@ -191,6 +484,17 @@ export function createCommerceAdminRouter(options) {
191
484
  ...(typeof body.allowBackorder === 'boolean'
192
485
  ? { allowBackorder: body.allowBackorder }
193
486
  : {}),
487
+ ...(typeof body.weightGrams === 'number' ? { weightGrams: body.weightGrams } : {}),
488
+ ...(typeof body.taxCategory === 'string' ? { taxCategory: body.taxCategory } : {}),
489
+ ...(typeof body.position === 'number' ? { position: body.position } : {}),
490
+ ...optionalField(body, 'lowStockThreshold', nullableInt),
491
+ ...optionalField(body, 'compareAtPriceMinor', nullableInt),
492
+ ...optionalField(body, 'saleStartsAt', nullableIsoString),
493
+ ...optionalField(body, 'saleEndsAt', nullableIsoString),
494
+ ...optionalField(body, 'widthMm', nullableInt),
495
+ ...optionalField(body, 'heightMm', nullableInt),
496
+ ...optionalField(body, 'depthMm', nullableInt),
497
+ ...optionalField(body, 'imageMediaId', nullableMediaId),
194
498
  }),
195
499
  };
196
500
  }
@@ -214,18 +518,95 @@ export function createCommerceAdminRouter(options) {
214
518
  }
215
519
  }
216
520
  // ---- orders -------------------------------------------------------
521
+ // Checked before the generic list/detail routes below, since both
522
+ // share the `orders`/length-1 or length-2 shape: an accounting CSV
523
+ // export (fiche 52 task 7) is not an order whose id happens to be
524
+ // "export.csv".
525
+ if (segments[0] === 'orders' && segments[1] === 'export.csv' && segments.length === 2) {
526
+ if (method === 'GET') {
527
+ permissions.assert('commerce.read', actor);
528
+ const query = request.query ?? {};
529
+ const status = query.status;
530
+ const q = query.q;
531
+ const orders = await options.orders.list({
532
+ ...(ORDER_STATUSES.includes(status ?? '')
533
+ ? { status: status }
534
+ : {}),
535
+ // Audit T-COM-03: `q` already narrows `GET /orders` (the list
536
+ // route above) by reference/e-mail — the export ignored it
537
+ // entirely, so a search that found three orders on screen
538
+ // still exported all of them.
539
+ ...(q === undefined || q === '' ? {} : { search: q }),
540
+ ...(query.from === undefined ? {} : { placedFrom: query.from }),
541
+ ...(query.to === undefined ? {} : { placedTo: query.to }),
542
+ limit: 5000,
543
+ });
544
+ const rows = [];
545
+ for (const order of orders) {
546
+ const invoice = await options.invoices?.readByOrder(order.id);
547
+ rows.push({ order, invoiceNumber: invoice?.number ?? null });
548
+ }
549
+ return { status: 200, body: ordersToCsv(rows) };
550
+ }
551
+ }
217
552
  if (segments[0] === 'orders' && segments.length === 1 && method === 'GET') {
218
553
  permissions.assert('commerce.read', actor);
219
- const status = request.query?.status;
554
+ const query = request.query ?? {};
555
+ const status = query.status;
556
+ const q = query.q;
220
557
  return {
221
558
  status: 200,
222
559
  body: {
223
- orders: await options.orders.list(ORDER_STATUSES.includes(status ?? '')
224
- ? { status: status }
225
- : {}),
560
+ orders: await options.orders.list({
561
+ ...(ORDER_STATUSES.includes(status ?? '')
562
+ ? { status: status }
563
+ : {}),
564
+ ...(q === undefined || q === '' ? {} : { search: q }),
565
+ // Advanced filters (fiche 52 task 7): an inclusive placed-at
566
+ // date range, on top of the status/search this route already had.
567
+ ...(query.from === undefined ? {} : { placedFrom: query.from }),
568
+ ...(query.to === undefined ? {} : { placedTo: query.to }),
569
+ }),
226
570
  },
227
571
  };
228
572
  }
573
+ // A shopkeeper-entered order (fiche 52 task 5) — a phone order, a
574
+ // trade-show sale, or a correction. `placeManual` reuses `place()`
575
+ // internally (see `order/store.ts`), so this route never duplicates
576
+ // stock-taking, pricing or coupon logic.
577
+ if (segments[0] === 'orders' && segments.length === 1 && method === 'POST') {
578
+ permissions.assert('commerce.order.write', actor);
579
+ const body = readObject(request.body);
580
+ const rawLines = body.lines;
581
+ if (!Array.isArray(rawLines) || rawLines.length === 0) {
582
+ throw new CogentaError({
583
+ code: 'COMMERCE_AMOUNT_INVALID',
584
+ message: '"lines" is required and must be a non-empty array.',
585
+ hint: 'Each line needs { variantId, quantity }.',
586
+ });
587
+ }
588
+ const lines = rawLines.map((entry) => {
589
+ const line = readObject(entry);
590
+ return { variantId: readString(line, 'variantId'), quantity: readInt(line, 'quantity') };
591
+ });
592
+ const customerName = readOptionalString(body, 'customerName');
593
+ const shippingAddress = readShippingAddress(body);
594
+ const outcome = await options.orders.placeManual({
595
+ email: readString(body, 'email'),
596
+ currency: readString(body, 'currency'),
597
+ lines,
598
+ actorId: actor.id,
599
+ ...(customerName === undefined ? {} : { customerName }),
600
+ ...(shippingAddress === undefined ? {} : { shippingAddress }),
601
+ });
602
+ if (outcome.kind === 'placed') {
603
+ if (options.orderEmails !== undefined) {
604
+ await options.orderEmails.enqueue(outcome.order.id, 'confirmation');
605
+ }
606
+ return { status: 201, body: outcome };
607
+ }
608
+ return { status: 200, body: outcome };
609
+ }
229
610
  if (segments[0] === 'orders' && segments.length === 2 && method === 'GET') {
230
611
  permissions.assert('commerce.read', actor);
231
612
  const order = await options.orders.read(segments[1] ?? '');
@@ -240,6 +621,22 @@ export function createCommerceAdminRouter(options) {
240
621
  },
241
622
  };
242
623
  }
624
+ // Corrects the e-mail/address before payment (fiche 52 task 5,
625
+ // "modification pré-paiement") — locked by `OrderStore.update` once
626
+ // the order is no longer `pending`.
627
+ if (segments[0] === 'orders' && segments.length === 2 && method === 'PATCH') {
628
+ permissions.assert('commerce.order.write', actor);
629
+ const body = readObject(request.body);
630
+ const email = readOptionalString(body, 'email');
631
+ const shippingAddress = readShippingAddress(body);
632
+ return {
633
+ status: 200,
634
+ body: await options.orders.update(segments[1] ?? '', {
635
+ ...(email === undefined ? {} : { email }),
636
+ ...(shippingAddress === undefined ? {} : { shippingAddress }),
637
+ }, { actorId: actor.id }),
638
+ };
639
+ }
243
640
  if (segments[0] === 'orders' && segments[2] === 'status' && segments.length === 3) {
244
641
  if (method === 'PUT') {
245
642
  permissions.assert('commerce.order.write', actor);
@@ -252,12 +649,52 @@ export function createCommerceAdminRouter(options) {
252
649
  hint: `Use one of: ${ORDER_STATUSES.join(', ')}.`,
253
650
  });
254
651
  }
652
+ const updated = await options.orders.transition(segments[1] ?? '', to, {
653
+ actorId: actor.id,
654
+ ...(typeof body.note === 'string' ? { note: body.note } : {}),
655
+ });
656
+ return { status: 200, body: updated };
657
+ }
658
+ }
659
+ // Shipment tracking (fiche 52 task 4). Setting it on a paid order
660
+ // also moves the order to `shipped` (see `OrderStore.setTracking`),
661
+ // which is what fires the shipment e-mail below.
662
+ if (segments[0] === 'orders' && segments[2] === 'tracking' && segments.length === 3) {
663
+ if (method === 'PUT') {
664
+ permissions.assert('commerce.order.write', actor);
665
+ const body = readObject(request.body);
666
+ const url = readOptionalString(body, 'url');
667
+ const before = await options.orders.read(segments[1] ?? '');
668
+ const updated = await options.orders.setTracking(segments[1] ?? '', {
669
+ carrier: readString(body, 'carrier'),
670
+ number: readString(body, 'number'),
671
+ ...(url === undefined ? {} : { url }),
672
+ }, { actorId: actor.id });
673
+ if (before?.status === 'paid' && updated.status === 'shipped' && options.orderEmails) {
674
+ await options.orderEmails.enqueue(updated.id, 'shipment');
675
+ }
676
+ return { status: 200, body: updated };
677
+ }
678
+ }
679
+ if (segments[0] === 'orders' && segments[2] === 'emails' && segments.length === 3) {
680
+ if (method === 'GET') {
681
+ permissions.assert('commerce.read', actor);
682
+ if (options.orderEmails === undefined)
683
+ return { status: 200, body: { emails: [] } };
255
684
  return {
256
685
  status: 200,
257
- body: await options.orders.transition(segments[1] ?? '', to, {
258
- actorId: actor.id,
259
- ...(typeof body.note === 'string' ? { note: body.note } : {}),
260
- }),
686
+ body: { emails: await options.orderEmails.listForOrder(segments[1] ?? '') },
687
+ };
688
+ }
689
+ }
690
+ if (segments[0] === 'orders' && segments[2] === 'credit-notes' && segments.length === 3) {
691
+ if (method === 'GET') {
692
+ permissions.assert('commerce.read', actor);
693
+ if (options.creditNotes === undefined)
694
+ return { status: 200, body: { creditNotes: [] } };
695
+ return {
696
+ status: 200,
697
+ body: { creditNotes: await options.creditNotes.listForOrder(segments[1] ?? '') },
261
698
  };
262
699
  }
263
700
  }
@@ -282,12 +719,35 @@ export function createCommerceAdminRouter(options) {
282
719
  if (method === 'POST') {
283
720
  permissions.assert('commerce.order.refund', actor);
284
721
  const body = readObject(request.body);
722
+ // "Motif obligatoire" (fiche 52 task 6) — a real reason, not an
723
+ // optional courtesy, for money leaving the business.
724
+ const reason = readString(body, 'reason');
725
+ const refund = await options.payments.refund(segments[1] ?? '', readInt(body, 'amountMinor'), { actorId: actor.id, reason });
726
+ // A credit note per refund, automatically — the same gate as
727
+ // invoicing (billing configured), since a credit note without a
728
+ // seller address is not a usable accounting document either.
729
+ const creditNote = options.creditNotes === undefined
730
+ ? null
731
+ : await options.creditNotes.issue({
732
+ orderId: refund.orderId,
733
+ refundId: refund.id,
734
+ amountMinor: refund.amountMinor,
735
+ reason,
736
+ actorId: actor.id,
737
+ });
738
+ return { status: 200, body: { refund, creditNote } };
739
+ }
740
+ }
741
+ // So the screen can show what is left to refund (fiche 52 task 6) —
742
+ // reads the exact list `PaymentStore.refund`'s own over-refund guard
743
+ // sums, never a second computation of "how much was already
744
+ // refunded".
745
+ if (segments[0] === 'payments' && segments[2] === 'refunds' && segments.length === 3) {
746
+ if (method === 'GET') {
747
+ permissions.assert('commerce.read', actor);
285
748
  return {
286
749
  status: 200,
287
- body: await options.payments.refund(segments[1] ?? '', readInt(body, 'amountMinor'), {
288
- actorId: actor.id,
289
- ...(typeof body.reason === 'string' ? { reason: body.reason } : {}),
290
- }),
750
+ body: { refunds: await options.payments.listRefunds(segments[1] ?? '') },
291
751
  };
292
752
  }
293
753
  }
@@ -303,6 +763,40 @@ export function createCommerceAdminRouter(options) {
303
763
  },
304
764
  };
305
765
  }
766
+ // A customer's own fiche (fiche 52 task 3): the record plus its
767
+ // orders and spend, aggregated here rather than inside
768
+ // `CustomerStore` — `CustomerStore` knows nothing about orders, and
769
+ // it should stay that way (a customer without a single sale is still
770
+ // a valid customer record).
771
+ if (segments[0] === 'customers' && segments.length === 2 && method === 'GET') {
772
+ permissions.assert('commerce.read', actor);
773
+ const customer = await options.customers.read(segments[1] ?? '');
774
+ if (customer === null)
775
+ return notFound('customer');
776
+ return { status: 200, body: await customerDetail(customer) };
777
+ }
778
+ // GDPR export (fiche 52 task 3) — the same read permission as the
779
+ // rest of the back office already exposes this data through, bundled
780
+ // into one downloadable record rather than requiring several calls.
781
+ if (segments[0] === 'customers' && segments[2] === 'export' && segments.length === 3) {
782
+ if (method === 'POST' || method === 'GET') {
783
+ permissions.assert('commerce.read', actor);
784
+ const customer = await options.customers.read(segments[1] ?? '');
785
+ if (customer === null)
786
+ return notFound('customer');
787
+ return { status: 200, body: await customerDetail(customer) };
788
+ }
789
+ }
790
+ // GDPR erasure of the customer record (fiche 52 task 3) — see
791
+ // `CustomerStore.anonymize`'s own comment on why orders themselves
792
+ // are left untouched. Gated behind the same permission as any other
793
+ // action on a customer's order data.
794
+ if (segments[0] === 'customers' && segments[2] === 'anonymize' && segments.length === 3) {
795
+ if (method === 'POST') {
796
+ permissions.assert('commerce.order.write', actor);
797
+ return { status: 200, body: await options.customers.anonymize(segments[1] ?? '') };
798
+ }
799
+ }
306
800
  // ---- invoices -----------------------------------------------------
307
801
  // The PDF, checked before the metadata route below since both start
308
802
  // with the same three segments. Its body is the raw bytes, not JSON —
@@ -323,6 +817,23 @@ export function createCommerceAdminRouter(options) {
323
817
  return { status: 200, body: await options.invoices.pdf(invoice.id) };
324
818
  }
325
819
  }
820
+ // A real preview PDF for an order that may never be invoiced (fiche
821
+ // 54 task 2) — same shape and same `application/pdf` handling as the
822
+ // route above, checked first for the same reason: both start with
823
+ // the same three segments. `commerce.read`, not
824
+ // `commerce.invoice.issue`, because nothing here claims a number or
825
+ // writes a row (see `InvoiceStore.preview`'s own comment).
826
+ if (segments[0] === 'orders' &&
827
+ segments[2] === 'invoice' &&
828
+ segments[3] === 'preview' &&
829
+ segments.length === 4) {
830
+ if (method === 'GET') {
831
+ permissions.assert('commerce.read', actor);
832
+ if (options.invoices === undefined)
833
+ return notFound('invoice');
834
+ return { status: 200, body: await options.invoices.preview(segments[1] ?? '') };
835
+ }
836
+ }
326
837
  if (segments[0] === 'orders' && segments[2] === 'invoice' && segments.length === 3) {
327
838
  if (method === 'GET') {
328
839
  permissions.assert('commerce.read', actor);
@@ -385,10 +896,26 @@ export function createCommerceAdminRouter(options) {
385
896
  ...(typeof body.maxRedemptions === 'number'
386
897
  ? { maxRedemptions: body.maxRedemptions }
387
898
  : {}),
899
+ ...(typeof body.maxRedemptionsPerCustomer === 'number'
900
+ ? { maxRedemptionsPerCustomer: body.maxRedemptionsPerCustomer }
901
+ : {}),
902
+ ...(Array.isArray(body.restrictedProductIds)
903
+ ? {
904
+ restrictedProductIds: body.restrictedProductIds.filter((id) => typeof id === 'string'),
905
+ }
906
+ : {}),
388
907
  }),
389
908
  };
390
909
  }
391
910
  }
911
+ // Read before the generic /coupons/{code}/deactivate route below —
912
+ // "metrics" is not a coupon code, but the two paths share a shape.
913
+ if (segments[0] === 'coupons' && segments[1] === 'metrics' && segments.length === 2) {
914
+ if (method === 'GET') {
915
+ permissions.assert('commerce.read', actor);
916
+ return { status: 200, body: await options.coupons.metrics() };
917
+ }
918
+ }
392
919
  if (segments[0] === 'coupons' && segments[2] === 'deactivate' && segments.length === 3) {
393
920
  if (method === 'POST') {
394
921
  permissions.assert('commerce.catalog.write', actor);
@@ -415,13 +942,63 @@ export function createCommerceAdminRouter(options) {
415
942
  return {
416
943
  status: 200,
417
944
  body: {
418
- subscriptions: await options.subscriptions.list(status === 'active' || status === 'paused' || status === 'cancelled'
419
- ? { status }
945
+ subscriptions: await options.subscriptions.list(SUBSCRIPTION_STATUSES.includes(status ?? '')
946
+ ? { status: status }
420
947
  : {}),
421
948
  },
422
949
  };
423
950
  }
424
951
  }
952
+ // Read before the generic /subscriptions/{id} route below — "metrics"
953
+ // is not a subscription id, but the two paths share a shape.
954
+ if (segments[0] === 'subscriptions' && segments[1] === 'metrics' && segments.length === 2) {
955
+ if (options.subscriptions === undefined) {
956
+ return {
957
+ status: 404,
958
+ body: {
959
+ error: {
960
+ code: 'COMMERCE_SUBSCRIPTION_NOT_FOUND',
961
+ message: 'Subscriptions are not configured on this site.',
962
+ },
963
+ },
964
+ };
965
+ }
966
+ if (method === 'GET') {
967
+ permissions.assert('commerce.read', actor);
968
+ return { status: 200, body: await options.subscriptions.metrics() };
969
+ }
970
+ }
971
+ // A subscription's own billing history (`cycles`) and open dunning
972
+ // cycle, if any — fiche 53 tasks 1 and 3: pause/resume/cancel were
973
+ // already routed below, but nothing surfaced either of these.
974
+ if (segments[0] === 'subscriptions' && segments.length === 2) {
975
+ if (options.subscriptions === undefined) {
976
+ return {
977
+ status: 404,
978
+ body: {
979
+ error: {
980
+ code: 'COMMERCE_SUBSCRIPTION_NOT_FOUND',
981
+ message: 'Subscriptions are not configured on this site.',
982
+ },
983
+ },
984
+ };
985
+ }
986
+ if (method === 'GET') {
987
+ permissions.assert('commerce.read', actor);
988
+ const id = segments[1] ?? '';
989
+ const subscription = await options.subscriptions.read(id);
990
+ if (subscription === null)
991
+ return notFound('subscription');
992
+ return {
993
+ status: 200,
994
+ body: {
995
+ subscription,
996
+ cycles: await options.subscriptions.cycles(id),
997
+ dunning: await options.subscriptions.dunning(id),
998
+ },
999
+ };
1000
+ }
1001
+ }
425
1002
  if (segments[0] === 'subscriptions' &&
426
1003
  segments.length === 3 &&
427
1004
  (segments[2] === 'pause' || segments[2] === 'resume' || segments[2] === 'cancel')) {
@@ -449,6 +1026,345 @@ export function createCommerceAdminRouter(options) {
449
1026
  return { status: 200, body: updated };
450
1027
  }
451
1028
  }
1029
+ if (segments[0] === 'subscriptions' &&
1030
+ segments[2] === 'change-plan' &&
1031
+ segments.length === 3) {
1032
+ if (options.subscriptions === undefined) {
1033
+ return {
1034
+ status: 404,
1035
+ body: {
1036
+ error: {
1037
+ code: 'COMMERCE_SUBSCRIPTION_NOT_FOUND',
1038
+ message: 'Subscriptions are not configured on this site.',
1039
+ },
1040
+ },
1041
+ };
1042
+ }
1043
+ if (method === 'POST') {
1044
+ permissions.assert('commerce.order.write', actor);
1045
+ const body = readObject(request.body);
1046
+ const quantity = readOptionalInt(body, 'quantity');
1047
+ const prorate = readOptionalBool(body, 'prorate');
1048
+ return {
1049
+ status: 200,
1050
+ body: await options.subscriptions.changePlan(segments[1] ?? '', readString(body, 'variantId'), {
1051
+ ...(quantity === undefined ? {} : { quantity }),
1052
+ ...(prorate === undefined ? {} : { prorate }),
1053
+ }),
1054
+ };
1055
+ }
1056
+ }
1057
+ // ---- tax (fiche 34 task 1) ------------------------------------------
1058
+ if (segments[0] === 'tax' && segments[1] === 'rules' && segments.length === 2) {
1059
+ if (method === 'GET') {
1060
+ permissions.assert('commerce.read', actor);
1061
+ return { status: 200, body: { rules: await options.tax.listRules() } };
1062
+ }
1063
+ if (method === 'POST') {
1064
+ permissions.assert('commerce.catalog.write', actor);
1065
+ const body = readObject(request.body);
1066
+ const taxCategory = readOptionalString(body, 'taxCategory');
1067
+ const includedInPrice = readOptionalBool(body, 'includedInPrice');
1068
+ const priority = readOptionalInt(body, 'priority');
1069
+ const active = readOptionalBool(body, 'active');
1070
+ return {
1071
+ status: 201,
1072
+ body: await options.tax.createRule({
1073
+ name: readString(body, 'name'),
1074
+ rateBp: readInt(body, 'rateBp'),
1075
+ country: readOptionalString(body, 'country') ?? null,
1076
+ region: readOptionalString(body, 'region') ?? null,
1077
+ ...(taxCategory === undefined ? {} : { taxCategory }),
1078
+ ...(includedInPrice === undefined ? {} : { includedInPrice }),
1079
+ ...(priority === undefined ? {} : { priority }),
1080
+ ...(active === undefined ? {} : { active }),
1081
+ }),
1082
+ };
1083
+ }
1084
+ }
1085
+ if (segments[0] === 'tax' && segments[1] === 'rules' && segments.length === 3) {
1086
+ if (method === 'PATCH') {
1087
+ permissions.assert('commerce.catalog.write', actor);
1088
+ const body = readObject(request.body);
1089
+ const country = readOptionalString(body, 'country');
1090
+ const region = readOptionalString(body, 'region');
1091
+ const taxCategory = readOptionalString(body, 'taxCategory');
1092
+ const name = readOptionalString(body, 'name');
1093
+ const rateBp = readOptionalInt(body, 'rateBp');
1094
+ const includedInPrice = readOptionalBool(body, 'includedInPrice');
1095
+ const priority = readOptionalInt(body, 'priority');
1096
+ const active = readOptionalBool(body, 'active');
1097
+ return {
1098
+ status: 200,
1099
+ body: await options.tax.updateRule(segments[2] ?? '', {
1100
+ // `readOptionalString`/`readOptionalBool`/`readOptionalInt`
1101
+ // return `undefined` for an absent key, matching this
1102
+ // patch's own "leave unchanged" — an explicit `null` in the
1103
+ // request body (to clear country/region) is read directly
1104
+ // below rather than through those helpers, which cannot
1105
+ // tell "absent" and "null" apart.
1106
+ ...(country === undefined
1107
+ ? body.country === null
1108
+ ? { country: null }
1109
+ : {}
1110
+ : { country }),
1111
+ ...(region === undefined
1112
+ ? body.region === null
1113
+ ? { region: null }
1114
+ : {}
1115
+ : { region }),
1116
+ ...(taxCategory === undefined ? {} : { taxCategory }),
1117
+ ...(name === undefined ? {} : { name }),
1118
+ ...(rateBp === undefined ? {} : { rateBp }),
1119
+ ...(includedInPrice === undefined ? {} : { includedInPrice }),
1120
+ ...(priority === undefined ? {} : { priority }),
1121
+ ...(active === undefined ? {} : { active }),
1122
+ }),
1123
+ };
1124
+ }
1125
+ if (method === 'DELETE') {
1126
+ permissions.assert('commerce.catalog.write', actor);
1127
+ await options.tax.deleteRule(segments[2] ?? '');
1128
+ return { status: 204, body: null };
1129
+ }
1130
+ }
1131
+ // A simulator that calls the exact resolver the checkout uses, never a
1132
+ // second implementation of "which rule wins" (fiche 34 § pièges) — the
1133
+ // whole point is that the screen can never show a different answer
1134
+ // than the one an order actually gets.
1135
+ if (segments[0] === 'tax' && segments[1] === 'simulate' && segments.length === 2) {
1136
+ if (method === 'POST') {
1137
+ permissions.assert('commerce.read', actor);
1138
+ const body = readObject(request.body);
1139
+ const country = readOptionalString(body, 'country');
1140
+ const zone = country === undefined
1141
+ ? null
1142
+ : { country, region: readOptionalString(body, 'region') ?? null };
1143
+ const rule = await options.tax.resolve(zone, readOptionalString(body, 'taxCategory') ?? 'standard');
1144
+ const outcome = taxFor(readInt(body, 'amountMinor'), rule);
1145
+ return { status: 200, body: { rule, outcome } };
1146
+ }
1147
+ }
1148
+ // ---- shipping (fiche 34 task 2) --------------------------------------
1149
+ if (segments[0] === 'shipping' && segments[1] === 'methods' && segments.length === 2) {
1150
+ if (method === 'GET') {
1151
+ permissions.assert('commerce.read', actor);
1152
+ return { status: 200, body: { methods: await options.shipping.listMethods() } };
1153
+ }
1154
+ if (method === 'POST') {
1155
+ permissions.assert('commerce.catalog.write', actor);
1156
+ const body = readObject(request.body);
1157
+ const kind = readOptionalString(body, 'kind');
1158
+ if (kind !== undefined && !SHIPPING_KINDS.includes(kind)) {
1159
+ throw new CogentaError({
1160
+ code: 'COMMERCE_SHIPPING_METHOD_UNKNOWN',
1161
+ message: `"${kind}" is not a shipping kind.`,
1162
+ hint: `Use one of: ${SHIPPING_KINDS.join(', ')}.`,
1163
+ });
1164
+ }
1165
+ const amountMinor = readOptionalInt(body, 'amountMinor');
1166
+ const perKgMinor = readOptionalInt(body, 'perKgMinor');
1167
+ const position = readOptionalInt(body, 'position');
1168
+ const active = readOptionalBool(body, 'active');
1169
+ return {
1170
+ status: 201,
1171
+ body: await options.shipping.createMethod({
1172
+ label: readString(body, 'label'),
1173
+ currency: readString(body, 'currency'),
1174
+ ...(kind === undefined ? {} : { kind: kind }),
1175
+ country: readOptionalString(body, 'country') ?? null,
1176
+ region: readOptionalString(body, 'region') ?? null,
1177
+ ...(amountMinor === undefined ? {} : { amountMinor }),
1178
+ ...(perKgMinor === undefined ? {} : { perKgMinor }),
1179
+ freeOverMinor: readOptionalInt(body, 'freeOverMinor') ?? null,
1180
+ carrier: readOptionalString(body, 'carrier') ?? null,
1181
+ ...(position === undefined ? {} : { position }),
1182
+ ...(active === undefined ? {} : { active }),
1183
+ }),
1184
+ };
1185
+ }
1186
+ }
1187
+ if (segments[0] === 'shipping' && segments[1] === 'methods' && segments.length === 3) {
1188
+ if (method === 'PATCH') {
1189
+ permissions.assert('commerce.catalog.write', actor);
1190
+ const body = readObject(request.body);
1191
+ const label = readOptionalString(body, 'label');
1192
+ const country = readOptionalString(body, 'country');
1193
+ const region = readOptionalString(body, 'region');
1194
+ const kind = readOptionalString(body, 'kind');
1195
+ if (kind !== undefined && !SHIPPING_KINDS.includes(kind)) {
1196
+ throw new CogentaError({
1197
+ code: 'COMMERCE_SHIPPING_METHOD_UNKNOWN',
1198
+ message: `"${kind}" is not a shipping kind.`,
1199
+ hint: `Use one of: ${SHIPPING_KINDS.join(', ')}.`,
1200
+ });
1201
+ }
1202
+ const currency = readOptionalString(body, 'currency');
1203
+ const amountMinor = readOptionalInt(body, 'amountMinor');
1204
+ const perKgMinor = readOptionalInt(body, 'perKgMinor');
1205
+ const freeOverMinor = readOptionalInt(body, 'freeOverMinor');
1206
+ const carrier = readOptionalString(body, 'carrier');
1207
+ const position = readOptionalInt(body, 'position');
1208
+ const active = readOptionalBool(body, 'active');
1209
+ return {
1210
+ status: 200,
1211
+ body: await options.shipping.updateMethod(segments[2] ?? '', {
1212
+ ...(label === undefined ? {} : { label }),
1213
+ ...(country === undefined
1214
+ ? body.country === null
1215
+ ? { country: null }
1216
+ : {}
1217
+ : { country }),
1218
+ ...(region === undefined
1219
+ ? body.region === null
1220
+ ? { region: null }
1221
+ : {}
1222
+ : { region }),
1223
+ ...(kind === undefined ? {} : { kind: kind }),
1224
+ ...(currency === undefined ? {} : { currency }),
1225
+ ...(amountMinor === undefined ? {} : { amountMinor }),
1226
+ ...(perKgMinor === undefined ? {} : { perKgMinor }),
1227
+ ...(freeOverMinor === undefined
1228
+ ? body.freeOverMinor === null
1229
+ ? { freeOverMinor: null }
1230
+ : {}
1231
+ : { freeOverMinor }),
1232
+ ...(carrier === undefined
1233
+ ? body.carrier === null
1234
+ ? { carrier: null }
1235
+ : {}
1236
+ : { carrier }),
1237
+ ...(position === undefined ? {} : { position }),
1238
+ ...(active === undefined ? {} : { active }),
1239
+ }),
1240
+ };
1241
+ }
1242
+ if (method === 'DELETE') {
1243
+ permissions.assert('commerce.catalog.write', actor);
1244
+ await options.shipping.deleteMethod(segments[2] ?? '');
1245
+ return { status: 204, body: null };
1246
+ }
1247
+ }
1248
+ // Same discipline as the tax simulator: calls `available()`, the exact
1249
+ // function checkout uses to price and offer methods, never a
1250
+ // reimplementation. A method naming a carrier always reports it, so
1251
+ // the screen can say "carrier rate, falls back to the stored rate if
1252
+ // the courier's API does not answer" without guessing at runtime
1253
+ // whether the fallback fired for *this* simulated shipment.
1254
+ if (segments[0] === 'shipping' && segments[1] === 'simulate' && segments.length === 2) {
1255
+ if (method === 'POST') {
1256
+ permissions.assert('commerce.read', actor);
1257
+ const body = readObject(request.body);
1258
+ const country = readOptionalString(body, 'country');
1259
+ const zone = country === undefined
1260
+ ? null
1261
+ : { country, region: readOptionalString(body, 'region') ?? null };
1262
+ const basis = {
1263
+ weightGrams: readOptionalInt(body, 'weightGrams') ?? 0,
1264
+ subtotalMinor: readOptionalInt(body, 'subtotalMinor') ?? 0,
1265
+ currency: readString(body, 'currency'),
1266
+ };
1267
+ return { status: 200, body: { quotes: await options.shipping.available(zone, basis) } };
1268
+ }
1269
+ }
1270
+ // ---- payment (fiche 34 task 3) ---------------------------------------
1271
+ // Presence and health only, never a key's value (fiche 34 § pièges:
1272
+ // "un écran de paiement est une fuite de clé en puissance").
1273
+ if (segments[0] === 'payment' && segments[1] === 'drivers' && segments.length === 2) {
1274
+ if (method === 'GET') {
1275
+ permissions.assert('commerce.read', actor);
1276
+ if (options.payment === undefined) {
1277
+ return { status: 200, body: { drivers: [], testMode: true, webhookUrl: null } };
1278
+ }
1279
+ const { registry, config } = options.payment;
1280
+ const drivers = await Promise.all(registry.list().map(async (driver) => ({
1281
+ name: driver.name,
1282
+ tier: driver.tier,
1283
+ settlesOffline: driver.name === 'manual',
1284
+ // "Configured" never means "reachable" — a key can be present
1285
+ // and wrong. `available()` is the same probe the registry uses
1286
+ // to choose a driver at startup, called again here on demand.
1287
+ configured: await driver.available(config).catch(() => false),
1288
+ selected: (config.driver ?? 'auto') === 'auto' ? undefined : config.driver === driver.name,
1289
+ })));
1290
+ return {
1291
+ status: 200,
1292
+ body: {
1293
+ drivers,
1294
+ testMode: options.payment.testMode,
1295
+ webhookUrl: options.payment.webhookUrl,
1296
+ },
1297
+ };
1298
+ }
1299
+ }
1300
+ // "Bouton tester la connexion" — actually calls the driver, and
1301
+ // returns whatever it says, error included, rather than a boolean.
1302
+ // fiche feedback: this used to gate on `commerce.read` — meaning any
1303
+ // signed-in `viewer` could trigger a real `driver.init()`/`health()`
1304
+ // probe against the site's live payment credentials. No key value
1305
+ // ever leaks (the response is only `ok`/`message`), but *probing* a
1306
+ // payment gateway on demand is a money-adjacent action, not a read;
1307
+ // `commerce.payment.settle` is the closest of the six permissions
1308
+ // (deliberately coarse, see permissions.ts) that already gates
1309
+ // money-touching operations, so it's reused here rather than adding
1310
+ // a seventh permission for one button.
1311
+ if (segments[0] === 'payment' &&
1312
+ segments[1] === 'drivers' &&
1313
+ segments[3] === 'test-connection' &&
1314
+ segments.length === 4) {
1315
+ if (method === 'POST') {
1316
+ permissions.assert('commerce.payment.settle', actor);
1317
+ if (options.payment === undefined) {
1318
+ throw new CogentaError({
1319
+ code: 'COMMERCE_PAYMENT_UNSUPPORTED',
1320
+ message: 'Payment is not configured on this site.',
1321
+ hint: 'Pass a `payment` option to createCommerceAdminRouter.',
1322
+ });
1323
+ }
1324
+ const name = segments[2] ?? '';
1325
+ const driver = options.payment.registry.list().find((entry) => entry.name === name);
1326
+ if (driver === undefined) {
1327
+ throw new CogentaError({
1328
+ code: 'DRIVER_UNKNOWN',
1329
+ message: `No payment driver named "${name}".`,
1330
+ hint: `Available: ${options.payment.registry
1331
+ .list()
1332
+ .map((entry) => entry.name)
1333
+ .join(', ')}.`,
1334
+ });
1335
+ }
1336
+ const reachable = await driver.available(options.payment.config);
1337
+ if (!reachable) {
1338
+ return {
1339
+ status: 200,
1340
+ body: {
1341
+ ok: false,
1342
+ message: `${name} is not reachable with the credentials configured on this server.`,
1343
+ },
1344
+ };
1345
+ }
1346
+ try {
1347
+ // `health()` reports on whatever `init()` last set up — see
1348
+ // `stripePaymentDriver`'s own comment — so the two are always
1349
+ // called back to back, never `health()` alone.
1350
+ await driver.init(options.payment.config);
1351
+ const health = await driver.health();
1352
+ return {
1353
+ status: 200,
1354
+ body: { ok: health.status === 'ok', message: health.message ?? null },
1355
+ };
1356
+ }
1357
+ catch (error) {
1358
+ return {
1359
+ status: 200,
1360
+ body: {
1361
+ ok: false,
1362
+ message: error instanceof Error ? error.message : String(error),
1363
+ },
1364
+ };
1365
+ }
1366
+ }
1367
+ }
452
1368
  return { status: 405, body: { error: { code: 'INTERNAL', message: 'No such route.' } } };
453
1369
  }
454
1370
  catch (error) {
@@ -461,6 +1377,8 @@ const NOT_FOUND_CODES = {
461
1377
  product: 'COMMERCE_PRODUCT_NOT_FOUND',
462
1378
  order: 'COMMERCE_ORDER_NOT_FOUND',
463
1379
  invoice: 'COMMERCE_INVOICE_NOT_FOUND',
1380
+ subscription: 'COMMERCE_SUBSCRIPTION_NOT_FOUND',
1381
+ customer: 'COMMERCE_CUSTOMER_NOT_FOUND',
464
1382
  };
465
1383
  function notFound(what) {
466
1384
  return {