@comity/order 0.9.0 → 0.9.1
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/README.md +1 -4
- package/dist/cjs/entities/order.js +120 -28
- package/dist/cjs/errors/order.js +2 -0
- package/dist/esm/entities/order.js +120 -28
- package/dist/esm/errors/order.js +2 -0
- package/dist/types/contracts/item.d.ts +8 -1
- package/dist/types/contracts/order.d.ts +14 -2
- package/dist/types/entities/order.d.ts +40 -10
- package/dist/types/errors/order.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ This package does NOT:
|
|
|
30
30
|
|
|
31
31
|
## Public API
|
|
32
32
|
|
|
33
|
-
- `Order` — order entity with lifecycle and item mutations (`addItem`, `removeItem`, `updateItemQuantity`, `submit`, `confirm`, `fulfill`, `cancel`)
|
|
33
|
+
- `Order` — order entity with lifecycle and item mutations (`setItems`, `addItem`, `removeItem`, `updateItemQuantity`, `submit`, `confirm`, `fulfill`, `cancel`)
|
|
34
34
|
- `OrderId` — order identifier value object
|
|
35
35
|
- `OrderRepository` — persistence-boundary contract (`getById`, `search`, `save`)
|
|
36
36
|
- `OrderState`, `OrderCreate`, `OrderUpdate`, `OrderSnapshot`, `OrderData`, `OrderStatus` — domain contracts
|
|
@@ -68,6 +68,3 @@ No exhaustive reference; see docs for constraints.
|
|
|
68
68
|
## Status
|
|
69
69
|
|
|
70
70
|
Stable
|
|
71
|
-
|
|
72
|
-
_Review Completed: 2026-08-01_
|
|
73
|
-
_Compliance Score: N/A% (Green)_
|
|
@@ -42,6 +42,32 @@ function copyProductSnapshot(snapshot) {
|
|
|
42
42
|
...(snapshot.metadata !== undefined ? { metadata: { ...snapshot.metadata } } : {}),
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns a defensive copy of an order item so later mutations of the input
|
|
47
|
+
* cannot leak into the order.
|
|
48
|
+
*
|
|
49
|
+
* @param item - The order item to copy.
|
|
50
|
+
*
|
|
51
|
+
* @returns A defensive copy of the item.
|
|
52
|
+
*/
|
|
53
|
+
function copyOrderItem(item) {
|
|
54
|
+
return {
|
|
55
|
+
...item,
|
|
56
|
+
product: copyProductSnapshot(item.product),
|
|
57
|
+
...(item.meta !== undefined ? { meta: { ...item.meta } } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns whether a quantity is a valid order item quantity: an integer
|
|
62
|
+
* greater than or equal to 1.
|
|
63
|
+
*
|
|
64
|
+
* @param quantity - The quantity to validate.
|
|
65
|
+
*
|
|
66
|
+
* @returns `true` when the quantity is a positive integer, otherwise `false`.
|
|
67
|
+
*/
|
|
68
|
+
function isValidItemQuantity(quantity) {
|
|
69
|
+
return Number.isInteger(quantity) && quantity >= 1;
|
|
70
|
+
}
|
|
45
71
|
/**
|
|
46
72
|
* Returns a defensive copy of a contact so later mutations of the input cannot
|
|
47
73
|
* leak into the order.
|
|
@@ -126,17 +152,22 @@ class Order {
|
|
|
126
152
|
#updatedAt;
|
|
127
153
|
/**
|
|
128
154
|
* @param fields - The fields used to create or hydrate the order.
|
|
129
|
-
* @param id - The unique identifier of the order,
|
|
155
|
+
* @param id - The unique identifier of the order, supplied by the caller.
|
|
156
|
+
* The Order never generates its own aggregate ID; hydration preserves the
|
|
157
|
+
* persisted ID verbatim.
|
|
130
158
|
*/
|
|
131
159
|
constructor(fields, id) {
|
|
132
160
|
this.#id = id;
|
|
133
161
|
this.#status = fields.status ?? "draft";
|
|
134
|
-
this.#items =
|
|
162
|
+
this.#items = fields.items.map(copyOrderItem);
|
|
135
163
|
this.#price = fields.price;
|
|
136
164
|
this.#channelId = fields.channelId;
|
|
137
|
-
this.#customer =
|
|
165
|
+
this.#customer =
|
|
166
|
+
fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
|
|
138
167
|
this.#addresses =
|
|
139
|
-
fields.addresses !== undefined
|
|
168
|
+
fields.addresses !== undefined
|
|
169
|
+
? fields.addresses.map((address) => copyAddressSnapshot(address))
|
|
170
|
+
: undefined;
|
|
140
171
|
this.#payments =
|
|
141
172
|
fields.payments !== undefined
|
|
142
173
|
? fields.payments.map((payment) => ({ ...payment }))
|
|
@@ -146,7 +177,7 @@ class Order {
|
|
|
146
177
|
this.#updatedAt = fields.updatedAt ?? this.#createdAt;
|
|
147
178
|
}
|
|
148
179
|
/**
|
|
149
|
-
* @returns The unique identifier of the order
|
|
180
|
+
* @returns The unique identifier of the order.
|
|
150
181
|
*/
|
|
151
182
|
get id() {
|
|
152
183
|
return this.#id;
|
|
@@ -167,7 +198,7 @@ class Order {
|
|
|
167
198
|
* @returns The order items.
|
|
168
199
|
*/
|
|
169
200
|
get items() {
|
|
170
|
-
return
|
|
201
|
+
return this.#items.map(copyOrderItem);
|
|
171
202
|
}
|
|
172
203
|
/**
|
|
173
204
|
* @returns The order price.
|
|
@@ -224,7 +255,7 @@ class Order {
|
|
|
224
255
|
* Updates the applied pricing result and metadata.
|
|
225
256
|
*
|
|
226
257
|
* Status transitions are performed through the domain methods; items are
|
|
227
|
-
* mutated through `addItem`/`removeItem`/`updateItemQuantity`.
|
|
258
|
+
* mutated through `setItems`/`addItem`/`removeItem`/`updateItemQuantity`.
|
|
228
259
|
*
|
|
229
260
|
* @param changes - The changes to apply to the order.
|
|
230
261
|
*/
|
|
@@ -248,10 +279,7 @@ class Order {
|
|
|
248
279
|
* @param snapshot - The payment fact to record.
|
|
249
280
|
*/
|
|
250
281
|
attachPayment(snapshot) {
|
|
251
|
-
this.#payments = [
|
|
252
|
-
...(this.#payments ?? []),
|
|
253
|
-
{ ...snapshot },
|
|
254
|
-
];
|
|
282
|
+
this.#payments = [...(this.#payments ?? []), { ...snapshot }];
|
|
255
283
|
this.#updatedAt = time_1.Instant.now();
|
|
256
284
|
}
|
|
257
285
|
/**
|
|
@@ -275,7 +303,7 @@ class Order {
|
|
|
275
303
|
if (this.#status !== "draft" && this.#status !== "pending") {
|
|
276
304
|
return (0, result_1.failure)(new order_js_1.OrderError("shipping_destination_immutable", {
|
|
277
305
|
details: {
|
|
278
|
-
|
|
306
|
+
orderId: this.#id.toString(),
|
|
279
307
|
},
|
|
280
308
|
}));
|
|
281
309
|
}
|
|
@@ -285,43 +313,108 @@ class Order {
|
|
|
285
313
|
if (current === undefined || addresses === undefined) {
|
|
286
314
|
return (0, result_1.failure)(new order_js_1.OrderError("ambiguous_shipping_destination", {
|
|
287
315
|
details: {
|
|
288
|
-
|
|
316
|
+
orderId: this.#id.toString(),
|
|
289
317
|
},
|
|
290
318
|
}));
|
|
291
319
|
}
|
|
292
320
|
if (isSameShippingDestination(current, destination)) {
|
|
293
321
|
return (0, result_1.success)(undefined);
|
|
294
322
|
}
|
|
295
|
-
this.#addresses = addresses.map((address) => address.role === "shipping"
|
|
323
|
+
this.#addresses = addresses.map((address) => address.role === "shipping"
|
|
324
|
+
? copyAddressSnapshot({ ...destination, role: "shipping" })
|
|
325
|
+
: address);
|
|
326
|
+
this.#updatedAt = time_1.Instant.now();
|
|
327
|
+
return (0, result_1.success)(undefined);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Replaces the entire item collection with the supplied one (ADR-030).
|
|
331
|
+
*
|
|
332
|
+
* The supplied collection is the complete desired collection: items omitted
|
|
333
|
+
* from it are removed, and no merging, appending, or quantity aggregation
|
|
334
|
+
* occurs. The supplied order is the resulting order and an empty collection
|
|
335
|
+
* is valid. Every entry is validated before any state changes — quantity
|
|
336
|
+
* (integer `>= 1`) first, then ID uniqueness against the IDs already seen —
|
|
337
|
+
* and the first violation rejects the whole operation, leaving the aggregate
|
|
338
|
+
* unchanged. Every successful replacement refreshes `updatedAt`, including
|
|
339
|
+
* identical replacements. Supplied item IDs are preserved verbatim and
|
|
340
|
+
* caller-owned data is defensively copied. Item mutation is not status-gated.
|
|
341
|
+
*
|
|
342
|
+
* @param items - The complete desired item collection.
|
|
343
|
+
*
|
|
344
|
+
* @returns A result indicating the success or failure of the replacement.
|
|
345
|
+
*/
|
|
346
|
+
setItems(items) {
|
|
347
|
+
const seen = new Set();
|
|
348
|
+
for (const item of items) {
|
|
349
|
+
if (!isValidItemQuantity(item.quantity)) {
|
|
350
|
+
return (0, result_1.failure)(new order_js_1.OrderError("invalid_quantity", {
|
|
351
|
+
details: {
|
|
352
|
+
orderId: this.#id.toString(),
|
|
353
|
+
itemId: item.id,
|
|
354
|
+
field: "quantity",
|
|
355
|
+
},
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
if (seen.has(item.id)) {
|
|
359
|
+
return (0, result_1.failure)(new order_js_1.OrderError("duplicate_item_id", {
|
|
360
|
+
details: {
|
|
361
|
+
orderId: this.#id.toString(),
|
|
362
|
+
itemId: item.id,
|
|
363
|
+
},
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
seen.add(item.id);
|
|
367
|
+
}
|
|
368
|
+
this.#items = items.map(copyOrderItem);
|
|
296
369
|
this.#updatedAt = time_1.Instant.now();
|
|
297
370
|
return (0, result_1.success)(undefined);
|
|
298
371
|
}
|
|
299
372
|
/**
|
|
300
|
-
*
|
|
373
|
+
* Appends one item occurrence to the order.
|
|
301
374
|
*
|
|
302
|
-
*
|
|
375
|
+
* The occurrence ID is supplied by the caller in the input: the Order never
|
|
376
|
+
* mints occurrence IDs itself. The ID must be unique within the order —
|
|
377
|
+
* appending an already-used ID is rejected so the `setItems` duplicate-ID
|
|
378
|
+
* invariant cannot be bypassed through this path. Validation runs before
|
|
379
|
+
* any mutation — quantity (integer `>= 1`) first, then ID uniqueness — and
|
|
380
|
+
* failures leave the aggregate unchanged. A successful append refreshes
|
|
381
|
+
* `updatedAt`. Occurrence IDs carry no product meaning: identical
|
|
382
|
+
* product/configuration data may coexist under distinct IDs.
|
|
303
383
|
*
|
|
304
|
-
* @
|
|
305
|
-
*
|
|
384
|
+
* @param input - The item data to add, including the caller-assigned
|
|
385
|
+
* occurrence ID.
|
|
386
|
+
*
|
|
387
|
+
* @returns The created item copy, an `invalid_quantity` error when the
|
|
388
|
+
* quantity is not a positive integer, or a `duplicate_item_id` error when
|
|
389
|
+
* the ID is already used within the order.
|
|
306
390
|
*/
|
|
307
391
|
addItem(input) {
|
|
308
|
-
if (!
|
|
392
|
+
if (!isValidItemQuantity(input.quantity)) {
|
|
309
393
|
return (0, result_1.failure)(new order_js_1.OrderError("invalid_quantity", {
|
|
310
394
|
details: {
|
|
311
|
-
|
|
395
|
+
orderId: this.#id.toString(),
|
|
396
|
+
itemId: input.id,
|
|
312
397
|
field: "quantity",
|
|
313
398
|
},
|
|
314
399
|
}));
|
|
315
400
|
}
|
|
401
|
+
if (this.#items.some((existing) => existing.id === input.id)) {
|
|
402
|
+
return (0, result_1.failure)(new order_js_1.OrderError("duplicate_item_id", {
|
|
403
|
+
details: {
|
|
404
|
+
orderId: this.#id.toString(),
|
|
405
|
+
itemId: input.id,
|
|
406
|
+
},
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
316
409
|
const item = {
|
|
317
|
-
id:
|
|
410
|
+
id: input.id,
|
|
318
411
|
product: copyProductSnapshot(input.product),
|
|
319
412
|
quantity: input.quantity,
|
|
320
413
|
price: input.price,
|
|
321
414
|
};
|
|
322
415
|
this.#items.push(item);
|
|
323
416
|
this.#updatedAt = time_1.Instant.now();
|
|
324
|
-
return (0, result_1.success)(item);
|
|
417
|
+
return (0, result_1.success)(copyOrderItem(item));
|
|
325
418
|
}
|
|
326
419
|
/**
|
|
327
420
|
* Removes an item from the order.
|
|
@@ -335,7 +428,7 @@ class Order {
|
|
|
335
428
|
if (index === -1) {
|
|
336
429
|
return (0, result_1.failure)(new order_js_1.OrderError("invalid_item", {
|
|
337
430
|
details: {
|
|
338
|
-
|
|
431
|
+
orderId: this.#id.toString(),
|
|
339
432
|
itemId,
|
|
340
433
|
},
|
|
341
434
|
}));
|
|
@@ -353,10 +446,10 @@ class Order {
|
|
|
353
446
|
* @returns A result indicating the success or failure of the update.
|
|
354
447
|
*/
|
|
355
448
|
updateItemQuantity(itemId, quantity) {
|
|
356
|
-
if (!
|
|
449
|
+
if (!isValidItemQuantity(quantity)) {
|
|
357
450
|
return (0, result_1.failure)(new order_js_1.OrderError("invalid_quantity", {
|
|
358
451
|
details: {
|
|
359
|
-
|
|
452
|
+
orderId: this.#id.toString(),
|
|
360
453
|
itemId,
|
|
361
454
|
field: "quantity",
|
|
362
455
|
},
|
|
@@ -366,7 +459,7 @@ class Order {
|
|
|
366
459
|
if (index === -1) {
|
|
367
460
|
return (0, result_1.failure)(new order_js_1.OrderError("invalid_item", {
|
|
368
461
|
details: {
|
|
369
|
-
|
|
462
|
+
orderId: this.#id.toString(),
|
|
370
463
|
itemId,
|
|
371
464
|
},
|
|
372
465
|
}));
|
|
@@ -412,7 +505,6 @@ class Order {
|
|
|
412
505
|
}
|
|
413
506
|
/**
|
|
414
507
|
* Creates a snapshot of the current state of the order.
|
|
415
|
-
* Requires the order to have an assigned identifier.
|
|
416
508
|
*
|
|
417
509
|
* @returns A snapshot representing the current state of the order.
|
|
418
510
|
*/
|
|
@@ -420,7 +512,7 @@ class Order {
|
|
|
420
512
|
return {
|
|
421
513
|
id: this.#id,
|
|
422
514
|
status: this.#status,
|
|
423
|
-
items:
|
|
515
|
+
items: this.#items.map(copyOrderItem),
|
|
424
516
|
price: this.#price,
|
|
425
517
|
channelId: this.#channelId,
|
|
426
518
|
...(this.#customer !== undefined ? { customer: copyCustomerSnapshot(this.#customer) } : {}),
|
package/dist/cjs/errors/order.js
CHANGED
|
@@ -8,6 +8,7 @@ const REASON_MESSAGES = {
|
|
|
8
8
|
invalid_status_transition: "Invalid order status transition",
|
|
9
9
|
shipping_destination_immutable: "Shipping destination cannot be changed in the current order status",
|
|
10
10
|
ambiguous_shipping_destination: "Order must contain exactly one shipping destination",
|
|
11
|
+
duplicate_item_id: "Duplicate order item identifier",
|
|
11
12
|
};
|
|
12
13
|
const REASON_HTTP_STATUS = {
|
|
13
14
|
invalid_quantity: 400,
|
|
@@ -15,6 +16,7 @@ const REASON_HTTP_STATUS = {
|
|
|
15
16
|
invalid_status_transition: 409,
|
|
16
17
|
shipping_destination_immutable: 409,
|
|
17
18
|
ambiguous_shipping_destination: 409,
|
|
19
|
+
duplicate_item_id: 400,
|
|
18
20
|
};
|
|
19
21
|
/**
|
|
20
22
|
* Order operation error with typed reasons.
|
|
@@ -39,6 +39,32 @@ function copyProductSnapshot(snapshot) {
|
|
|
39
39
|
...(snapshot.metadata !== undefined ? { metadata: { ...snapshot.metadata } } : {}),
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Returns a defensive copy of an order item so later mutations of the input
|
|
44
|
+
* cannot leak into the order.
|
|
45
|
+
*
|
|
46
|
+
* @param item - The order item to copy.
|
|
47
|
+
*
|
|
48
|
+
* @returns A defensive copy of the item.
|
|
49
|
+
*/
|
|
50
|
+
function copyOrderItem(item) {
|
|
51
|
+
return {
|
|
52
|
+
...item,
|
|
53
|
+
product: copyProductSnapshot(item.product),
|
|
54
|
+
...(item.meta !== undefined ? { meta: { ...item.meta } } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Returns whether a quantity is a valid order item quantity: an integer
|
|
59
|
+
* greater than or equal to 1.
|
|
60
|
+
*
|
|
61
|
+
* @param quantity - The quantity to validate.
|
|
62
|
+
*
|
|
63
|
+
* @returns `true` when the quantity is a positive integer, otherwise `false`.
|
|
64
|
+
*/
|
|
65
|
+
function isValidItemQuantity(quantity) {
|
|
66
|
+
return Number.isInteger(quantity) && quantity >= 1;
|
|
67
|
+
}
|
|
42
68
|
/**
|
|
43
69
|
* Returns a defensive copy of a contact so later mutations of the input cannot
|
|
44
70
|
* leak into the order.
|
|
@@ -123,17 +149,22 @@ export class Order {
|
|
|
123
149
|
#updatedAt;
|
|
124
150
|
/**
|
|
125
151
|
* @param fields - The fields used to create or hydrate the order.
|
|
126
|
-
* @param id - The unique identifier of the order,
|
|
152
|
+
* @param id - The unique identifier of the order, supplied by the caller.
|
|
153
|
+
* The Order never generates its own aggregate ID; hydration preserves the
|
|
154
|
+
* persisted ID verbatim.
|
|
127
155
|
*/
|
|
128
156
|
constructor(fields, id) {
|
|
129
157
|
this.#id = id;
|
|
130
158
|
this.#status = fields.status ?? "draft";
|
|
131
|
-
this.#items =
|
|
159
|
+
this.#items = fields.items.map(copyOrderItem);
|
|
132
160
|
this.#price = fields.price;
|
|
133
161
|
this.#channelId = fields.channelId;
|
|
134
|
-
this.#customer =
|
|
162
|
+
this.#customer =
|
|
163
|
+
fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
|
|
135
164
|
this.#addresses =
|
|
136
|
-
fields.addresses !== undefined
|
|
165
|
+
fields.addresses !== undefined
|
|
166
|
+
? fields.addresses.map((address) => copyAddressSnapshot(address))
|
|
167
|
+
: undefined;
|
|
137
168
|
this.#payments =
|
|
138
169
|
fields.payments !== undefined
|
|
139
170
|
? fields.payments.map((payment) => ({ ...payment }))
|
|
@@ -143,7 +174,7 @@ export class Order {
|
|
|
143
174
|
this.#updatedAt = fields.updatedAt ?? this.#createdAt;
|
|
144
175
|
}
|
|
145
176
|
/**
|
|
146
|
-
* @returns The unique identifier of the order
|
|
177
|
+
* @returns The unique identifier of the order.
|
|
147
178
|
*/
|
|
148
179
|
get id() {
|
|
149
180
|
return this.#id;
|
|
@@ -164,7 +195,7 @@ export class Order {
|
|
|
164
195
|
* @returns The order items.
|
|
165
196
|
*/
|
|
166
197
|
get items() {
|
|
167
|
-
return
|
|
198
|
+
return this.#items.map(copyOrderItem);
|
|
168
199
|
}
|
|
169
200
|
/**
|
|
170
201
|
* @returns The order price.
|
|
@@ -221,7 +252,7 @@ export class Order {
|
|
|
221
252
|
* Updates the applied pricing result and metadata.
|
|
222
253
|
*
|
|
223
254
|
* Status transitions are performed through the domain methods; items are
|
|
224
|
-
* mutated through `addItem`/`removeItem`/`updateItemQuantity`.
|
|
255
|
+
* mutated through `setItems`/`addItem`/`removeItem`/`updateItemQuantity`.
|
|
225
256
|
*
|
|
226
257
|
* @param changes - The changes to apply to the order.
|
|
227
258
|
*/
|
|
@@ -245,10 +276,7 @@ export class Order {
|
|
|
245
276
|
* @param snapshot - The payment fact to record.
|
|
246
277
|
*/
|
|
247
278
|
attachPayment(snapshot) {
|
|
248
|
-
this.#payments = [
|
|
249
|
-
...(this.#payments ?? []),
|
|
250
|
-
{ ...snapshot },
|
|
251
|
-
];
|
|
279
|
+
this.#payments = [...(this.#payments ?? []), { ...snapshot }];
|
|
252
280
|
this.#updatedAt = Instant.now();
|
|
253
281
|
}
|
|
254
282
|
/**
|
|
@@ -272,7 +300,7 @@ export class Order {
|
|
|
272
300
|
if (this.#status !== "draft" && this.#status !== "pending") {
|
|
273
301
|
return failure(new OrderError("shipping_destination_immutable", {
|
|
274
302
|
details: {
|
|
275
|
-
|
|
303
|
+
orderId: this.#id.toString(),
|
|
276
304
|
},
|
|
277
305
|
}));
|
|
278
306
|
}
|
|
@@ -282,43 +310,108 @@ export class Order {
|
|
|
282
310
|
if (current === undefined || addresses === undefined) {
|
|
283
311
|
return failure(new OrderError("ambiguous_shipping_destination", {
|
|
284
312
|
details: {
|
|
285
|
-
|
|
313
|
+
orderId: this.#id.toString(),
|
|
286
314
|
},
|
|
287
315
|
}));
|
|
288
316
|
}
|
|
289
317
|
if (isSameShippingDestination(current, destination)) {
|
|
290
318
|
return success(undefined);
|
|
291
319
|
}
|
|
292
|
-
this.#addresses = addresses.map((address) => address.role === "shipping"
|
|
320
|
+
this.#addresses = addresses.map((address) => address.role === "shipping"
|
|
321
|
+
? copyAddressSnapshot({ ...destination, role: "shipping" })
|
|
322
|
+
: address);
|
|
323
|
+
this.#updatedAt = Instant.now();
|
|
324
|
+
return success(undefined);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Replaces the entire item collection with the supplied one (ADR-030).
|
|
328
|
+
*
|
|
329
|
+
* The supplied collection is the complete desired collection: items omitted
|
|
330
|
+
* from it are removed, and no merging, appending, or quantity aggregation
|
|
331
|
+
* occurs. The supplied order is the resulting order and an empty collection
|
|
332
|
+
* is valid. Every entry is validated before any state changes — quantity
|
|
333
|
+
* (integer `>= 1`) first, then ID uniqueness against the IDs already seen —
|
|
334
|
+
* and the first violation rejects the whole operation, leaving the aggregate
|
|
335
|
+
* unchanged. Every successful replacement refreshes `updatedAt`, including
|
|
336
|
+
* identical replacements. Supplied item IDs are preserved verbatim and
|
|
337
|
+
* caller-owned data is defensively copied. Item mutation is not status-gated.
|
|
338
|
+
*
|
|
339
|
+
* @param items - The complete desired item collection.
|
|
340
|
+
*
|
|
341
|
+
* @returns A result indicating the success or failure of the replacement.
|
|
342
|
+
*/
|
|
343
|
+
setItems(items) {
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
if (!isValidItemQuantity(item.quantity)) {
|
|
347
|
+
return failure(new OrderError("invalid_quantity", {
|
|
348
|
+
details: {
|
|
349
|
+
orderId: this.#id.toString(),
|
|
350
|
+
itemId: item.id,
|
|
351
|
+
field: "quantity",
|
|
352
|
+
},
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
if (seen.has(item.id)) {
|
|
356
|
+
return failure(new OrderError("duplicate_item_id", {
|
|
357
|
+
details: {
|
|
358
|
+
orderId: this.#id.toString(),
|
|
359
|
+
itemId: item.id,
|
|
360
|
+
},
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
seen.add(item.id);
|
|
364
|
+
}
|
|
365
|
+
this.#items = items.map(copyOrderItem);
|
|
293
366
|
this.#updatedAt = Instant.now();
|
|
294
367
|
return success(undefined);
|
|
295
368
|
}
|
|
296
369
|
/**
|
|
297
|
-
*
|
|
370
|
+
* Appends one item occurrence to the order.
|
|
298
371
|
*
|
|
299
|
-
*
|
|
372
|
+
* The occurrence ID is supplied by the caller in the input: the Order never
|
|
373
|
+
* mints occurrence IDs itself. The ID must be unique within the order —
|
|
374
|
+
* appending an already-used ID is rejected so the `setItems` duplicate-ID
|
|
375
|
+
* invariant cannot be bypassed through this path. Validation runs before
|
|
376
|
+
* any mutation — quantity (integer `>= 1`) first, then ID uniqueness — and
|
|
377
|
+
* failures leave the aggregate unchanged. A successful append refreshes
|
|
378
|
+
* `updatedAt`. Occurrence IDs carry no product meaning: identical
|
|
379
|
+
* product/configuration data may coexist under distinct IDs.
|
|
300
380
|
*
|
|
301
|
-
* @
|
|
302
|
-
*
|
|
381
|
+
* @param input - The item data to add, including the caller-assigned
|
|
382
|
+
* occurrence ID.
|
|
383
|
+
*
|
|
384
|
+
* @returns The created item copy, an `invalid_quantity` error when the
|
|
385
|
+
* quantity is not a positive integer, or a `duplicate_item_id` error when
|
|
386
|
+
* the ID is already used within the order.
|
|
303
387
|
*/
|
|
304
388
|
addItem(input) {
|
|
305
|
-
if (!
|
|
389
|
+
if (!isValidItemQuantity(input.quantity)) {
|
|
306
390
|
return failure(new OrderError("invalid_quantity", {
|
|
307
391
|
details: {
|
|
308
|
-
|
|
392
|
+
orderId: this.#id.toString(),
|
|
393
|
+
itemId: input.id,
|
|
309
394
|
field: "quantity",
|
|
310
395
|
},
|
|
311
396
|
}));
|
|
312
397
|
}
|
|
398
|
+
if (this.#items.some((existing) => existing.id === input.id)) {
|
|
399
|
+
return failure(new OrderError("duplicate_item_id", {
|
|
400
|
+
details: {
|
|
401
|
+
orderId: this.#id.toString(),
|
|
402
|
+
itemId: input.id,
|
|
403
|
+
},
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
313
406
|
const item = {
|
|
314
|
-
id:
|
|
407
|
+
id: input.id,
|
|
315
408
|
product: copyProductSnapshot(input.product),
|
|
316
409
|
quantity: input.quantity,
|
|
317
410
|
price: input.price,
|
|
318
411
|
};
|
|
319
412
|
this.#items.push(item);
|
|
320
413
|
this.#updatedAt = Instant.now();
|
|
321
|
-
return success(item);
|
|
414
|
+
return success(copyOrderItem(item));
|
|
322
415
|
}
|
|
323
416
|
/**
|
|
324
417
|
* Removes an item from the order.
|
|
@@ -332,7 +425,7 @@ export class Order {
|
|
|
332
425
|
if (index === -1) {
|
|
333
426
|
return failure(new OrderError("invalid_item", {
|
|
334
427
|
details: {
|
|
335
|
-
|
|
428
|
+
orderId: this.#id.toString(),
|
|
336
429
|
itemId,
|
|
337
430
|
},
|
|
338
431
|
}));
|
|
@@ -350,10 +443,10 @@ export class Order {
|
|
|
350
443
|
* @returns A result indicating the success or failure of the update.
|
|
351
444
|
*/
|
|
352
445
|
updateItemQuantity(itemId, quantity) {
|
|
353
|
-
if (!
|
|
446
|
+
if (!isValidItemQuantity(quantity)) {
|
|
354
447
|
return failure(new OrderError("invalid_quantity", {
|
|
355
448
|
details: {
|
|
356
|
-
|
|
449
|
+
orderId: this.#id.toString(),
|
|
357
450
|
itemId,
|
|
358
451
|
field: "quantity",
|
|
359
452
|
},
|
|
@@ -363,7 +456,7 @@ export class Order {
|
|
|
363
456
|
if (index === -1) {
|
|
364
457
|
return failure(new OrderError("invalid_item", {
|
|
365
458
|
details: {
|
|
366
|
-
|
|
459
|
+
orderId: this.#id.toString(),
|
|
367
460
|
itemId,
|
|
368
461
|
},
|
|
369
462
|
}));
|
|
@@ -409,7 +502,6 @@ export class Order {
|
|
|
409
502
|
}
|
|
410
503
|
/**
|
|
411
504
|
* Creates a snapshot of the current state of the order.
|
|
412
|
-
* Requires the order to have an assigned identifier.
|
|
413
505
|
*
|
|
414
506
|
* @returns A snapshot representing the current state of the order.
|
|
415
507
|
*/
|
|
@@ -417,7 +509,7 @@ export class Order {
|
|
|
417
509
|
return {
|
|
418
510
|
id: this.#id,
|
|
419
511
|
status: this.#status,
|
|
420
|
-
items:
|
|
512
|
+
items: this.#items.map(copyOrderItem),
|
|
421
513
|
price: this.#price,
|
|
422
514
|
channelId: this.#channelId,
|
|
423
515
|
...(this.#customer !== undefined ? { customer: copyCustomerSnapshot(this.#customer) } : {}),
|
package/dist/esm/errors/order.js
CHANGED
|
@@ -5,6 +5,7 @@ const REASON_MESSAGES = {
|
|
|
5
5
|
invalid_status_transition: "Invalid order status transition",
|
|
6
6
|
shipping_destination_immutable: "Shipping destination cannot be changed in the current order status",
|
|
7
7
|
ambiguous_shipping_destination: "Order must contain exactly one shipping destination",
|
|
8
|
+
duplicate_item_id: "Duplicate order item identifier",
|
|
8
9
|
};
|
|
9
10
|
const REASON_HTTP_STATUS = {
|
|
10
11
|
invalid_quantity: 400,
|
|
@@ -12,6 +13,7 @@ const REASON_HTTP_STATUS = {
|
|
|
12
13
|
invalid_status_transition: 409,
|
|
13
14
|
shipping_destination_immutable: 409,
|
|
14
15
|
ambiguous_shipping_destination: 409,
|
|
16
|
+
duplicate_item_id: 400,
|
|
15
17
|
};
|
|
16
18
|
/**
|
|
17
19
|
* Order operation error with typed reasons.
|
|
@@ -81,7 +81,14 @@ export interface OrderProductSnapshot {
|
|
|
81
81
|
* identity, lifecycle, or repository.
|
|
82
82
|
*/
|
|
83
83
|
export interface OrderItem {
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Stable technical key for one occurrence within the order.
|
|
86
|
+
*
|
|
87
|
+
* The key identifies the occurrence, not the product: it carries no
|
|
88
|
+
* commercial equivalence meaning and must never be derived by comparing
|
|
89
|
+
* product data. Occurrence keys are unique within the order, preserved
|
|
90
|
+
* across retention/updates, and never reassigned after removal.
|
|
91
|
+
*/
|
|
85
92
|
readonly id: string;
|
|
86
93
|
/** Associated product snapshot. */
|
|
87
94
|
readonly product: OrderProductSnapshot;
|
|
@@ -63,7 +63,9 @@ export type OrderSnapshot = Readonly<OrderState & {
|
|
|
63
63
|
/**
|
|
64
64
|
* Data required to create a new order.
|
|
65
65
|
*
|
|
66
|
-
* The identifier
|
|
66
|
+
* The identifier is supplied separately as the entity constructor's `id`
|
|
67
|
+
* argument: the caller (application service or hydration path) owns ID
|
|
68
|
+
* generation and the Order never generates its own aggregate ID. An order
|
|
67
69
|
* without a supplied status starts as {@link OrderStatus."draft"}. Lifecycle
|
|
68
70
|
* timestamps are optional so the same contract supports both creation and
|
|
69
71
|
* hydration from persisted state (ADR-001).
|
|
@@ -81,7 +83,7 @@ export type OrderCreate = OrderData & {
|
|
|
81
83
|
*
|
|
82
84
|
* Status transitions are not part of the update contract: they must pass
|
|
83
85
|
* through the entity's domain methods (`submit`, `confirm`, `fulfill`,
|
|
84
|
-
* `cancel`). Items are mutated through `addItem`/`removeItem`/
|
|
86
|
+
* `cancel`). Items are mutated through `setItems`/`addItem`/`removeItem`/
|
|
85
87
|
* `updateItemQuantity`, which protect the order's invariants.
|
|
86
88
|
*/
|
|
87
89
|
export interface OrderUpdate {
|
|
@@ -99,6 +101,16 @@ export interface OrderUpdate {
|
|
|
99
101
|
* from `ProductProjection`: the Order never depends on `@comity/catalog`.
|
|
100
102
|
*/
|
|
101
103
|
export interface OrderItemInput {
|
|
104
|
+
/**
|
|
105
|
+
* Caller-assigned technical key for the new occurrence within the order.
|
|
106
|
+
*
|
|
107
|
+
* The ID identifies one occurrence, not a product: two occurrences may
|
|
108
|
+
* carry identical product/configuration data under distinct IDs. It must be
|
|
109
|
+
* unique within the order; the aggregate rejects duplicates. The ID is
|
|
110
|
+
* generated outside the Order (application service or caller); the Order
|
|
111
|
+
* never mints occurrence IDs itself.
|
|
112
|
+
*/
|
|
113
|
+
readonly id: string;
|
|
102
114
|
/** Product snapshot to add to the order. */
|
|
103
115
|
readonly product: OrderProductSnapshot;
|
|
104
116
|
/** Quantity to add. */
|
|
@@ -23,13 +23,15 @@ export declare class Order {
|
|
|
23
23
|
#private;
|
|
24
24
|
/**
|
|
25
25
|
* @param fields - The fields used to create or hydrate the order.
|
|
26
|
-
* @param id - The unique identifier of the order,
|
|
26
|
+
* @param id - The unique identifier of the order, supplied by the caller.
|
|
27
|
+
* The Order never generates its own aggregate ID; hydration preserves the
|
|
28
|
+
* persisted ID verbatim.
|
|
27
29
|
*/
|
|
28
|
-
constructor(fields: OrderCreate, id
|
|
30
|
+
constructor(fields: OrderCreate, id: OrderId);
|
|
29
31
|
/**
|
|
30
|
-
* @returns The unique identifier of the order
|
|
32
|
+
* @returns The unique identifier of the order.
|
|
31
33
|
*/
|
|
32
|
-
get id(): OrderId
|
|
34
|
+
get id(): OrderId;
|
|
33
35
|
/**
|
|
34
36
|
* @returns The commercial channel through which the order was placed.
|
|
35
37
|
*/
|
|
@@ -74,7 +76,7 @@ export declare class Order {
|
|
|
74
76
|
* Updates the applied pricing result and metadata.
|
|
75
77
|
*
|
|
76
78
|
* Status transitions are performed through the domain methods; items are
|
|
77
|
-
* mutated through `addItem`/`removeItem`/`updateItemQuantity`.
|
|
79
|
+
* mutated through `setItems`/`addItem`/`removeItem`/`updateItemQuantity`.
|
|
78
80
|
*
|
|
79
81
|
* @param changes - The changes to apply to the order.
|
|
80
82
|
*/
|
|
@@ -109,12 +111,41 @@ export declare class Order {
|
|
|
109
111
|
*/
|
|
110
112
|
changeShippingDestination(destination: Omit<OrderAddressSnapshot, "role">): Result<void, OrderError>;
|
|
111
113
|
/**
|
|
112
|
-
*
|
|
114
|
+
* Replaces the entire item collection with the supplied one (ADR-030).
|
|
113
115
|
*
|
|
114
|
-
*
|
|
116
|
+
* The supplied collection is the complete desired collection: items omitted
|
|
117
|
+
* from it are removed, and no merging, appending, or quantity aggregation
|
|
118
|
+
* occurs. The supplied order is the resulting order and an empty collection
|
|
119
|
+
* is valid. Every entry is validated before any state changes — quantity
|
|
120
|
+
* (integer `>= 1`) first, then ID uniqueness against the IDs already seen —
|
|
121
|
+
* and the first violation rejects the whole operation, leaving the aggregate
|
|
122
|
+
* unchanged. Every successful replacement refreshes `updatedAt`, including
|
|
123
|
+
* identical replacements. Supplied item IDs are preserved verbatim and
|
|
124
|
+
* caller-owned data is defensively copied. Item mutation is not status-gated.
|
|
115
125
|
*
|
|
116
|
-
* @
|
|
117
|
-
*
|
|
126
|
+
* @param items - The complete desired item collection.
|
|
127
|
+
*
|
|
128
|
+
* @returns A result indicating the success or failure of the replacement.
|
|
129
|
+
*/
|
|
130
|
+
setItems(items: ReadonlyArray<OrderItem>): Result<void, OrderError>;
|
|
131
|
+
/**
|
|
132
|
+
* Appends one item occurrence to the order.
|
|
133
|
+
*
|
|
134
|
+
* The occurrence ID is supplied by the caller in the input: the Order never
|
|
135
|
+
* mints occurrence IDs itself. The ID must be unique within the order —
|
|
136
|
+
* appending an already-used ID is rejected so the `setItems` duplicate-ID
|
|
137
|
+
* invariant cannot be bypassed through this path. Validation runs before
|
|
138
|
+
* any mutation — quantity (integer `>= 1`) first, then ID uniqueness — and
|
|
139
|
+
* failures leave the aggregate unchanged. A successful append refreshes
|
|
140
|
+
* `updatedAt`. Occurrence IDs carry no product meaning: identical
|
|
141
|
+
* product/configuration data may coexist under distinct IDs.
|
|
142
|
+
*
|
|
143
|
+
* @param input - The item data to add, including the caller-assigned
|
|
144
|
+
* occurrence ID.
|
|
145
|
+
*
|
|
146
|
+
* @returns The created item copy, an `invalid_quantity` error when the
|
|
147
|
+
* quantity is not a positive integer, or a `duplicate_item_id` error when
|
|
148
|
+
* the ID is already used within the order.
|
|
118
149
|
*/
|
|
119
150
|
addItem(input: OrderItemInput): Result<OrderItem, OrderError>;
|
|
120
151
|
/**
|
|
@@ -160,7 +191,6 @@ export declare class Order {
|
|
|
160
191
|
cancel(): Result<void, OrderError>;
|
|
161
192
|
/**
|
|
162
193
|
* Creates a snapshot of the current state of the order.
|
|
163
|
-
* Requires the order to have an assigned identifier.
|
|
164
194
|
*
|
|
165
195
|
* @returns A snapshot representing the current state of the order.
|
|
166
196
|
*/
|
|
@@ -8,7 +8,7 @@ import { BaseError } from "@comity/primitives/errors";
|
|
|
8
8
|
* cross-module failures (`repository_error`, `insufficient_stock`,
|
|
9
9
|
* `product_not_available`, coupon validation) belong to other layers/modules.
|
|
10
10
|
*/
|
|
11
|
-
export type OrderErrorReason = "invalid_quantity" | "invalid_item" | "invalid_status_transition" | "shipping_destination_immutable" | "ambiguous_shipping_destination";
|
|
11
|
+
export type OrderErrorReason = "invalid_quantity" | "invalid_item" | "invalid_status_transition" | "shipping_destination_immutable" | "ambiguous_shipping_destination" | "duplicate_item_id";
|
|
12
12
|
/**
|
|
13
13
|
* Order error metadata.
|
|
14
14
|
*/
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { OrderAddressRole, OrderAddressSnapshot } from "./contracts/address
|
|
|
2
2
|
export type { OrderContact } from "./contracts/contact.js";
|
|
3
3
|
export type { OrderCustomerSnapshot } from "./contracts/customer-snapshot.js";
|
|
4
4
|
export type { OrderItem, OrderProductAttribute, OrderProductOption, OrderProductSnapshot, OrderVariantSnapshot, } from "./contracts/item.js";
|
|
5
|
-
export type { OrderRepository, OrderRepositoryContext, OrderSearchCriteria, OrderSearchResult } from "./contracts/order-repository.js";
|
|
5
|
+
export type { OrderRepository, OrderRepositoryContext, OrderSearchCriteria, OrderSearchResult, } from "./contracts/order-repository.js";
|
|
6
6
|
export type { OrderCreate, OrderData, OrderItemInput, OrderSnapshot, OrderState, OrderStatus, OrderUpdate, } from "./contracts/order.js";
|
|
7
7
|
export type { OrderPaymentSnapshot, OrderPaymentStatus } from "./contracts/payment-snapshot.js";
|
|
8
8
|
export { Order } from "./entities/order.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comity/order",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Order domain contracts for Comity commerce modules.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -84,12 +84,12 @@
|
|
|
84
84
|
},
|
|
85
85
|
"sideEffects": false,
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@comity/
|
|
88
|
-
"@comity/
|
|
89
|
-
"@comity/primitives": "0.9.
|
|
87
|
+
"@comity/organization": "0.9.1",
|
|
88
|
+
"@comity/pricing": "0.9.1",
|
|
89
|
+
"@comity/primitives": "0.9.1"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
|
-
"@types/node": "^24.
|
|
92
|
+
"@types/node": "^24.19.1",
|
|
93
93
|
"typescript": "^5.9.3"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|