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