@unchainedshop/core-orders 4.9.0 → 4.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/lib/db/OrderLocksCollection.d.ts +1 -0
- package/lib/db/OrderLocksCollection.js +29 -16
- package/lib/migrations/20260907120000-order-calculation-net.d.ts +4 -0
- package/lib/migrations/20260907120000-order-calculation-net.js +125 -0
- package/lib/module/configureOrdersModule.d.ts +1 -0
- package/lib/orders-settings.d.ts +0 -1
- package/lib/orders-settings.js +1 -3
- package/package.json +2 -2
|
@@ -7,4 +7,5 @@ export interface OrderLock {
|
|
|
7
7
|
export declare const OrderLocksCollection: (db: mongodb.Db) => Promise<mongodb.Collection<OrderLock>>;
|
|
8
8
|
export declare const acquireLock: (OrderLocks: mongodb.Collection<OrderLock>, key: string, ttl: number) => Promise<{
|
|
9
9
|
release: () => Promise<void>;
|
|
10
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
10
11
|
}>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mongodb, buildDbIndexes } from '@unchainedshop/mongodb';
|
|
2
2
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
3
|
const DUPLICATE_KEY = 11000;
|
|
4
|
-
const
|
|
5
|
-
const LOCK_RETRY_DELAY_MS =
|
|
4
|
+
const LOCK_WAIT_MS = 2000;
|
|
5
|
+
const LOCK_RETRY_DELAY_MS = 50;
|
|
6
6
|
export const OrderLocksCollection = async (db) => {
|
|
7
7
|
const OrderLocks = db.collection('locco-locks');
|
|
8
8
|
const success = await buildDbIndexes(OrderLocks, [
|
|
@@ -16,25 +16,38 @@ export const OrderLocksCollection = async (db) => {
|
|
|
16
16
|
};
|
|
17
17
|
export const acquireLock = async (OrderLocks, key, ttl) => {
|
|
18
18
|
const uniqueValue = crypto.randomUUID();
|
|
19
|
-
|
|
19
|
+
const lease = () => ({ $set: { key, uniqueValue, expireAt: new Date(Date.now() + ttl) } });
|
|
20
|
+
const waitUntil = Date.now() + LOCK_WAIT_MS;
|
|
21
|
+
while (true) {
|
|
20
22
|
try {
|
|
21
|
-
await OrderLocks.updateOne({ key, expireAt: { $lt: new Date() } },
|
|
22
|
-
|
|
23
|
-
release: async () => {
|
|
24
|
-
try {
|
|
25
|
-
await OrderLocks.deleteOne({ key, uniqueValue, expireAt: { $gt: new Date() } });
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
};
|
|
23
|
+
await OrderLocks.updateOne({ key, expireAt: { $lt: new Date() } }, lease(), { upsert: true });
|
|
24
|
+
break;
|
|
31
25
|
}
|
|
32
26
|
catch (error) {
|
|
33
27
|
if (error?.code !== DUPLICATE_KEY)
|
|
34
28
|
throw error;
|
|
35
|
-
if (attempt < LOCK_RETRIES - 1)
|
|
36
|
-
await sleep(LOCK_RETRY_DELAY_MS);
|
|
37
29
|
}
|
|
30
|
+
if (Date.now() > waitUntil)
|
|
31
|
+
throw new Error(`Could not acquire lock ${key}`, { cause: 'LOCKED' });
|
|
32
|
+
await sleep(LOCK_RETRY_DELAY_MS * (0.5 + Math.random()));
|
|
38
33
|
}
|
|
39
|
-
|
|
34
|
+
const renewal = setInterval(async () => {
|
|
35
|
+
try {
|
|
36
|
+
const { matchedCount } = await OrderLocks.updateOne({ key, uniqueValue }, lease());
|
|
37
|
+
if (!matchedCount)
|
|
38
|
+
clearInterval(renewal);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
}
|
|
42
|
+
}, ttl / 3);
|
|
43
|
+
renewal.unref?.();
|
|
44
|
+
const release = async () => {
|
|
45
|
+
clearInterval(renewal);
|
|
46
|
+
try {
|
|
47
|
+
await OrderLocks.deleteOne({ key, uniqueValue, expireAt: { $gt: new Date() } });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
return { release, [Symbol.asyncDispose]: release };
|
|
40
53
|
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MigrationRepository } from '@unchainedshop/mongodb';
|
|
2
|
+
import type { PricingCalculation } from '@unchainedshop/utils';
|
|
3
|
+
export declare const convertOrderCalculationToNet: (calculation: PricingCalculation[]) => PricingCalculation[];
|
|
4
|
+
export default function migrateOrderCalculationToNet(repository: MigrationRepository): void;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { OrdersCollection } from "../db/OrdersCollection.js";
|
|
2
|
+
const legacyCalculationSelector = {
|
|
3
|
+
'calculation.0': { $exists: true },
|
|
4
|
+
calculation: {
|
|
5
|
+
$not: { $elemMatch: { category: { $ne: 'TAXES' }, isNetPrice: true } },
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
export const convertOrderCalculationToNet = (calculation) => {
|
|
9
|
+
let previousBaseRow;
|
|
10
|
+
const attributed = calculation.map((row) => {
|
|
11
|
+
if (!row || !Number.isFinite(row.amount)) {
|
|
12
|
+
throw new Error('Calculation contains an invalid amount');
|
|
13
|
+
}
|
|
14
|
+
if (row.category !== 'TAXES') {
|
|
15
|
+
previousBaseRow = row;
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
const baseCategory = row.baseCategory || previousBaseRow?.category;
|
|
19
|
+
if (!baseCategory || baseCategory === 'TAXES') {
|
|
20
|
+
throw new Error('Cannot determine the base category of a tax row');
|
|
21
|
+
}
|
|
22
|
+
const discountId = row.discountId ??
|
|
23
|
+
(previousBaseRow?.category === baseCategory ? previousBaseRow.discountId : undefined);
|
|
24
|
+
return { ...row, baseCategory, discountId, isNetPrice: false };
|
|
25
|
+
});
|
|
26
|
+
const converted = attributed.flatMap((row) => {
|
|
27
|
+
if (row.category !== 'TAXES')
|
|
28
|
+
return [row];
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
category: row.baseCategory,
|
|
32
|
+
amount: -row.amount,
|
|
33
|
+
discountId: row.discountId,
|
|
34
|
+
isNetPrice: true,
|
|
35
|
+
meta: row.meta,
|
|
36
|
+
},
|
|
37
|
+
row,
|
|
38
|
+
];
|
|
39
|
+
});
|
|
40
|
+
const sum = (rows, category, discountId) => rows.reduce((total, row) => (category === undefined || row.category === category) &&
|
|
41
|
+
(discountId === undefined || row.discountId === discountId)
|
|
42
|
+
? total + row.amount
|
|
43
|
+
: total, 0);
|
|
44
|
+
const taxes = attributed.filter((row) => row.category === 'TAXES');
|
|
45
|
+
const taxSum = (category, discountId) => sum(category === undefined ? taxes : taxes.filter((row) => row.baseCategory === category), undefined, discountId);
|
|
46
|
+
const categories = [
|
|
47
|
+
...new Set(attributed.map((row) => (row.category === 'TAXES' ? row.baseCategory : row.category))),
|
|
48
|
+
];
|
|
49
|
+
const discountIds = [...new Set(attributed.map((row) => row.discountId))].filter((discountId) => discountId !== undefined);
|
|
50
|
+
let roundingCategory = 'ROUNDING';
|
|
51
|
+
while (categories.includes(roundingCategory))
|
|
52
|
+
roundingCategory = `_${roundingCategory}`;
|
|
53
|
+
const scopes = [
|
|
54
|
+
...categories.flatMap((category) => [
|
|
55
|
+
...discountIds.map((discountId) => ({ category, discountId })),
|
|
56
|
+
{ category, discountId: undefined },
|
|
57
|
+
]),
|
|
58
|
+
...discountIds.map((discountId) => ({ category: undefined, discountId })),
|
|
59
|
+
{ category: undefined, discountId: undefined },
|
|
60
|
+
];
|
|
61
|
+
const balances = scopes.map(({ category, discountId }) => ({
|
|
62
|
+
category,
|
|
63
|
+
discountId,
|
|
64
|
+
amount: sum(attributed, category, discountId) - taxSum(category, discountId),
|
|
65
|
+
}));
|
|
66
|
+
for (const { category, discountId, amount } of balances) {
|
|
67
|
+
const difference = amount - sum(converted, category, discountId);
|
|
68
|
+
if (!Number.isFinite(amount) || !Number.isFinite(difference)) {
|
|
69
|
+
throw new Error('Calculation contains an invalid balance');
|
|
70
|
+
}
|
|
71
|
+
if (difference) {
|
|
72
|
+
converted.push({
|
|
73
|
+
category: category ?? roundingCategory,
|
|
74
|
+
discountId,
|
|
75
|
+
amount: difference,
|
|
76
|
+
isNetPrice: true,
|
|
77
|
+
meta: { migration: 20260907120000, reason: 'Preserve historical floating-point balance' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const { category, discountId, amount } of balances) {
|
|
82
|
+
if (sum(converted, category, discountId) !== amount) {
|
|
83
|
+
throw new Error(`Cannot preserve historical balance for ${category ?? 'order'} ${discountId ?? ''}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const discountId of discountIds) {
|
|
87
|
+
const before = sum(attributed, 'DISCOUNTS', discountId);
|
|
88
|
+
const after = sum(converted, 'DISCOUNTS', discountId) + taxSum('DISCOUNTS', discountId);
|
|
89
|
+
if (Math.round(before) !== Math.round(after) || Boolean(before) !== Boolean(after)) {
|
|
90
|
+
throw new Error(`Cannot preserve historical discount ${discountId}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return converted;
|
|
94
|
+
};
|
|
95
|
+
export default function migrateOrderCalculationToNet(repository) {
|
|
96
|
+
repository?.register({
|
|
97
|
+
id: 20260907120000,
|
|
98
|
+
name: 'Convert persisted order calculations from gross to net category balances',
|
|
99
|
+
up: async ({ logger }) => {
|
|
100
|
+
const Orders = await OrdersCollection(repository.db);
|
|
101
|
+
const projection = { _id: 1, calculation: 1 };
|
|
102
|
+
let migrated = 0;
|
|
103
|
+
for await (const order of Orders.find(legacyCalculationSelector, { projection })) {
|
|
104
|
+
let calculation;
|
|
105
|
+
try {
|
|
106
|
+
calculation = convertOrderCalculationToNet(order.calculation);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new Error(`Cannot migrate order ${order._id}: ${error.message}`, { cause: error });
|
|
110
|
+
}
|
|
111
|
+
if (!calculation.some((row) => row.isNetPrice === true)) {
|
|
112
|
+
calculation = calculation.map((row) => ({ ...row, isNetPrice: true }));
|
|
113
|
+
}
|
|
114
|
+
const result = await Orders.updateOne({ _id: order._id, calculation: order.calculation }, { $set: { calculation } });
|
|
115
|
+
if (result.matchedCount) {
|
|
116
|
+
migrated += 1;
|
|
117
|
+
}
|
|
118
|
+
else if (await Orders.findOne({ _id: order._id, ...legacyCalculationSelector }, { projection })) {
|
|
119
|
+
throw new Error(`Order ${order._id} changed during migration; retry after stopping old writers`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
logger?.info(`Converted ${migrated} order calculation(s) to net category balances`);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -117,6 +117,7 @@ export declare const configureOrdersModule: ({ db, migrationRepository, options:
|
|
|
117
117
|
}) => Promise<mongodb.WithId<Order> | null>;
|
|
118
118
|
acquireLock: (orderId: string, identifier: string, timeout?: number) => Promise<{
|
|
119
119
|
release: () => Promise<void>;
|
|
120
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
120
121
|
}>;
|
|
121
122
|
setDeliveryProvider: (orderId: string, deliveryProviderId: string) => Promise<mongodb.WithId<Order> | null>;
|
|
122
123
|
setPaymentProvider: (orderId: string, paymentProviderId: string) => Promise<mongodb.WithId<Order> | null>;
|
package/lib/orders-settings.d.ts
CHANGED
|
@@ -12,7 +12,6 @@ export interface OrdersSettings {
|
|
|
12
12
|
ensureUserHasCart: boolean;
|
|
13
13
|
orderNumberHashFn: (order: Order, index: number) => string;
|
|
14
14
|
validateOrderPosition: <UnchainedAPI = unknown>(validationParams: OrderSettingsOrderPositionValidation, unchainedAPI: UnchainedAPI) => Promise<void>;
|
|
15
|
-
lockOrderDuringCheckout: boolean;
|
|
16
15
|
configureSettings: (options?: OrdersSettingsOptions) => void;
|
|
17
16
|
}
|
|
18
17
|
export type OrdersSettingsOptions = Omit<Partial<OrdersSettings>, 'configureSettings'>;
|
package/lib/orders-settings.js
CHANGED
|
@@ -8,11 +8,9 @@ export const ordersSettings = {
|
|
|
8
8
|
ensureUserHasCart: false,
|
|
9
9
|
orderNumberHashFn: generateRandomHash,
|
|
10
10
|
validateOrderPosition: defaultValidateOrderPosition,
|
|
11
|
-
|
|
12
|
-
configureSettings({ ensureUserHasCart, orderNumberHashFn, validateOrderPosition, lockOrderDuringCheckout, } = {}) {
|
|
11
|
+
configureSettings({ ensureUserHasCart, orderNumberHashFn, validateOrderPosition } = {}) {
|
|
13
12
|
this.ensureUserHasCart = ensureUserHasCart || false;
|
|
14
13
|
this.orderNumberHashFn = orderNumberHashFn || generateRandomHash;
|
|
15
14
|
this.validateOrderPosition = validateOrderPosition || defaultValidateOrderPosition;
|
|
16
|
-
this.lockOrderDuringCheckout = lockOrderDuringCheckout || false;
|
|
17
15
|
},
|
|
18
16
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unchainedshop/core-orders",
|
|
3
3
|
"description": "Order management module for the Unchained Engine",
|
|
4
|
-
"version": "4.9.
|
|
4
|
+
"version": "4.9.1",
|
|
5
5
|
"main": "lib/orders-index.js",
|
|
6
6
|
"types": "lib/orders-index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@unchainedshop/utils": "^4.9.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^26.6.
|
|
43
|
+
"@types/node": "^26.6.6",
|
|
44
44
|
"typescript": "^6.0.3"
|
|
45
45
|
}
|
|
46
46
|
}
|