@porulle/core 0.8.0 → 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/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +26 -7
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +7 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +3 -1
- package/dist/config/types.d.ts +13 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/hooks/checkout-completion.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.js +2 -2
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +2 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/staff.js +37 -3
- package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +82 -22
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +13 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +13 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +2 -2
- package/dist/interfaces/rest/schemas/responses.d.ts +85 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.d.ts +2 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +74 -15
- package/dist/kernel/errors.d.ts +12 -0
- package/dist/kernel/errors.d.ts.map +1 -1
- package/dist/kernel/errors.js +16 -0
- package/dist/kernel/jobs/adapter.d.ts +27 -8
- package/dist/kernel/jobs/adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/adapter.js +1 -2
- package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
- package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/drizzle-adapter.js +46 -5
- package/dist/kernel/jobs/runner.d.ts +2 -1
- package/dist/kernel/jobs/runner.d.ts.map +1 -1
- package/dist/kernel/jobs/runner.js +100 -15
- package/dist/kernel/jobs/types.d.ts +13 -0
- package/dist/kernel/jobs/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +5 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -0
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +3 -2
- package/dist/modules/catalog/entity-service.d.ts +1 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +49 -3
- package/dist/modules/catalog/repository/index.d.ts +2 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +9 -1
- package/dist/modules/catalog/schema.d.ts +51 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +12 -1
- package/dist/modules/catalog/schemas.d.ts +3 -1
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/channels/adapter.d.ts +117 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -0
- package/dist/modules/channels/adapter.js +3 -0
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -0
- package/dist/modules/inventory/repository/index.js +4 -4
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +7 -3
- package/dist/modules/orders/schema.d.ts +17 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +4 -0
- package/dist/modules/orders/service.d.ts +16 -2
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +226 -26
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +37 -16
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +5 -5
- package/dist/modules/promotions/repository/index.d.ts +1 -1
- package/dist/modules/promotions/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/repository/index.js +6 -3
- package/dist/modules/promotions/service.d.ts +1 -0
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +1 -1
- package/dist/modules/webhooks/repository/index.d.ts +1 -1
- package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
- package/dist/modules/webhooks/repository/index.js +16 -10
- package/dist/runtime/kernel.d.ts.map +1 -1
- package/dist/runtime/kernel.js +14 -2
- package/dist/runtime/server.d.ts +1 -4
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +16 -34
- package/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +2 -0
- package/dist/test-utils/order-test-helpers.d.ts +12 -0
- package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
- package/dist/test-utils/order-test-helpers.js +18 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +1 -0
- package/dist/test-utils/test-actors.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/package.json +1 -1
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +10 -4
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +16 -3
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/hooks/checkout-completion.ts +22 -16
- package/src/hooks/checkout.ts +11 -8
- package/src/index.ts +31 -1
- package/src/interfaces/rest/router.ts +2 -0
- package/src/interfaces/rest/routes/admin/staff.ts +45 -3
- package/src/interfaces/rest/routes/checkout.ts +94 -28
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +11 -0
- package/src/interfaces/rest/schemas/orders.ts +2 -2
- package/src/kernel/database/scoped-db.ts +83 -15
- package/src/kernel/errors.ts +20 -0
- package/src/kernel/jobs/adapter.ts +39 -8
- package/src/kernel/jobs/drizzle-adapter.ts +72 -9
- package/src/kernel/jobs/runner.ts +194 -72
- package/src/kernel/jobs/types.ts +25 -0
- package/src/kernel/plugin/manifest.ts +15 -0
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/cart/service.ts +3 -2
- package/src/modules/catalog/entity-service.ts +37 -2
- package/src/modules/catalog/repository/index.ts +13 -3
- package/src/modules/catalog/schema.ts +33 -18
- package/src/modules/catalog/schemas.ts +3 -1
- package/src/modules/channels/adapter.ts +139 -0
- package/src/modules/customers/service.ts +6 -0
- package/src/modules/inventory/repository/index.ts +4 -4
- package/src/modules/inventory/service.ts +12 -2
- package/src/modules/orders/schema.ts +4 -0
- package/src/modules/orders/service.ts +404 -64
- package/src/modules/orders/stale-order-cleanup.ts +52 -27
- package/src/modules/pricing/service.ts +5 -6
- package/src/modules/promotions/repository/index.ts +6 -2
- package/src/modules/promotions/service.ts +2 -0
- package/src/modules/webhooks/repository/index.ts +16 -14
- package/src/runtime/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
- package/src/test-utils/create-test-config.ts +2 -0
- package/src/test-utils/order-test-helpers.ts +26 -0
- package/src/test-utils/rest-api-test-utils.ts +1 -0
- package/src/test-utils/test-actors.ts +1 -1
- package/src/testing.ts +1 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../../kernel/errors.js";
|
|
11
11
|
import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
|
|
12
12
|
import { createHookContext } from "../../kernel/hooks/create-context.js";
|
|
13
|
+
import type { JobsAdapter } from "../../kernel/jobs/adapter.js";
|
|
13
14
|
import type {
|
|
14
15
|
AfterHook,
|
|
15
16
|
BeforeHook,
|
|
@@ -25,7 +26,10 @@ import {
|
|
|
25
26
|
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
26
27
|
import { createLogger } from "../../utils/logger.js";
|
|
27
28
|
import { paginate, type Pagination } from "../../utils/pagination.js";
|
|
28
|
-
import
|
|
29
|
+
import {
|
|
30
|
+
withTransaction,
|
|
31
|
+
type TxContext,
|
|
32
|
+
} from "../../kernel/database/tx-context.js";
|
|
29
33
|
import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
|
|
30
34
|
import type { PluginDb } from "../../kernel/database/plugin-types.js";
|
|
31
35
|
import {
|
|
@@ -127,6 +131,7 @@ function context(
|
|
|
127
131
|
tx,
|
|
128
132
|
logger: createLogger("orders"),
|
|
129
133
|
services,
|
|
134
|
+
...(services.jobs ? { jobs: services.jobs as JobsAdapter } : {}),
|
|
130
135
|
context: { moduleName: "orders" },
|
|
131
136
|
database: { db: database.db as PluginDb },
|
|
132
137
|
});
|
|
@@ -149,11 +154,128 @@ export class OrderService {
|
|
|
149
154
|
return { ...order, lineItems };
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Resolve every line item's price with org-integrity + price provenance,
|
|
159
|
+
* mirroring how our forefathers handle this (Vendure: order lines are always
|
|
160
|
+
* server-priced from the variant; Medusa: an admin override is honored but
|
|
161
|
+
* flagged `is_custom_price`). Three modes:
|
|
162
|
+
* - trusted caller (checkout / POS exchange, already server-priced) → prices honored as-is.
|
|
163
|
+
* - actor with `orders:manage` → explicit price honored as a manual override, flagged isCustomPrice.
|
|
164
|
+
* - otherwise → price re-derived server-side from the catalog; client prices ignored.
|
|
165
|
+
* The referenced entity MUST belong to the order's org in every mode.
|
|
166
|
+
*/
|
|
167
|
+
private async resolveLines(
|
|
168
|
+
input: CreateOrderInput,
|
|
169
|
+
actor: Actor | null,
|
|
170
|
+
trusted: boolean,
|
|
171
|
+
ctx: TxContext | undefined,
|
|
172
|
+
): Promise<Result<Array<CreateOrderInput["lineItems"][number] & { isCustomPrice: boolean }>>> {
|
|
173
|
+
let canOverride = trusted;
|
|
174
|
+
if (!canOverride) {
|
|
175
|
+
try {
|
|
176
|
+
assertPermission(actor, "orders:manage");
|
|
177
|
+
canOverride = true;
|
|
178
|
+
} catch {
|
|
179
|
+
canOverride = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const catalog = this.deps.services.catalog as {
|
|
183
|
+
getById(
|
|
184
|
+
id: string,
|
|
185
|
+
options: Record<string, unknown>,
|
|
186
|
+
actor?: Actor | null,
|
|
187
|
+
ctx?: TxContext,
|
|
188
|
+
): Promise<{
|
|
189
|
+
ok: boolean;
|
|
190
|
+
value?: { variants?: Array<{ id: string }> };
|
|
191
|
+
}>;
|
|
192
|
+
};
|
|
193
|
+
const pricing = this.deps.services.pricing as {
|
|
194
|
+
resolve(
|
|
195
|
+
input: {
|
|
196
|
+
entityId: string;
|
|
197
|
+
variantId?: string;
|
|
198
|
+
currency: string;
|
|
199
|
+
quantity: number;
|
|
200
|
+
customerId?: string;
|
|
201
|
+
},
|
|
202
|
+
actor?: Actor | null,
|
|
203
|
+
ctx?: TxContext,
|
|
204
|
+
): Promise<{ ok: boolean; value?: { finalAmount: number } }>;
|
|
205
|
+
};
|
|
206
|
+
const resolved: Array<
|
|
207
|
+
CreateOrderInput["lineItems"][number] & { isCustomPrice: boolean }
|
|
208
|
+
> = [];
|
|
209
|
+
for (const item of input.lineItems) {
|
|
210
|
+
// Org-integrity — the referenced entity must belong to this order's org.
|
|
211
|
+
const owned = await catalog.getById(
|
|
212
|
+
item.entityId,
|
|
213
|
+
{ includeVariants: item.variantId !== undefined },
|
|
214
|
+
actor,
|
|
215
|
+
ctx,
|
|
216
|
+
);
|
|
217
|
+
if (!owned.ok) {
|
|
218
|
+
return Err(
|
|
219
|
+
new CommerceValidationError(
|
|
220
|
+
`Line item entity ${item.entityId} does not belong to this organization.`,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
item.variantId !== undefined &&
|
|
226
|
+
!owned.value?.variants?.some((variant) => variant.id === item.variantId)
|
|
227
|
+
) {
|
|
228
|
+
return Err(
|
|
229
|
+
new CommerceValidationError(
|
|
230
|
+
`Line item variant ${item.variantId} does not belong to entity ${item.entityId}.`,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (trusted || canOverride) {
|
|
235
|
+
// Trusted pipeline totals, or a staff manual/negotiated override.
|
|
236
|
+
resolved.push({ ...item, isCustomPrice: !trusted && canOverride });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Server-derive the price; the client-supplied unitPrice/totalPrice is ignored.
|
|
240
|
+
const priced = await pricing.resolve(
|
|
241
|
+
{
|
|
242
|
+
entityId: item.entityId,
|
|
243
|
+
currency: input.currency,
|
|
244
|
+
quantity: item.quantity,
|
|
245
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
246
|
+
...(input.customerId ? { customerId: input.customerId } : {}),
|
|
247
|
+
},
|
|
248
|
+
actor,
|
|
249
|
+
ctx,
|
|
250
|
+
);
|
|
251
|
+
if (!priced.ok || !priced.value) {
|
|
252
|
+
return Err(
|
|
253
|
+
new CommerceValidationError(`Cannot resolve price for ${item.entityId}.`),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const unitPrice = priced.value.finalAmount;
|
|
257
|
+
resolved.push({
|
|
258
|
+
...item,
|
|
259
|
+
unitPrice,
|
|
260
|
+
totalPrice: unitPrice * item.quantity,
|
|
261
|
+
isCustomPrice: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return Ok(resolved);
|
|
265
|
+
}
|
|
266
|
+
|
|
152
267
|
async create(
|
|
153
268
|
input: CreateOrderInput,
|
|
154
269
|
actor: Actor | null,
|
|
155
270
|
ctx?: TxContext,
|
|
271
|
+
opts?: { trustedPricing?: boolean; stockPolicy?: "reserve" | "backorder" },
|
|
156
272
|
): Promise<Result<HydratedOrder>> {
|
|
273
|
+
if (opts?.stockPolicy === "reserve" && !ctx) {
|
|
274
|
+
return withTransaction(this.deps.database, { actor }, (txCtx) =>
|
|
275
|
+
this.create(input, actor, txCtx, opts),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
157
279
|
try {
|
|
158
280
|
assertPermission(actor, "orders:create");
|
|
159
281
|
} catch (error) {
|
|
@@ -196,6 +318,61 @@ export class OrderService {
|
|
|
196
318
|
}
|
|
197
319
|
}
|
|
198
320
|
|
|
321
|
+
// Resolve line prices with org-integrity + provenance (see resolveLines).
|
|
322
|
+
const trusted = opts?.trustedPricing === true;
|
|
323
|
+
const resolvedResult = await this.resolveLines(processed, actor, trusted, ctx);
|
|
324
|
+
if (!resolvedResult.ok) return Err(resolvedResult.error);
|
|
325
|
+
const resolvedLines = resolvedResult.value;
|
|
326
|
+
|
|
327
|
+
// Order totals: trusted callers (checkout / exchange) supply rich totals
|
|
328
|
+
// (promotions, tax, shipping); otherwise derive from the server-priced lines.
|
|
329
|
+
const computedSubtotal = trusted
|
|
330
|
+
? processed.subtotal
|
|
331
|
+
: resolvedLines.reduce(
|
|
332
|
+
(sum, l) => sum + (l.totalPrice ?? l.unitPrice * l.quantity),
|
|
333
|
+
0,
|
|
334
|
+
);
|
|
335
|
+
const computedGrandTotal = trusted
|
|
336
|
+
? processed.grandTotal
|
|
337
|
+
: computedSubtotal +
|
|
338
|
+
processed.taxTotal +
|
|
339
|
+
processed.shippingTotal -
|
|
340
|
+
(processed.discountTotal ?? 0);
|
|
341
|
+
|
|
342
|
+
// Stock: order creation does NOT reserve by default. Stock is allocated at
|
|
343
|
+
// the lifecycle transition to a committed state — checkout reserves in its
|
|
344
|
+
// own pipeline, mirroring Vendure's StockAllocationStrategy (allocate on
|
|
345
|
+
// PaymentAuthorized, not on creation). A caller that represents an immediate
|
|
346
|
+
// committed sale (e.g. a POS exchange) opts in with stockPolicy: "reserve".
|
|
347
|
+
const stockPolicy = opts?.stockPolicy;
|
|
348
|
+
if (stockPolicy === "reserve") {
|
|
349
|
+
const inventory = this.deps.services.inventory as {
|
|
350
|
+
getAvailable(
|
|
351
|
+
entityId: string,
|
|
352
|
+
variantId: string | undefined,
|
|
353
|
+
ctx?: TxContext,
|
|
354
|
+
actor?: Actor | null,
|
|
355
|
+
): Promise<{ ok: boolean; value?: number }>;
|
|
356
|
+
};
|
|
357
|
+
for (const line of resolvedLines) {
|
|
358
|
+
const avail = await inventory.getAvailable(
|
|
359
|
+
line.entityId,
|
|
360
|
+
line.variantId,
|
|
361
|
+
ctx,
|
|
362
|
+
actor,
|
|
363
|
+
);
|
|
364
|
+
if (!avail.ok || (avail.value ?? 0) < line.quantity) {
|
|
365
|
+
return Err(
|
|
366
|
+
new CommerceValidationError(
|
|
367
|
+
`Insufficient stock for ${line.title ?? line.entityId}. Available: ${
|
|
368
|
+
avail.ok ? (avail.value ?? 0) : 0
|
|
369
|
+
}, requested: ${line.quantity}.`,
|
|
370
|
+
),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
199
376
|
const orderNumber = await this.repo.getNextOrderNumber(ctx);
|
|
200
377
|
|
|
201
378
|
let order: Order;
|
|
@@ -206,11 +383,11 @@ export class OrderService {
|
|
|
206
383
|
orderNumber,
|
|
207
384
|
status: "pending",
|
|
208
385
|
currency: processed.currency,
|
|
209
|
-
subtotal:
|
|
386
|
+
subtotal: computedSubtotal,
|
|
210
387
|
taxTotal: processed.taxTotal,
|
|
211
388
|
shippingTotal: processed.shippingTotal,
|
|
212
389
|
discountTotal: processed.discountTotal ?? 0,
|
|
213
|
-
grandTotal:
|
|
390
|
+
grandTotal: computedGrandTotal,
|
|
214
391
|
...(processed.paymentIntentId != null ? { paymentIntentId: processed.paymentIntentId } : {}),
|
|
215
392
|
...(processed.paymentMethodId != null ? { paymentMethodId: processed.paymentMethodId } : {}),
|
|
216
393
|
...(processed.idempotencyKey != null ? { idempotencyKey: processed.idempotencyKey } : {}),
|
|
@@ -235,14 +412,15 @@ export class OrderService {
|
|
|
235
412
|
throw error;
|
|
236
413
|
}
|
|
237
414
|
|
|
238
|
-
const lineItemsData =
|
|
415
|
+
const lineItemsData = resolvedLines.map((item) => ({
|
|
239
416
|
orderId: order.id,
|
|
240
417
|
entityId: item.entityId,
|
|
241
418
|
entityType: item.entityType,
|
|
242
419
|
title: item.title,
|
|
243
420
|
quantity: item.quantity,
|
|
244
421
|
unitPrice: item.unitPrice,
|
|
245
|
-
totalPrice: item.totalPrice,
|
|
422
|
+
totalPrice: item.totalPrice ?? item.unitPrice * item.quantity,
|
|
423
|
+
isCustomPrice: item.isCustomPrice,
|
|
246
424
|
taxAmount: item.taxAmount ?? 0,
|
|
247
425
|
discountAmount: item.discountAmount ?? 0,
|
|
248
426
|
fulfillmentStatus: "unfulfilled" as const,
|
|
@@ -253,6 +431,45 @@ export class OrderService {
|
|
|
253
431
|
|
|
254
432
|
await this.repo.createLineItems(lineItemsData, ctx);
|
|
255
433
|
|
|
434
|
+
// Reserve stock for the resolved lines when the reserve policy is active
|
|
435
|
+
// (availability was verified above). Trusted callers reserve themselves.
|
|
436
|
+
if (stockPolicy === "reserve") {
|
|
437
|
+
const inventory = this.deps.services.inventory as {
|
|
438
|
+
reserve(
|
|
439
|
+
input: {
|
|
440
|
+
entityId: string;
|
|
441
|
+
quantity: number;
|
|
442
|
+
orderId: string;
|
|
443
|
+
variantId?: string;
|
|
444
|
+
},
|
|
445
|
+
actor?: Actor | null,
|
|
446
|
+
ctx?: TxContext,
|
|
447
|
+
): Promise<{ ok: boolean; error?: unknown }>;
|
|
448
|
+
};
|
|
449
|
+
for (const line of resolvedLines) {
|
|
450
|
+
const reserved = await inventory.reserve(
|
|
451
|
+
{
|
|
452
|
+
entityId: line.entityId,
|
|
453
|
+
quantity: line.quantity,
|
|
454
|
+
orderId: order.id,
|
|
455
|
+
...(line.variantId ? { variantId: line.variantId } : {}),
|
|
456
|
+
},
|
|
457
|
+
actor,
|
|
458
|
+
ctx,
|
|
459
|
+
);
|
|
460
|
+
if (!reserved.ok) {
|
|
461
|
+
return Err(
|
|
462
|
+
toCommerceError(
|
|
463
|
+
reserved.error ??
|
|
464
|
+
new CommerceValidationError(
|
|
465
|
+
`Unable to reserve stock for ${line.title ?? line.entityId}.`,
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
256
473
|
await this.repo.createStatusHistory(
|
|
257
474
|
{
|
|
258
475
|
orderId: order.id,
|
|
@@ -518,6 +735,19 @@ export class OrderService {
|
|
|
518
735
|
const previous = order.status;
|
|
519
736
|
const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
|
|
520
737
|
|
|
738
|
+
// R-04: goods must not ship against refunded lines. Reject the fulfilled
|
|
739
|
+
// transition if any line has been (partially) refunded.
|
|
740
|
+
if (
|
|
741
|
+
input.newStatus === "fulfilled" &&
|
|
742
|
+
lineItems.some((li) => li.refundedQuantity > 0)
|
|
743
|
+
) {
|
|
744
|
+
return Err(
|
|
745
|
+
new CommerceValidationError(
|
|
746
|
+
"Cannot fulfill an order that has refunded line items.",
|
|
747
|
+
),
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
521
751
|
// VAPT r2 (codex) finding: side effects (inventory release, payment
|
|
522
752
|
// refund, tax void) used to run BEFORE the atomic compare-and-swap on
|
|
523
753
|
// updateStatus. Two parallel cancel/refund requests on the same order
|
|
@@ -545,13 +775,17 @@ export class OrderService {
|
|
|
545
775
|
// 1. Release inventory reservations
|
|
546
776
|
const inventory = this.deps.services.inventory as
|
|
547
777
|
| {
|
|
548
|
-
release(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
778
|
+
release(
|
|
779
|
+
input: {
|
|
780
|
+
entityId: string;
|
|
781
|
+
variantId?: string;
|
|
782
|
+
quantity: number;
|
|
783
|
+
orderId: string;
|
|
784
|
+
performedBy?: string;
|
|
785
|
+
},
|
|
786
|
+
actor?: Actor | null,
|
|
787
|
+
ctx?: TxContext,
|
|
788
|
+
): Promise<Result<void>>;
|
|
555
789
|
}
|
|
556
790
|
| undefined;
|
|
557
791
|
|
|
@@ -561,15 +795,27 @@ export class OrderService {
|
|
|
561
795
|
// Fulfilled items had their reservation released during the
|
|
562
796
|
// fulfilled transition — releasing again would double-release.
|
|
563
797
|
if (lineItem.fulfillmentStatus === "unfulfilled") {
|
|
564
|
-
await inventory.release(
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
798
|
+
const releaseResult = await inventory.release(
|
|
799
|
+
{
|
|
800
|
+
entityId: lineItem.entityId,
|
|
801
|
+
quantity: lineItem.quantity,
|
|
802
|
+
orderId: order.id,
|
|
803
|
+
performedBy: actor?.userId ?? "system",
|
|
804
|
+
...(lineItem.variantId != null
|
|
805
|
+
? { variantId: lineItem.variantId }
|
|
806
|
+
: {}),
|
|
807
|
+
},
|
|
808
|
+
actor,
|
|
809
|
+
ctx,
|
|
810
|
+
);
|
|
811
|
+
if (!releaseResult.ok) {
|
|
812
|
+
const err = toCommerceError(releaseResult.error);
|
|
813
|
+
// Tolerate a missing inventory record (nothing to release); surface
|
|
814
|
+
// any other inventory error. Check the typed code, not the message.
|
|
815
|
+
if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
|
|
816
|
+
return Err(err);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
573
819
|
}
|
|
574
820
|
}
|
|
575
821
|
}
|
|
@@ -591,19 +837,27 @@ export class OrderService {
|
|
|
591
837
|
| undefined;
|
|
592
838
|
|
|
593
839
|
if (payments?.refund) {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
840
|
+
// R-01: subtract prior gateway refunds (line-level refundLines, and
|
|
841
|
+
// undone refunds — the gateway was never reversed) so this order-level
|
|
842
|
+
// refund can't double-pay on top of what has already been refunded.
|
|
843
|
+
const priorRefunds = await this.repo.findRefundsByOrderId(order.id, ctx);
|
|
844
|
+
const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
|
|
845
|
+
const maxRefund = Math.max(
|
|
846
|
+
0,
|
|
847
|
+
Math.min(order.grandTotal, order.amountCaptured ?? order.grandTotal) -
|
|
848
|
+
alreadyRefunded,
|
|
597
849
|
);
|
|
598
850
|
const refundAmount =
|
|
599
851
|
input.refundAmount != null
|
|
600
852
|
? Math.min(input.refundAmount, maxRefund)
|
|
601
853
|
: maxRefund;
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
854
|
+
if (refundAmount > 0) {
|
|
855
|
+
await payments.refund(
|
|
856
|
+
paymentIntentId,
|
|
857
|
+
refundAmount,
|
|
858
|
+
input.reason ?? `order_${input.newStatus}`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
607
861
|
}
|
|
608
862
|
}
|
|
609
863
|
|
|
@@ -636,13 +890,17 @@ export class OrderService {
|
|
|
636
890
|
orderId: string;
|
|
637
891
|
orgId?: string;
|
|
638
892
|
}): Promise<unknown>;
|
|
639
|
-
release(
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
893
|
+
release(
|
|
894
|
+
input: {
|
|
895
|
+
entityId: string;
|
|
896
|
+
variantId?: string;
|
|
897
|
+
quantity: number;
|
|
898
|
+
orderId: string;
|
|
899
|
+
performedBy?: string;
|
|
900
|
+
},
|
|
901
|
+
actor?: Actor | null,
|
|
902
|
+
ctx?: TxContext,
|
|
903
|
+
): Promise<Result<void>>;
|
|
646
904
|
}
|
|
647
905
|
| undefined;
|
|
648
906
|
|
|
@@ -661,15 +919,27 @@ export class OrderService {
|
|
|
661
919
|
});
|
|
662
920
|
|
|
663
921
|
// Release reservation (no longer needed)
|
|
664
|
-
await inventory.release(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
922
|
+
const releaseResult = await inventory.release(
|
|
923
|
+
{
|
|
924
|
+
entityId: lineItem.entityId,
|
|
925
|
+
quantity: lineItem.quantity,
|
|
926
|
+
orderId: order.id,
|
|
927
|
+
performedBy: actor?.userId ?? "system",
|
|
928
|
+
...(lineItem.variantId != null
|
|
929
|
+
? { variantId: lineItem.variantId }
|
|
930
|
+
: {}),
|
|
931
|
+
},
|
|
932
|
+
actor,
|
|
933
|
+
ctx,
|
|
934
|
+
);
|
|
935
|
+
if (!releaseResult.ok) {
|
|
936
|
+
const err = toCommerceError(releaseResult.error);
|
|
937
|
+
// Tolerate a missing inventory record (nothing to release); surface
|
|
938
|
+
// any other inventory error. Check the typed code, not the message.
|
|
939
|
+
if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
|
|
940
|
+
return Err(err);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
673
943
|
|
|
674
944
|
// Mark line item as fulfilled
|
|
675
945
|
await this.repo.updateLineItem(
|
|
@@ -790,6 +1060,26 @@ export class OrderService {
|
|
|
790
1060
|
if (input.lines.length === 0) {
|
|
791
1061
|
return Err(new CommerceValidationError("At least one line is required."));
|
|
792
1062
|
}
|
|
1063
|
+
// R-02: a terminal order (cancelled / fully refunded) cannot be line-refunded.
|
|
1064
|
+
if (this.machine.terminal.includes(found.order.status)) {
|
|
1065
|
+
return Err(
|
|
1066
|
+
new CommerceValidationError(
|
|
1067
|
+
`Cannot refund an order in terminal status "${found.order.status}".`,
|
|
1068
|
+
),
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
// R-03: a refund moves collected money — reject an unpaid order (otherwise a
|
|
1072
|
+
// "refund" is recorded against funds never taken). "Paid" is either a core
|
|
1073
|
+
// captured payment (checkout: amountCaptured > 0) or the order having left
|
|
1074
|
+
// the initial pending state (e.g. a completed POS/in-store sale). Only a
|
|
1075
|
+
// still-pending, uncaptured order is treated as unpaid.
|
|
1076
|
+
if ((found.order.amountCaptured ?? 0) <= 0 && found.order.status === "pending") {
|
|
1077
|
+
return Err(
|
|
1078
|
+
new CommerceValidationError(
|
|
1079
|
+
"Cannot refund an unpaid order (no captured payment and still pending).",
|
|
1080
|
+
),
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
793
1083
|
|
|
794
1084
|
const byId = new Map(found.lineItems.map((li) => [li.id, li]));
|
|
795
1085
|
const refundLines: Array<{ lineItemId: string; quantity: number; amount: number }> = [];
|
|
@@ -841,15 +1131,20 @@ export class OrderService {
|
|
|
841
1131
|
| undefined;
|
|
842
1132
|
if (payments?.refund) {
|
|
843
1133
|
const priorRefunds = await this.repo.findRefundsByOrderId(orderId, ctx);
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1134
|
+
// Gross total incl. UNDONE refunds: undoRefund reverses the local ledger
|
|
1135
|
+
// but not the payment gateway, so that money already left. Counting only
|
|
1136
|
+
// "completed" would let refund → undo → refund re-issue a gateway refund
|
|
1137
|
+
// (F-04 / R-05 / R-07). Capping on gross keeps total payout ≤ captured.
|
|
1138
|
+
const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
|
|
847
1139
|
const refundable = Math.max(0, (found.order.amountCaptured ?? 0) - alreadyRefunded);
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1140
|
+
const payAmount = Math.min(totalAmount, refundable);
|
|
1141
|
+
if (payAmount > 0) {
|
|
1142
|
+
await payments.refund(
|
|
1143
|
+
found.order.paymentIntentId,
|
|
1144
|
+
payAmount,
|
|
1145
|
+
input.reason ?? "line_refund",
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
853
1148
|
}
|
|
854
1149
|
}
|
|
855
1150
|
|
|
@@ -1238,6 +1533,14 @@ export class OrderService {
|
|
|
1238
1533
|
"Order payment is fully captured; line items cannot be edited.",
|
|
1239
1534
|
);
|
|
1240
1535
|
}
|
|
1536
|
+
// R-06: a checkout order carries a paymentIntentId. If amountCaptured is null
|
|
1537
|
+
// (auth-only capture, or a mock adapter reporting 0), treat it as authorized —
|
|
1538
|
+
// an order with a live payment must not have its lines silently edited/inflated.
|
|
1539
|
+
if (order.paymentIntentId != null && order.amountCaptured == null) {
|
|
1540
|
+
return new CommerceValidationError(
|
|
1541
|
+
"Order has an authorized payment; line items cannot be edited.",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1241
1544
|
return null;
|
|
1242
1545
|
}
|
|
1243
1546
|
|
|
@@ -1304,22 +1607,59 @@ export class OrderService {
|
|
|
1304
1607
|
const guard = this.lineItemEditGuard(order);
|
|
1305
1608
|
if (guard) return Err(guard);
|
|
1306
1609
|
|
|
1610
|
+
// Org-integrity + price provenance for the added line (same rules as create):
|
|
1611
|
+
// the entity must belong to the order's org, and the price is server-derived
|
|
1612
|
+
// unless the actor holds `orders:manage` (then honored + flagged isCustomPrice).
|
|
1613
|
+
const resolvedResult = await this.resolveLines(
|
|
1614
|
+
{
|
|
1615
|
+
currency: order.currency,
|
|
1616
|
+
subtotal: 0,
|
|
1617
|
+
taxTotal: 0,
|
|
1618
|
+
shippingTotal: 0,
|
|
1619
|
+
grandTotal: 0,
|
|
1620
|
+
lineItems: [
|
|
1621
|
+
{
|
|
1622
|
+
entityId: input.entityId,
|
|
1623
|
+
entityType: input.entityType,
|
|
1624
|
+
title: input.title,
|
|
1625
|
+
quantity: input.quantity,
|
|
1626
|
+
unitPrice: input.unitPrice,
|
|
1627
|
+
totalPrice: input.totalPrice ?? input.unitPrice * input.quantity,
|
|
1628
|
+
...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
|
|
1629
|
+
...(input.sku !== undefined ? { sku: input.sku } : {}),
|
|
1630
|
+
...(input.taxAmount !== undefined ? { taxAmount: input.taxAmount } : {}),
|
|
1631
|
+
...(input.discountAmount !== undefined
|
|
1632
|
+
? { discountAmount: input.discountAmount }
|
|
1633
|
+
: {}),
|
|
1634
|
+
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
|
1635
|
+
},
|
|
1636
|
+
],
|
|
1637
|
+
...(order.customerId ? { customerId: order.customerId } : {}),
|
|
1638
|
+
},
|
|
1639
|
+
actor,
|
|
1640
|
+
false,
|
|
1641
|
+
ctx,
|
|
1642
|
+
);
|
|
1643
|
+
if (!resolvedResult.ok) return Err(resolvedResult.error);
|
|
1644
|
+
const line = resolvedResult.value[0]!;
|
|
1645
|
+
|
|
1307
1646
|
await this.repo.createLineItems(
|
|
1308
1647
|
[
|
|
1309
1648
|
{
|
|
1310
1649
|
orderId: order.id,
|
|
1311
|
-
entityId:
|
|
1312
|
-
entityType:
|
|
1313
|
-
title:
|
|
1314
|
-
quantity:
|
|
1315
|
-
unitPrice:
|
|
1316
|
-
totalPrice:
|
|
1317
|
-
|
|
1318
|
-
|
|
1650
|
+
entityId: line.entityId,
|
|
1651
|
+
entityType: line.entityType,
|
|
1652
|
+
title: line.title,
|
|
1653
|
+
quantity: line.quantity,
|
|
1654
|
+
unitPrice: line.unitPrice,
|
|
1655
|
+
totalPrice: line.totalPrice ?? line.unitPrice * line.quantity,
|
|
1656
|
+
isCustomPrice: line.isCustomPrice,
|
|
1657
|
+
taxAmount: line.taxAmount ?? 0,
|
|
1658
|
+
discountAmount: line.discountAmount ?? 0,
|
|
1319
1659
|
fulfillmentStatus: "unfulfilled",
|
|
1320
|
-
metadata:
|
|
1321
|
-
...(
|
|
1322
|
-
...(
|
|
1660
|
+
metadata: line.metadata ?? {},
|
|
1661
|
+
...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
|
|
1662
|
+
...(line.sku !== undefined ? { sku: line.sku } : {}),
|
|
1323
1663
|
},
|
|
1324
1664
|
],
|
|
1325
1665
|
ctx,
|