@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.
Files changed (85) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +7 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +2 -0
  7. package/dist/config/types.d.ts +9 -0
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  10. package/dist/hooks/checkout-completion.js +2 -2
  11. package/dist/hooks/checkout.d.ts.map +1 -1
  12. package/dist/hooks/checkout.js +1 -1
  13. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  14. package/dist/interfaces/rest/routes/admin/staff.js +37 -3
  15. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  16. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  17. package/dist/interfaces/rest/routes/checkout.js +81 -22
  18. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  19. package/dist/interfaces/rest/routes/inventory.js +13 -0
  20. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  21. package/dist/interfaces/rest/routes/orders.js +13 -0
  22. package/dist/interfaces/rest/schemas/orders.js +2 -2
  23. package/dist/interfaces/rest/schemas/responses.d.ts +34 -0
  24. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  25. package/dist/kernel/database/scoped-db.d.ts +2 -1
  26. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  27. package/dist/kernel/database/scoped-db.js +62 -1
  28. package/dist/kernel/plugin/manifest.d.ts +3 -0
  29. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  30. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  31. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  32. package/dist/modules/cart/service.d.ts.map +1 -1
  33. package/dist/modules/cart/service.js +3 -2
  34. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  35. package/dist/modules/catalog/entity-service.js +34 -2
  36. package/dist/modules/customers/service.d.ts.map +1 -1
  37. package/dist/modules/customers/service.js +6 -0
  38. package/dist/modules/orders/schema.d.ts +17 -0
  39. package/dist/modules/orders/schema.d.ts.map +1 -1
  40. package/dist/modules/orders/schema.js +4 -0
  41. package/dist/modules/orders/service.d.ts +16 -2
  42. package/dist/modules/orders/service.d.ts.map +1 -1
  43. package/dist/modules/orders/service.js +223 -26
  44. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  45. package/dist/modules/orders/stale-order-cleanup.js +13 -1
  46. package/dist/modules/pricing/service.d.ts.map +1 -1
  47. package/dist/modules/pricing/service.js +5 -5
  48. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  49. package/dist/test-utils/create-test-config.js +2 -0
  50. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  51. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  52. package/dist/test-utils/order-test-helpers.js +18 -0
  53. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  54. package/dist/test-utils/rest-api-test-utils.js +1 -0
  55. package/dist/test-utils/test-actors.js +1 -1
  56. package/dist/testing.d.ts +1 -0
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +1 -0
  59. package/package.json +1 -1
  60. package/src/auth/middleware.ts +26 -7
  61. package/src/auth/setup.ts +10 -4
  62. package/src/config/defaults.ts +2 -0
  63. package/src/config/types.ts +9 -0
  64. package/src/hooks/checkout-completion.ts +22 -16
  65. package/src/hooks/checkout.ts +11 -8
  66. package/src/interfaces/rest/routes/admin/staff.ts +45 -3
  67. package/src/interfaces/rest/routes/checkout.ts +93 -28
  68. package/src/interfaces/rest/routes/inventory.ts +14 -0
  69. package/src/interfaces/rest/routes/orders.ts +11 -0
  70. package/src/interfaces/rest/schemas/orders.ts +2 -2
  71. package/src/kernel/database/scoped-db.ts +70 -1
  72. package/src/kernel/plugin/manifest.ts +3 -0
  73. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  74. package/src/modules/cart/service.ts +3 -2
  75. package/src/modules/catalog/entity-service.ts +28 -1
  76. package/src/modules/customers/service.ts +6 -0
  77. package/src/modules/orders/schema.ts +4 -0
  78. package/src/modules/orders/service.ts +408 -64
  79. package/src/modules/orders/stale-order-cleanup.ts +18 -4
  80. package/src/modules/pricing/service.ts +5 -6
  81. package/src/test-utils/create-test-config.ts +2 -0
  82. package/src/test-utils/order-test-helpers.ts +26 -0
  83. package/src/test-utils/rest-api-test-utils.ts +1 -0
  84. package/src/test-utils/test-actors.ts +1 -1
  85. 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 type { TxContext } from "../../kernel/database/tx-context.js";
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: processed.subtotal,
384
+ subtotal: computedSubtotal,
210
385
  taxTotal: processed.taxTotal,
211
386
  shippingTotal: processed.shippingTotal,
212
387
  discountTotal: processed.discountTotal ?? 0,
213
- grandTotal: processed.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 = processed.lineItems.map((item) => ({
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(input: {
549
- entityId: string;
550
- variantId?: string;
551
- quantity: number;
552
- orderId: string;
553
- performedBy?: string;
554
- }): Promise<unknown>;
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
- entityId: lineItem.entityId,
566
- quantity: lineItem.quantity,
567
- orderId: order.id,
568
- performedBy: actor?.userId ?? "system",
569
- ...(lineItem.variantId != null
570
- ? { variantId: lineItem.variantId }
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
- const maxRefund = Math.min(
595
- order.grandTotal,
596
- order.amountCaptured ?? order.grandTotal,
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
- await payments.refund(
603
- paymentIntentId,
604
- refundAmount,
605
- input.reason ?? `order_${input.newStatus}`,
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(input: {
640
- entityId: string;
641
- variantId?: string;
642
- quantity: number;
643
- orderId: string;
644
- performedBy?: string;
645
- }): Promise<unknown>;
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
- entityId: lineItem.entityId,
666
- quantity: lineItem.quantity,
667
- orderId: order.id,
668
- performedBy: actor?.userId ?? "system",
669
- ...(lineItem.variantId != null
670
- ? { variantId: lineItem.variantId }
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
- const alreadyRefunded = priorRefunds
845
- .filter((r) => r.status === "completed")
846
- .reduce((sum, r) => sum + r.amount, 0);
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
- await payments.refund(
849
- found.order.paymentIntentId,
850
- Math.min(totalAmount, refundable),
851
- input.reason ?? "line_refund",
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: input.entityId,
1312
- entityType: input.entityType,
1313
- title: input.title,
1314
- quantity: input.quantity,
1315
- unitPrice: input.unitPrice,
1316
- totalPrice: input.totalPrice ?? input.unitPrice * input.quantity,
1317
- taxAmount: input.taxAmount ?? 0,
1318
- discountAmount: input.discountAmount ?? 0,
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: input.metadata ?? {},
1321
- ...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
1322
- ...(input.sku !== undefined ? { sku: input.sku } : {}),
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, sql } from "drizzle-orm";
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, TaskContext } from "../../kernel/jobs/types.js";
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
- null,
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 entity = await this.catalogRepo.findEntityById(input.entityId, ctx);
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 entity = await this.catalogRepo.findEntityById(input.entityId, ctx);
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,