@comity/order 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,68 +1,73 @@
1
+ import { PriceRecord } from "@comity/pricing";
1
2
  import { failure, success } from "@comity/primitives/result";
2
3
  import { Instant } from "@comity/primitives/time";
3
4
  import { transitionOrderStatus } from "../domain/order-transitions.js";
4
5
  import { OrderError } from "../errors/order.js";
5
6
  /**
6
- * Returns a defensive copy of a product snapshot so later mutations of the
7
+ * Returns a defensive copy of a line subject snapshot so later mutations of the
7
8
  * input cannot leak into the order.
8
9
  *
9
- * @param snapshot - The product snapshot to copy.
10
+ * @param subject - The subject snapshot to copy.
10
11
  *
11
- * @returns A defensive copy of the snapshot.
12
+ * @returns A defensive copy of the subject.
12
13
  */
13
- function copyProductSnapshot(snapshot) {
14
- /**
15
- * Returns a defensive copy of a variant snapshot.
16
- *
17
- * @param variant - The variant snapshot to copy.
18
- *
19
- * @returns A defensive copy of the variant.
20
- */
21
- const copyVariant = (variant) => ({
22
- ...variant,
23
- ...(variant.attributes !== undefined
24
- ? { attributes: variant.attributes.map((attribute) => ({ ...attribute })) }
25
- : {}),
26
- ...(variant.options !== undefined
27
- ? { options: variant.options.map((option) => ({ ...option })) }
28
- : {}),
29
- });
14
+ function copySubject(subject) {
15
+ return { ...subject };
16
+ }
17
+ /**
18
+ * Returns a defensive copy of one recorded modifier.
19
+ *
20
+ * @param modifier - The recorded modifier to copy.
21
+ *
22
+ * @returns A defensive copy of the modifier.
23
+ */
24
+ function copyModifierRecord(modifier) {
25
+ const adjustment = modifier.adjustment;
26
+ const copiedAdjustment = adjustment.type === "money"
27
+ ? { type: "money", amount: adjustment.amount, operation: adjustment.operation }
28
+ : { type: "percentage", rate: adjustment.rate, operation: adjustment.operation };
29
+ return { ...modifier, adjustment: copiedAdjustment };
30
+ }
31
+ /**
32
+ * Returns a defensive copy of a price record so later mutations of the input
33
+ * cannot leak into the aggregate.
34
+ *
35
+ * @param price - The record to copy.
36
+ *
37
+ * @returns A defensive copy of the record.
38
+ */
39
+ function copyPriceRecord(price) {
30
40
  return {
31
- ...snapshot,
32
- ...(snapshot.attributes !== undefined
33
- ? { attributes: snapshot.attributes.map((attribute) => ({ ...attribute })) }
34
- : {}),
35
- ...(snapshot.options !== undefined
36
- ? { options: snapshot.options.map((option) => ({ ...option })) }
37
- : {}),
38
- ...(snapshot.variant !== undefined ? { variant: copyVariant(snapshot.variant) } : {}),
39
- ...(snapshot.metadata !== undefined ? { metadata: { ...snapshot.metadata } } : {}),
41
+ ...price,
42
+ currency: { ...price.currency },
43
+ modifiers: price.modifiers.map(copyModifierRecord),
40
44
  };
41
45
  }
42
46
  /**
43
- * Returns a defensive copy of an order item so later mutations of the input
47
+ * Returns a defensive copy of a committed line so later mutations of the input
44
48
  * cannot leak into the order.
45
49
  *
46
- * @param item - The order item to copy.
50
+ * @param line - The line to copy.
47
51
  *
48
- * @returns A defensive copy of the item.
52
+ * @returns A defensive copy of the line.
49
53
  */
50
- function copyOrderItem(item) {
54
+ function copyLine(line) {
51
55
  return {
52
- ...item,
53
- product: copyProductSnapshot(item.product),
54
- ...(item.meta !== undefined ? { meta: { ...item.meta } } : {}),
56
+ ...line,
57
+ subject: copySubject(line.subject),
58
+ price: copyPriceRecord(line.price),
59
+ ...(line.meta !== undefined ? { meta: { ...line.meta } } : {}),
55
60
  };
56
61
  }
57
62
  /**
58
- * Returns whether a quantity is a valid order item quantity: an integer
59
- * greater than or equal to 1.
63
+ * Returns whether a quantity is a valid line quantity: an integer greater than
64
+ * or equal to 1.
60
65
  *
61
66
  * @param quantity - The quantity to validate.
62
67
  *
63
68
  * @returns `true` when the quantity is a positive integer, otherwise `false`.
64
69
  */
65
- function isValidItemQuantity(quantity) {
70
+ function isValidLineQuantity(quantity) {
66
71
  return Number.isInteger(quantity) && quantity >= 1;
67
72
  }
68
73
  /**
@@ -125,21 +130,246 @@ function isSameShippingDestination(address, destination) {
125
130
  address.countryCode === destination.countryCode &&
126
131
  address.capturedAt.epochMilliseconds === destination.capturedAt.epochMilliseconds);
127
132
  }
133
+ /**
134
+ * Evaluates one payment fact against the recorded facts (invariant O-9).
135
+ *
136
+ * The deduplication key is `(paymentId, status)`. An identical repeat is a
137
+ * no-op; the same key with a different amount is a conflict; a fact without a
138
+ * `paymentId` is always appended. A new status for a known `paymentId` is
139
+ * legal only from `authorized` to `captured`, `failed` or `cancelled`; recorded
140
+ * facts are terminal. Amounts are not compared across different statuses.
141
+ *
142
+ * @param existing - The payment facts already recorded on the order.
143
+ * @param fact - The candidate fact.
144
+ *
145
+ * @returns The outcome of the candidate fact.
146
+ */
147
+ function evaluatePaymentFact(existing, fact) {
148
+ if (fact.paymentId === undefined) {
149
+ return "append";
150
+ }
151
+ const samePayment = existing.filter((candidate) => candidate.paymentId === fact.paymentId);
152
+ const sameKey = samePayment.filter((candidate) => candidate.status === fact.status);
153
+ const recordedForKey = sameKey[sameKey.length - 1];
154
+ if (recordedForKey !== undefined) {
155
+ return recordedForKey.amount.equals(fact.amount) ? "noop" : "conflict";
156
+ }
157
+ const previous = samePayment[samePayment.length - 1];
158
+ if (previous === undefined) {
159
+ return "append";
160
+ }
161
+ if (previous.status !== "authorized") {
162
+ return "conflict";
163
+ }
164
+ return fact.status === "authorized" ? "conflict" : "append";
165
+ }
166
+ /**
167
+ * Returns whether two structural currencies are equal on `(code, exponent)`.
168
+ *
169
+ * @param left - The first currency.
170
+ * @param right - The second currency.
171
+ *
172
+ * @returns `true` when both fields match.
173
+ */
174
+ function currencyEquals(left, right) {
175
+ return left.code === right.code && left.exponent === right.exponent;
176
+ }
177
+ /**
178
+ * Compares two recorded modifiers on every persisted field.
179
+ *
180
+ * @param left - The first recorded modifier.
181
+ * @param right - The second recorded modifier.
182
+ *
183
+ * @returns `true` when the modifiers are identical.
184
+ */
185
+ function modifierRecordsEqual(left, right) {
186
+ if (left.code !== right.code ||
187
+ left.label !== right.label ||
188
+ left.kind !== right.kind ||
189
+ left.component !== right.component) {
190
+ return false;
191
+ }
192
+ const leftAdjustment = left.adjustment;
193
+ const rightAdjustment = right.adjustment;
194
+ if (leftAdjustment.type !== rightAdjustment.type ||
195
+ leftAdjustment.operation !== rightAdjustment.operation) {
196
+ return false;
197
+ }
198
+ if (leftAdjustment.type === "money" && rightAdjustment.type === "money") {
199
+ return leftAdjustment.amount === rightAdjustment.amount;
200
+ }
201
+ if (leftAdjustment.type === "percentage" && rightAdjustment.type === "percentage") {
202
+ return (leftAdjustment.rate.amount === rightAdjustment.rate.amount &&
203
+ leftAdjustment.rate.scale === rightAdjustment.rate.scale);
204
+ }
205
+ return false;
206
+ }
207
+ /**
208
+ * Returns whether two records are commitment-equal (ADR-014 A14-12).
209
+ *
210
+ * Compares every persisted field, including format, semantics identifier,
211
+ * currency, amounts and the ordered modifier list. Arithmetic equality alone
212
+ * is insufficient.
213
+ *
214
+ * @param left - The first record.
215
+ * @param right - The second record.
216
+ *
217
+ * @returns `true` when the records are commitment-equal.
218
+ */
219
+ function priceRecordsEqual(left, right) {
220
+ if (left.format !== right.format ||
221
+ left.semanticsId !== right.semanticsId ||
222
+ !currencyEquals(left.currency, right.currency) ||
223
+ left.subtotal !== right.subtotal ||
224
+ left.total !== right.total ||
225
+ left.modifiers.length !== right.modifiers.length) {
226
+ return false;
227
+ }
228
+ return left.modifiers.every((modifier, index) => {
229
+ const other = right.modifiers[index];
230
+ return other !== undefined && modifierRecordsEqual(modifier, other);
231
+ });
232
+ }
233
+ /**
234
+ * Maps a Pricing failure to the approved Order reason (ADR-032 AM-5d).
235
+ *
236
+ * @param error - The Pricing error.
237
+ * @param error.meta - The Pricing error metadata.
238
+ * @param error.meta.reason - The Pricing failure reason.
239
+ * @param orderId - The order identifier, used in the error details.
240
+ *
241
+ * @returns The corresponding Order error.
242
+ */
243
+ function mapPricingFailure(error, orderId) {
244
+ const reason = error.meta.reason === "currency_mismatch" ? "currency_mismatch" : "price_incoherent";
245
+ return new OrderError(reason, { details: { orderId, field: "price" } });
246
+ }
247
+ /**
248
+ * Derives the order-level record from line records and adjustments.
249
+ *
250
+ * @param lines - The lines whose records are the parts.
251
+ * @param adjustments - The order-level adjustments.
252
+ * @param orderId - The order identifier, used in the error details.
253
+ *
254
+ * @returns The derived record, or the mapped Order error.
255
+ */
256
+ function combinePricedLines(lines, adjustments, orderId) {
257
+ const combined = PriceRecord.combine(lines.map((line) => line.price), adjustments);
258
+ if (combined.success === false) {
259
+ return failure(mapPricingFailure(combined.error, orderId));
260
+ }
261
+ return success(combined.value);
262
+ }
263
+ /**
264
+ * Returns the first structural line violation (O-1, O-2), or `null`.
265
+ *
266
+ * @param lines - The candidate lines.
267
+ * @param orderId - The order identifier, used in the error details.
268
+ *
269
+ * @returns The first violation, or `null`.
270
+ */
271
+ function findLineStructureViolation(lines, orderId) {
272
+ const seen = new Set();
273
+ for (const line of lines) {
274
+ if (!isValidLineQuantity(line.quantity)) {
275
+ return new OrderError("invalid_quantity", {
276
+ details: { orderId, itemId: line.lineId, field: "quantity" },
277
+ });
278
+ }
279
+ if (seen.has(line.lineId)) {
280
+ return new OrderError("duplicate_item_id", { details: { orderId, itemId: line.lineId } });
281
+ }
282
+ seen.add(line.lineId);
283
+ }
284
+ return null;
285
+ }
286
+ /**
287
+ * Returns the first O-6 violation against the order currency, or `null`.
288
+ *
289
+ * @param lines - The candidate lines.
290
+ * @param currency - The order currency.
291
+ * @param orderId - The order identifier, used in the error details.
292
+ *
293
+ * @returns The violation, or `null`.
294
+ */
295
+ function findLineCurrencyViolation(lines, currency, orderId) {
296
+ for (const line of lines) {
297
+ if (!currencyEquals(line.price.currency, currency)) {
298
+ return new OrderError("currency_mismatch", {
299
+ details: { orderId, itemId: line.lineId, field: "price" },
300
+ });
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+ /**
306
+ * Returns the first payment-fact violation (O-9) in a supplied sequence, or
307
+ * `null` when the whole sequence is legal.
308
+ *
309
+ * @param payments - The candidate payment facts, in recorded order.
310
+ * @param orderId - The order identifier, used in the error details.
311
+ *
312
+ * @returns The violation, or `null`.
313
+ */
314
+ function findPaymentViolation(payments, orderId) {
315
+ const accepted = [];
316
+ for (const fact of payments) {
317
+ if (evaluatePaymentFact(accepted, fact) === "conflict") {
318
+ return new OrderError("payment_fact_conflict", {
319
+ details: { orderId, field: "payments" },
320
+ });
321
+ }
322
+ accepted.push(fact);
323
+ }
324
+ return null;
325
+ }
326
+ /**
327
+ * Validates the canonical zero record of an empty order (ADR-032 AM-5a).
328
+ *
329
+ * @param price - The supplied order record.
330
+ * @param currency - The order currency.
331
+ * @param orderId - The order identifier, used in the error details.
332
+ *
333
+ * @returns The violation, or `null`.
334
+ */
335
+ function findEmptyOrderPriceViolation(price, currency, orderId) {
336
+ if (!currencyEquals(price.currency, currency)) {
337
+ return new OrderError("currency_mismatch", { details: { orderId, field: "currency" } });
338
+ }
339
+ if (price.subtotal !== 0n || price.total !== 0n || price.modifiers.length !== 0) {
340
+ return new OrderError("price_incoherent", { details: { orderId, field: "price" } });
341
+ }
342
+ const combined = PriceRecord.combine([price], []);
343
+ if (combined.success === false) {
344
+ return mapPricingFailure(combined.error, orderId);
345
+ }
346
+ if (!priceRecordsEqual(combined.value, price)) {
347
+ return new OrderError("price_incoherent", { details: { orderId, field: "price" } });
348
+ }
349
+ return null;
350
+ }
128
351
  /**
129
352
  * Represents an order entity in the system.
130
353
  *
131
354
  * @remark
132
- * An order is a lifecycle aggregate: it owns its status transitions, its line
133
- * items, the applied pricing result, and its historical facts (buyer,
134
- * addresses, payment). It does not validate coupons, does not know promotion
135
- * rules, and does not orchestrate external services — those concerns belong
136
- * to `@comity/pricing`, other Core Modules, and application/domain services.
355
+ * An order is a lifecycle aggregate: it owns its status transitions, its
356
+ * committed lines, the applied pricing result, its advisory version token, and
357
+ * its historical facts (buyer, addresses, payments). It does not validate
358
+ * coupons, does not know promotion rules, and does not orchestrate external
359
+ * services — those concerns belong to `@comity/pricing`, other Core Modules,
360
+ * and application/domain services.
361
+ *
362
+ * @remark
363
+ * The version token is ADVISORY: the aggregate carries it and never increments
364
+ * it. Optimistic concurrency belongs to the repository (Comity precondition
365
+ * ADR-029) and is not implemented here.
137
366
  */
138
367
  export class Order {
139
368
  #id;
140
369
  #status;
141
- #items;
370
+ #lines;
142
371
  #price;
372
+ #currency;
143
373
  #channelId;
144
374
  #customer;
145
375
  #addresses;
@@ -147,8 +377,65 @@ export class Order {
147
377
  #meta;
148
378
  #createdAt;
149
379
  #updatedAt;
380
+ #version;
150
381
  /**
151
- * @param fields - The fields used to create or hydrate the order.
382
+ * Creates a new, validated order (ADR-001, ADR-032 AM-5a).
383
+ *
384
+ * Applies the invariants decidable from creation data — O-1 (integer
385
+ * quantity `>= 1`), O-2 (unique line ids), O-6 (one currency on `(code,
386
+ * exponent)`), O-7 (exact `PriceRecord` commitment equality against
387
+ * `combine`) and O-9 (supplied payment facts) — before any state exists. An
388
+ * empty order is accepted only with the canonical zero record. A new order
389
+ * is `draft` with `version: 0`.
390
+ *
391
+ * @param data - The business data of the order.
392
+ * @param id - The unique identifier of the order, supplied by the caller.
393
+ *
394
+ * @returns The order, or `invalid_quantity` / `duplicate_item_id` /
395
+ * `currency_mismatch` / `price_incoherent` / `payment_fact_conflict`.
396
+ */
397
+ static create(data, id) {
398
+ const orderId = id.toString();
399
+ const structureViolation = findLineStructureViolation(data.lines, orderId);
400
+ if (structureViolation !== null) {
401
+ return failure(structureViolation);
402
+ }
403
+ const lineCurrencyViolation = findLineCurrencyViolation(data.lines, data.currency, orderId);
404
+ if (lineCurrencyViolation !== null) {
405
+ return failure(lineCurrencyViolation);
406
+ }
407
+ if (!currencyEquals(data.price.currency, data.currency)) {
408
+ return failure(new OrderError("currency_mismatch", { details: { orderId, field: "currency" } }));
409
+ }
410
+ if (data.lines.length === 0) {
411
+ const emptyViolation = findEmptyOrderPriceViolation(data.price, data.currency, orderId);
412
+ if (emptyViolation !== null) {
413
+ return failure(emptyViolation);
414
+ }
415
+ }
416
+ else {
417
+ const combined = combinePricedLines(data.lines, data.price.modifiers, orderId);
418
+ if (combined.success === false) {
419
+ return failure(combined.error);
420
+ }
421
+ if (!priceRecordsEqual(combined.value, data.price)) {
422
+ return failure(new OrderError("price_incoherent", { details: { orderId, field: "price" } }));
423
+ }
424
+ }
425
+ const paymentViolation = findPaymentViolation(data.payments ?? [], orderId);
426
+ if (paymentViolation !== null) {
427
+ return failure(paymentViolation);
428
+ }
429
+ return success(new Order({ ...data, status: "draft", version: 0 }, id));
430
+ }
431
+ /**
432
+ * Hydrates an existing order (ADR-001).
433
+ *
434
+ * The constructor performs NO validation: it restores persisted state
435
+ * verbatim, including `version`. Use {@link Order.create} for the validating
436
+ * creation path.
437
+ *
438
+ * @param fields - The persisted fields of the order.
152
439
  * @param id - The unique identifier of the order, supplied by the caller.
153
440
  * The Order never generates its own aggregate ID; hydration preserves the
154
441
  * persisted ID verbatim.
@@ -156,8 +443,9 @@ export class Order {
156
443
  constructor(fields, id) {
157
444
  this.#id = id;
158
445
  this.#status = fields.status ?? "draft";
159
- this.#items = fields.items.map(copyOrderItem);
160
- this.#price = fields.price;
446
+ this.#lines = fields.lines.map(copyLine);
447
+ this.#price = copyPriceRecord(fields.price);
448
+ this.#currency = { ...fields.currency };
161
449
  this.#channelId = fields.channelId;
162
450
  this.#customer =
163
451
  fields.customer !== undefined ? copyCustomerSnapshot(fields.customer) : undefined;
@@ -172,6 +460,7 @@ export class Order {
172
460
  this.#meta = fields.meta ? { ...fields.meta } : undefined;
173
461
  this.#createdAt = fields.createdAt ?? Instant.now();
174
462
  this.#updatedAt = fields.updatedAt ?? this.#createdAt;
463
+ this.#version = fields.version ?? 0;
175
464
  }
176
465
  /**
177
466
  * @returns The unique identifier of the order.
@@ -192,16 +481,22 @@ export class Order {
192
481
  return this.#status;
193
482
  }
194
483
  /**
195
- * @returns The order items.
484
+ * @returns The committed lines.
196
485
  */
197
- get items() {
198
- return this.#items.map(copyOrderItem);
486
+ get lines() {
487
+ return this.#lines.map(copyLine);
199
488
  }
200
489
  /**
201
490
  * @returns The order price.
202
491
  */
203
492
  get price() {
204
- return this.#price;
493
+ return copyPriceRecord(this.#price);
494
+ }
495
+ /**
496
+ * @returns The monetary unit of the order and of every amount it stores.
497
+ */
498
+ get currency() {
499
+ return { ...this.#currency };
205
500
  }
206
501
  /**
207
502
  * @returns The historical buyer fact, if captured.
@@ -249,21 +544,31 @@ export class Order {
249
544
  return this.#updatedAt;
250
545
  }
251
546
  /**
252
- * Updates the applied pricing result and metadata.
547
+ * Returns the persisted-version token (read-only; advisory, never
548
+ * incremented by the aggregate).
253
549
  *
254
- * Status transitions are performed through the domain methods; items are
255
- * mutated through `setItems`/`addItem`/`removeItem`/`updateItemQuantity`.
550
+ * @returns The version token: `0` while unpersisted.
551
+ */
552
+ get version() {
553
+ return this.#version;
554
+ }
555
+ /**
556
+ * Updates non-semantic metadata.
557
+ *
558
+ * Status transitions pass through the domain methods; priced content is
559
+ * replaced atomically through {@link setPricedContent}. Metadata is
560
+ * updatable in any status.
256
561
  *
257
562
  * @param changes - The changes to apply to the order.
563
+ *
564
+ * @returns Success.
258
565
  */
259
566
  update(changes) {
260
- if (changes.price !== undefined) {
261
- this.#price = changes.price;
262
- }
263
567
  if (changes.meta !== undefined) {
264
568
  this.#meta = { ...changes.meta };
265
569
  }
266
570
  this.#updatedAt = Instant.now();
571
+ return success(undefined);
267
572
  }
268
573
  /**
269
574
  * Appends a historical payment fact to the order.
@@ -273,23 +578,45 @@ export class Order {
273
578
  * This records a historical fact only: it performs no payment logic, knows
274
579
  * no payment module, and does not coordinate, authorize, or capture.
275
580
  *
581
+ * Facts dedupe on `(paymentId, status)`: an identical repeat is an idempotent
582
+ * no-op that touches nothing; the same key with a different amount, and an
583
+ * illegal progression for a known `paymentId`, are conflicts. A fact without
584
+ * a `paymentId` is always appended.
585
+ *
276
586
  * @param snapshot - The payment fact to record.
587
+ *
588
+ * @returns Success (including an idempotent no-op), or
589
+ * `payment_fact_conflict`.
277
590
  */
278
591
  attachPayment(snapshot) {
279
- this.#payments = [...(this.#payments ?? []), { ...snapshot }];
592
+ const existing = this.#payments ?? [];
593
+ const outcome = evaluatePaymentFact(existing, snapshot);
594
+ if (outcome === "conflict") {
595
+ return failure(new OrderError("payment_fact_conflict", {
596
+ details: {
597
+ orderId: this.#id.toString(),
598
+ ...(snapshot.paymentId !== undefined ? { field: "payments" } : {}),
599
+ },
600
+ }));
601
+ }
602
+ if (outcome === "noop") {
603
+ return success(undefined);
604
+ }
605
+ this.#payments = [...existing, { ...snapshot }];
280
606
  this.#updatedAt = Instant.now();
607
+ return success(undefined);
281
608
  }
282
609
  /**
283
610
  * Replaces the shipping destination of the order.
284
611
  *
285
612
  * Allowed only while the order is `draft | pending`; in later statuses the
286
- * shipping destination is immutable (ADR-018). The order must hold exactly
287
- * one shipping address fact; a missing or ambiguous one is an error rather
288
- * than a guess. A structurally identical destination is a successful no-op
289
- * that leaves timestamps untouched. The operation never reaches the generic
290
- * `update()` path, never touches billing, customer, item, price, or payment
291
- * facts, and records no history: the aggregate retains only the current
292
- * shipping destination.
613
+ * shipping destination is immutable. The order must hold exactly one shipping
614
+ * address fact; a missing or ambiguous one is an error rather than a guess. A
615
+ * structurally identical destination is a successful no-op that leaves
616
+ * timestamps untouched. The operation never reaches the generic `update()`
617
+ * path, never touches billing, customer, line, price, or payment facts, and
618
+ * records no history: the aggregate retains only the current shipping
619
+ * destination.
293
620
  *
294
621
  * @param destination - The replacement shipping destination. The address
295
622
  * role is owned by this operation and is always stored as `"shipping"`.
@@ -324,156 +651,87 @@ export class Order {
324
651
  return success(undefined);
325
652
  }
326
653
  /**
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);
366
- this.#updatedAt = Instant.now();
367
- return success(undefined);
368
- }
369
- /**
370
- * Appends one item occurrence to the order.
654
+ * Atomically replaces the priced content of the order.
371
655
  *
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.
656
+ * `lines` and `adjustments` are the complete desired priced content. The
657
+ * order-level `PriceRecord` is derived with `PriceRecord.combine`; line
658
+ * modifiers are provenance and are never reapplied or copied. All validation
659
+ * happens before any state changes, so a failure leaves the aggregate
660
+ * completely unchanged. Allowed only while the order is `draft`.
380
661
  *
381
- * @param input - The item data to add, including the caller-assigned
382
- * occurrence ID.
662
+ * @param lines - The complete desired line collection; must be non-empty.
663
+ * @param adjustments - The order-level adjustments, in application order.
383
664
  *
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.
665
+ * @returns Success, or `commitment_immutable` outside `draft` /
666
+ * `currency_mismatch` / `price_incoherent` / `invalid_quantity` /
667
+ * `duplicate_item_id`.
387
668
  */
388
- addItem(input) {
389
- if (!isValidItemQuantity(input.quantity)) {
390
- return failure(new OrderError("invalid_quantity", {
391
- details: {
392
- orderId: this.#id.toString(),
393
- itemId: input.id,
394
- field: "quantity",
395
- },
396
- }));
669
+ setPricedContent(lines, adjustments) {
670
+ if (this.#status !== "draft") {
671
+ return failure(new OrderError("commitment_immutable", { details: { orderId: this.#id.toString() } }));
397
672
  }
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
- }));
673
+ const orderId = this.#id.toString();
674
+ if (lines.length === 0) {
675
+ return failure(new OrderError("price_incoherent", { details: { orderId, field: "lines" } }));
405
676
  }
406
- const item = {
407
- id: input.id,
408
- product: copyProductSnapshot(input.product),
409
- quantity: input.quantity,
410
- price: input.price,
411
- };
412
- this.#items.push(item);
413
- this.#updatedAt = Instant.now();
414
- return success(copyOrderItem(item));
415
- }
416
- /**
417
- * Removes an item from the order.
418
- *
419
- * @param itemId - The order item ID.
420
- *
421
- * @returns A result indicating the success or failure of the removal.
422
- */
423
- removeItem(itemId) {
424
- const index = this.#items.findIndex((item) => item.id === itemId);
425
- if (index === -1) {
426
- return failure(new OrderError("invalid_item", {
427
- details: {
428
- orderId: this.#id.toString(),
429
- itemId,
430
- },
431
- }));
677
+ const structureViolation = findLineStructureViolation(lines, orderId);
678
+ if (structureViolation !== null) {
679
+ return failure(structureViolation);
432
680
  }
433
- this.#items.splice(index, 1);
434
- this.#updatedAt = Instant.now();
435
- return success(undefined);
436
- }
437
- /**
438
- * Updates the quantity of an order item.
439
- *
440
- * @param itemId - The order item ID.
441
- * @param quantity - The new quantity.
442
- *
443
- * @returns A result indicating the success or failure of the update.
444
- */
445
- updateItemQuantity(itemId, quantity) {
446
- if (!isValidItemQuantity(quantity)) {
447
- return failure(new OrderError("invalid_quantity", {
448
- details: {
449
- orderId: this.#id.toString(),
450
- itemId,
451
- field: "quantity",
452
- },
453
- }));
681
+ const currencyViolation = findLineCurrencyViolation(lines, this.#currency, orderId);
682
+ if (currencyViolation !== null) {
683
+ return failure(currencyViolation);
454
684
  }
455
- const index = this.#items.findIndex((candidate) => candidate.id === itemId);
456
- if (index === -1) {
457
- return failure(new OrderError("invalid_item", {
458
- details: {
459
- orderId: this.#id.toString(),
460
- itemId,
461
- },
462
- }));
685
+ const combined = combinePricedLines(lines, adjustments, orderId);
686
+ if (combined.success === false) {
687
+ return failure(combined.error);
463
688
  }
464
- const current = this.#items[index];
465
- if (current !== undefined) {
466
- this.#items[index] = { ...current, quantity };
467
- this.#updatedAt = Instant.now();
689
+ if (!currencyEquals(combined.value.currency, this.#currency)) {
690
+ return failure(new OrderError("currency_mismatch", { details: { orderId, field: "price" } }));
468
691
  }
692
+ this.#lines = lines.map(copyLine);
693
+ this.#price = copyPriceRecord(combined.value);
694
+ this.#updatedAt = Instant.now();
469
695
  return success(undefined);
470
696
  }
471
697
  /**
472
698
  * Submits the order: `draft` → `pending`.
473
699
  *
700
+ * Before leaving `draft`, re-derives the order-level record from the stored
701
+ * line records and the stored order-level modifiers and requires exact
702
+ * commitment equality (ADR-032 AM-5c). An empty draft is validated against
703
+ * the canonical zero record. Reconciliation uses only `PriceRecord.combine`,
704
+ * never `verify()` and never an aggregate-side `PriceRecord.from()`.
705
+ *
474
706
  * @returns A result indicating the success or failure of the transition.
475
707
  */
476
708
  submit() {
709
+ if (this.#status !== "draft") {
710
+ return this.#transition("pending");
711
+ }
712
+ const orderId = this.#id.toString();
713
+ if (this.#lines.length === 0) {
714
+ const emptyViolation = findEmptyOrderPriceViolation(this.#price, this.#currency, orderId);
715
+ if (emptyViolation !== null) {
716
+ return failure(emptyViolation);
717
+ }
718
+ }
719
+ else {
720
+ if (!currencyEquals(this.#price.currency, this.#currency)) {
721
+ return failure(new OrderError("currency_mismatch", { details: { orderId, field: "currency" } }));
722
+ }
723
+ const lineCurrencyViolation = findLineCurrencyViolation(this.#lines, this.#price.currency, orderId);
724
+ if (lineCurrencyViolation !== null) {
725
+ return failure(lineCurrencyViolation);
726
+ }
727
+ const combined = combinePricedLines(this.#lines, this.#price.modifiers, orderId);
728
+ if (combined.success === false) {
729
+ return failure(combined.error);
730
+ }
731
+ if (!priceRecordsEqual(combined.value, this.#price)) {
732
+ return failure(new OrderError("price_incoherent", { details: { orderId, field: "price" } }));
733
+ }
734
+ }
477
735
  return this.#transition("pending");
478
736
  }
479
737
  /**
@@ -495,6 +753,9 @@ export class Order {
495
753
  /**
496
754
  * Cancels the order: `draft | pending | confirmed` → `cancelled`.
497
755
  *
756
+ * Takes no argument: the Order is business-model neutral and the application
757
+ * records why the commitment ended.
758
+ *
498
759
  * @returns A result indicating the success or failure of the transition.
499
760
  */
500
761
  cancel() {
@@ -509,8 +770,9 @@ export class Order {
509
770
  return {
510
771
  id: this.#id,
511
772
  status: this.#status,
512
- items: this.#items.map(copyOrderItem),
513
- price: this.#price,
773
+ lines: this.#lines.map(copyLine),
774
+ price: copyPriceRecord(this.#price),
775
+ currency: { ...this.#currency },
514
776
  channelId: this.#channelId,
515
777
  ...(this.#customer !== undefined ? { customer: copyCustomerSnapshot(this.#customer) } : {}),
516
778
  ...(this.#addresses !== undefined
@@ -524,6 +786,7 @@ export class Order {
524
786
  ...(this.#meta !== undefined ? { meta: { ...this.#meta } } : {}),
525
787
  createdAt: this.#createdAt,
526
788
  updatedAt: this.#updatedAt,
789
+ version: this.#version,
527
790
  capturedAt: Instant.now(),
528
791
  };
529
792
  }