@comity/order 0.9.3 → 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.
- package/README.md +75 -18
- package/dist/contracts/customer-snapshot.d.ts +4 -4
- package/dist/contracts/line.d.ts +51 -0
- package/dist/contracts/line.js +2 -0
- package/dist/contracts/order.d.ts +46 -46
- package/dist/entities/order.d.ts +97 -74
- package/dist/entities/order.js +463 -200
- package/dist/errors/order.d.ts +7 -1
- package/dist/errors/order.js +11 -3
- package/dist/index.d.ts +2 -2
- package/dist/repositories/memory.js +11 -1
- package/package.json +4 -4
- package/dist/contracts/item.d.ts +0 -107
- package/dist/contracts/item.js +0 -2
package/dist/entities/order.js
CHANGED
|
@@ -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
|
|
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
|
|
10
|
+
* @param subject - The subject snapshot to copy.
|
|
10
11
|
*
|
|
11
|
-
* @returns A defensive copy of the
|
|
12
|
+
* @returns A defensive copy of the subject.
|
|
12
13
|
*/
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
...
|
|
32
|
-
...
|
|
33
|
-
|
|
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
|
|
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
|
|
50
|
+
* @param line - The line to copy.
|
|
47
51
|
*
|
|
48
|
-
* @returns A defensive copy of the
|
|
52
|
+
* @returns A defensive copy of the line.
|
|
49
53
|
*/
|
|
50
|
-
function
|
|
54
|
+
function copyLine(line) {
|
|
51
55
|
return {
|
|
52
|
-
...
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
-
*
|
|
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
|
|
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
|
|
133
|
-
*
|
|
134
|
-
* addresses,
|
|
135
|
-
* rules, and does not orchestrate external
|
|
136
|
-
* to `@comity/pricing`, other Core Modules,
|
|
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
|
-
#
|
|
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
|
-
*
|
|
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.#
|
|
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
|
|
484
|
+
* @returns The committed lines.
|
|
196
485
|
*/
|
|
197
|
-
get
|
|
198
|
-
return this.#
|
|
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
|
-
*
|
|
547
|
+
* Returns the persisted-version token (read-only; advisory, never
|
|
548
|
+
* incremented by the aggregate).
|
|
253
549
|
*
|
|
254
|
-
*
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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
|
|
382
|
-
*
|
|
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
|
|
385
|
-
*
|
|
386
|
-
*
|
|
665
|
+
* @returns Success, or `commitment_immutable` outside `draft` /
|
|
666
|
+
* `currency_mismatch` / `price_incoherent` / `invalid_quantity` /
|
|
667
|
+
* `duplicate_item_id`.
|
|
387
668
|
*/
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
return failure(new OrderError("
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
407
|
-
|
|
408
|
-
|
|
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.#
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
456
|
-
if (
|
|
457
|
-
return failure(
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
}
|