@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.
- package/dist/admin/permissions.d.ts +8 -0
- package/dist/admin/permissions.d.ts.map +1 -1
- package/dist/admin/permissions.js +1 -0
- package/dist/admin/permissions.js.map +1 -1
- package/dist/admin/router.d.ts +47 -5
- package/dist/admin/router.d.ts.map +1 -1
- package/dist/admin/router.js +942 -24
- package/dist/admin/router.js.map +1 -1
- package/dist/cart/store.d.ts +23 -0
- package/dist/cart/store.d.ts.map +1 -1
- package/dist/cart/store.js +49 -2
- package/dist/cart/store.js.map +1 -1
- package/dist/catalog/csv.d.ts +29 -0
- package/dist/catalog/csv.d.ts.map +1 -0
- package/dist/catalog/csv.js +396 -0
- package/dist/catalog/csv.js.map +1 -0
- package/dist/catalog/store.d.ts +24 -4
- package/dist/catalog/store.d.ts.map +1 -1
- package/dist/catalog/store.js +238 -11
- package/dist/catalog/store.js.map +1 -1
- package/dist/catalog/types.d.ts +92 -0
- package/dist/catalog/types.d.ts.map +1 -1
- package/dist/catalog/types.js +20 -0
- package/dist/catalog/types.js.map +1 -1
- package/dist/coupon/store.d.ts +63 -5
- package/dist/coupon/store.d.ts.map +1 -1
- package/dist/coupon/store.js +140 -7
- package/dist/coupon/store.js.map +1 -1
- package/dist/customer/store.d.ts +17 -0
- package/dist/customer/store.d.ts.map +1 -1
- package/dist/customer/store.js +26 -0
- package/dist/customer/store.js.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/invoice/credit-note.d.ts +45 -0
- package/dist/invoice/credit-note.d.ts.map +1 -0
- package/dist/invoice/credit-note.js +109 -0
- package/dist/invoice/credit-note.js.map +1 -0
- package/dist/invoice/sequence.d.ts +21 -0
- package/dist/invoice/sequence.d.ts.map +1 -0
- package/dist/invoice/sequence.js +64 -0
- package/dist/invoice/sequence.js.map +1 -0
- package/dist/invoice/store.d.ts +23 -1
- package/dist/invoice/store.d.ts.map +1 -1
- package/dist/invoice/store.js +23 -56
- package/dist/invoice/store.js.map +1 -1
- package/dist/order/csv.d.ts +24 -0
- package/dist/order/csv.d.ts.map +1 -0
- package/dist/order/csv.js +42 -0
- package/dist/order/csv.js.map +1 -0
- package/dist/order/notify.d.ts +65 -0
- package/dist/order/notify.d.ts.map +1 -0
- package/dist/order/notify.js +180 -0
- package/dist/order/notify.js.map +1 -0
- package/dist/order/store.d.ts +58 -1
- package/dist/order/store.d.ts.map +1 -1
- package/dist/order/store.js +145 -10
- package/dist/order/store.js.map +1 -1
- package/dist/order/types.d.ts +35 -1
- package/dist/order/types.d.ts.map +1 -1
- package/dist/order/types.js +2 -0
- package/dist/order/types.js.map +1 -1
- package/dist/payment/paypal.d.ts +9 -0
- package/dist/payment/paypal.d.ts.map +1 -0
- package/dist/payment/paypal.js +583 -0
- package/dist/payment/paypal.js.map +1 -0
- package/dist/payment/registry.d.ts +15 -6
- package/dist/payment/registry.d.ts.map +1 -1
- package/dist/payment/registry.js +17 -6
- package/dist/payment/registry.js.map +1 -1
- package/dist/payment/types.d.ts +6 -0
- package/dist/payment/types.d.ts.map +1 -1
- package/dist/shipping/store.d.ts +29 -3
- package/dist/shipping/store.d.ts.map +1 -1
- package/dist/shipping/store.js +68 -4
- package/dist/shipping/store.js.map +1 -1
- package/dist/subscription/renewal-notifier.d.ts +17 -0
- package/dist/subscription/renewal-notifier.d.ts.map +1 -0
- package/dist/subscription/renewal-notifier.js +33 -0
- package/dist/subscription/renewal-notifier.js.map +1 -0
- package/dist/subscription/store.d.ts +126 -3
- package/dist/subscription/store.d.ts.map +1 -1
- package/dist/subscription/store.js +348 -8
- package/dist/subscription/store.js.map +1 -1
- package/dist/tables.d.ts +17 -0
- package/dist/tables.d.ts.map +1 -1
- package/dist/tables.js +273 -0
- package/dist/tables.js.map +1 -1
- package/dist/tax/store.d.ts +20 -0
- package/dist/tax/store.d.ts.map +1 -1
- package/dist/tax/store.js +51 -7
- package/dist/tax/store.js.map +1 -1
- package/package.json +3 -2
package/dist/admin/router.js
CHANGED
|
@@ -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 {
|
|
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: {
|
|
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
|
|
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(
|
|
224
|
-
|
|
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.
|
|
258
|
-
|
|
259
|
-
|
|
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.
|
|
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
|
|
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 {
|