@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 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, if it has been assigned.
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 = [...fields.items];
162
+ this.#items = fields.items.map(copyOrderItem);
135
163
  this.#price = fields.price;
136
164
  this.#channelId = fields.channelId;
137
- this.#customer = fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
165
+ this.#customer =
166
+ fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
138
167
  this.#addresses =
139
- fields.addresses !== undefined ? fields.addresses.map((address) => copyAddressSnapshot(address)) : 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, if it has been assigned.
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 [...this.#items];
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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" ? copyAddressSnapshot({ ...destination, role: "shipping" }) : address);
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
- * Adds an item to the order.
373
+ * Appends one item occurrence to the order.
301
374
  *
302
- * @param input - The item data to add.
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
- * @returns The created item, or an `invalid_quantity` error when the
305
- * quantity is not a positive integer.
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 (!Number.isInteger(input.quantity) || input.quantity < 1) {
392
+ if (!isValidItemQuantity(input.quantity)) {
309
393
  return (0, result_1.failure)(new order_js_1.OrderError("invalid_quantity", {
310
394
  details: {
311
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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: crypto.randomUUID(),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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 (!Number.isInteger(quantity) || quantity < 1) {
449
+ if (!isValidItemQuantity(quantity)) {
357
450
  return (0, result_1.failure)(new order_js_1.OrderError("invalid_quantity", {
358
451
  details: {
359
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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: [...this.#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) } : {}),
@@ -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, if it has been assigned.
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 = [...fields.items];
159
+ this.#items = fields.items.map(copyOrderItem);
132
160
  this.#price = fields.price;
133
161
  this.#channelId = fields.channelId;
134
- this.#customer = fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
162
+ this.#customer =
163
+ fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
135
164
  this.#addresses =
136
- fields.addresses !== undefined ? fields.addresses.map((address) => copyAddressSnapshot(address)) : 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, if it has been assigned.
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 [...this.#items];
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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" ? copyAddressSnapshot({ ...destination, role: "shipping" }) : address);
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
- * Adds an item to the order.
370
+ * Appends one item occurrence to the order.
298
371
  *
299
- * @param input - The item data to add.
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
- * @returns The created item, or an `invalid_quantity` error when the
302
- * quantity is not a positive integer.
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 (!Number.isInteger(input.quantity) || input.quantity < 1) {
389
+ if (!isValidItemQuantity(input.quantity)) {
306
390
  return failure(new OrderError("invalid_quantity", {
307
391
  details: {
308
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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: crypto.randomUUID(),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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 (!Number.isInteger(quantity) || quantity < 1) {
446
+ if (!isValidItemQuantity(quantity)) {
354
447
  return failure(new OrderError("invalid_quantity", {
355
448
  details: {
356
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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
- ...(this.#id !== undefined ? { orderId: this.#id.toString() } : {}),
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: [...this.#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) } : {}),
@@ -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
- /** Unique item identifier within the order. */
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 and the initial status are managed by the entity: an order
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, if it has been assigned.
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?: OrderId);
30
+ constructor(fields: OrderCreate, id: OrderId);
29
31
  /**
30
- * @returns The unique identifier of the order, if it has been assigned.
32
+ * @returns The unique identifier of the order.
31
33
  */
32
- get id(): OrderId | undefined;
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
- * Adds an item to the order.
114
+ * Replaces the entire item collection with the supplied one (ADR-030).
113
115
  *
114
- * @param input - The item data to add.
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
- * @returns The created item, or an `invalid_quantity` error when the
117
- * quantity is not a positive integer.
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
  */
@@ -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.0",
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/pricing": "0.9.0",
88
- "@comity/organization": "0.9.0",
89
- "@comity/primitives": "0.9.0"
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.13.4",
92
+ "@types/node": "^24.19.1",
93
93
  "typescript": "^5.9.3"
94
94
  },
95
95
  "scripts": {