@porulle/core 0.7.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 +8 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +7 -0
- package/dist/config/types.d.ts +26 -1
- 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 +17 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/staff.js +206 -0
- package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
- package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/analytics.js +24 -0
- package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/carts.js +39 -2
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -1
- 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 +97 -21
- package/dist/interfaces/rest/routes/documents.d.ts +10 -0
- package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/documents.js +40 -0
- 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 +111 -1
- package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/pricing.js +36 -1
- package/dist/interfaces/rest/routes/settings.d.ts +5 -0
- package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/settings.js +50 -0
- package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
- package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/shipping.js +68 -0
- package/dist/interfaces/rest/routes/tax.d.ts +5 -0
- package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/tax.js +68 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/analytics.js +33 -0
- package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/carts.js +38 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +48 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/checkout.js +4 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
- package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/documents.js +65 -0
- package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +234 -1
- package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/pricing.js +70 -0
- package/dist/interfaces/rest/schemas/responses.d.ts +190 -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/interfaces/rest/schemas/settings.d.ts +397 -0
- package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/settings.js +79 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/shipping.js +146 -0
- package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
- package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/tax.js +132 -0
- package/dist/kernel/database/schema.d.ts +4 -0
- package/dist/kernel/database/schema.d.ts.map +1 -1
- package/dist/kernel/database/schema.js +8 -0
- 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/hooks/types.d.ts +1 -1
- package/dist/kernel/hooks/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +26 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +17 -2
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/analytics/module.d.ts.map +1 -1
- package/dist/modules/analytics/module.js +6 -0
- package/dist/modules/analytics/reports.d.ts +58 -0
- package/dist/modules/analytics/reports.d.ts.map +1 -0
- package/dist/modules/analytics/reports.js +249 -0
- package/dist/modules/analytics/service.d.ts +10 -0
- package/dist/modules/analytics/service.d.ts.map +1 -1
- package/dist/modules/analytics/service.js +14 -0
- package/dist/modules/cart/repository/index.d.ts +7 -0
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +25 -1
- package/dist/modules/cart/schema.d.ts +17 -0
- package/dist/modules/cart/schema.d.ts.map +1 -1
- package/dist/modules/cart/schema.js +2 -0
- package/dist/modules/cart/schemas.d.ts +1 -0
- package/dist/modules/cart/schemas.d.ts.map +1 -1
- package/dist/modules/cart/schemas.js +1 -0
- package/dist/modules/cart/service.d.ts +32 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +61 -2
- package/dist/modules/catalog/entity-service.d.ts +21 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +166 -5
- package/dist/modules/catalog/schema.d.ts +34 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +5 -0
- package/dist/modules/catalog/schemas.d.ts +3 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +3 -0
- package/dist/modules/catalog/service.d.ts +36 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +6 -0
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -0
- package/dist/modules/documents/module.d.ts +7 -0
- package/dist/modules/documents/module.d.ts.map +1 -0
- package/dist/modules/documents/module.js +12 -0
- package/dist/modules/documents/pdf.d.ts +25 -0
- package/dist/modules/documents/pdf.d.ts.map +1 -0
- package/dist/modules/documents/pdf.js +80 -0
- package/dist/modules/documents/render.d.ts +32 -0
- package/dist/modules/documents/render.d.ts.map +1 -0
- package/dist/modules/documents/render.js +190 -0
- package/dist/modules/documents/repository/index.d.ts +25 -0
- package/dist/modules/documents/repository/index.d.ts.map +1 -0
- package/dist/modules/documents/repository/index.js +47 -0
- package/dist/modules/documents/schema.d.ts +214 -0
- package/dist/modules/documents/schema.d.ts.map +1 -0
- package/dist/modules/documents/schema.js +42 -0
- package/dist/modules/documents/service.d.ts +51 -0
- package/dist/modules/documents/service.d.ts.map +1 -0
- package/dist/modules/documents/service.js +119 -0
- package/dist/modules/fulfillment/service.d.ts +18 -0
- package/dist/modules/fulfillment/service.d.ts.map +1 -1
- package/dist/modules/fulfillment/service.js +85 -1
- package/dist/modules/orders/repository/index.d.ts +18 -1
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +86 -1
- package/dist/modules/orders/schema.d.ts +391 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +56 -1
- package/dist/modules/orders/service.d.ts +102 -3
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +653 -29
- 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/repository/index.d.ts +5 -0
- package/dist/modules/pricing/repository/index.d.ts.map +1 -1
- package/dist/modules/pricing/repository/index.js +19 -0
- package/dist/modules/pricing/service.d.ts +18 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +43 -5
- package/dist/modules/settings/module.d.ts +6 -0
- package/dist/modules/settings/module.d.ts.map +1 -0
- package/dist/modules/settings/module.js +11 -0
- package/dist/modules/settings/repository/index.d.ts +16 -0
- package/dist/modules/settings/repository/index.d.ts.map +1 -0
- package/dist/modules/settings/repository/index.js +41 -0
- package/dist/modules/settings/schema.d.ts +121 -0
- package/dist/modules/settings/schema.d.ts.map +1 -0
- package/dist/modules/settings/schema.js +24 -0
- package/dist/modules/settings/service.d.ts +26 -0
- package/dist/modules/settings/service.d.ts.map +1 -0
- package/dist/modules/settings/service.js +51 -0
- package/dist/modules/shipping/calculator.d.ts +5 -0
- package/dist/modules/shipping/calculator.d.ts.map +1 -1
- package/dist/modules/shipping/calculator.js +10 -0
- package/dist/modules/shipping/module.d.ts +5 -1
- package/dist/modules/shipping/module.d.ts.map +1 -1
- package/dist/modules/shipping/module.js +4 -1
- package/dist/modules/shipping/repository/index.d.ts +27 -2
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.js +105 -1
- package/dist/modules/shipping/schema.d.ts +419 -0
- package/dist/modules/shipping/schema.d.ts.map +1 -0
- package/dist/modules/shipping/schema.js +56 -0
- package/dist/modules/shipping/service.d.ts +54 -0
- package/dist/modules/shipping/service.d.ts.map +1 -1
- package/dist/modules/shipping/service.js +161 -2
- package/dist/modules/tax/adapter.d.ts +13 -0
- package/dist/modules/tax/adapter.d.ts.map +1 -1
- package/dist/modules/tax/module.d.ts +5 -1
- package/dist/modules/tax/module.d.ts.map +1 -1
- package/dist/modules/tax/module.js +4 -1
- package/dist/modules/tax/repository/index.d.ts +38 -2
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.js +138 -1
- package/dist/modules/tax/schema.d.ts +355 -0
- package/dist/modules/tax/schema.d.ts.map +1 -0
- package/dist/modules/tax/schema.js +55 -0
- package/dist/modules/tax/service.d.ts +60 -1
- package/dist/modules/tax/service.d.ts.map +1 -1
- package/dist/modules/tax/service.js +227 -2
- package/dist/runtime/kernel-modules.d.ts +15 -2
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/dist/runtime/kernel-modules.js +4 -0
- package/dist/runtime/kernel-types.d.ts +5 -1
- package/dist/runtime/kernel-types.d.ts.map +1 -1
- package/dist/runtime/kernel-types.js +2 -0
- package/dist/runtime/server.js +1 -1
- package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +15 -3
- 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 +7 -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 +3 -3
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +11 -4
- package/src/config/defaults.ts +7 -0
- package/src/config/types.ts +26 -1
- 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 +36 -11
- package/src/index.ts +1 -0
- package/src/interfaces/rest/index.ts +12 -0
- package/src/interfaces/rest/routes/admin/staff.ts +260 -0
- package/src/interfaces/rest/routes/analytics.ts +33 -0
- package/src/interfaces/rest/routes/carts.ts +52 -2
- package/src/interfaces/rest/routes/catalog.ts +34 -0
- package/src/interfaces/rest/routes/checkout.ts +114 -27
- package/src/interfaces/rest/routes/documents.ts +61 -0
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +148 -1
- package/src/interfaces/rest/routes/pricing.ts +44 -1
- package/src/interfaces/rest/routes/settings.ts +61 -0
- package/src/interfaces/rest/routes/shipping.ts +92 -0
- package/src/interfaces/rest/routes/tax.ts +90 -0
- package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
- package/src/interfaces/rest/schemas/analytics.ts +39 -0
- package/src/interfaces/rest/schemas/carts.ts +40 -0
- package/src/interfaces/rest/schemas/catalog.ts +53 -0
- package/src/interfaces/rest/schemas/checkout.ts +4 -0
- package/src/interfaces/rest/schemas/documents.ts +72 -0
- package/src/interfaces/rest/schemas/orders.ts +258 -1
- package/src/interfaces/rest/schemas/pricing.ts +75 -0
- package/src/interfaces/rest/schemas/settings.ts +93 -0
- package/src/interfaces/rest/schemas/shipping.ts +164 -0
- package/src/interfaces/rest/schemas/tax.ts +148 -0
- package/src/kernel/database/schema.ts +12 -0
- package/src/kernel/database/scoped-db.ts +70 -1
- package/src/kernel/hooks/types.ts +1 -0
- package/src/kernel/plugin/manifest.ts +41 -2
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/analytics/module.ts +10 -0
- package/src/modules/analytics/reports.ts +311 -0
- package/src/modules/analytics/service.ts +23 -0
- package/src/modules/cart/repository/index.ts +30 -1
- package/src/modules/cart/schema.ts +2 -0
- package/src/modules/cart/schemas.ts +1 -0
- package/src/modules/cart/service.ts +104 -2
- package/src/modules/catalog/entity-service.ts +196 -4
- package/src/modules/catalog/schema.ts +5 -0
- package/src/modules/catalog/schemas.ts +3 -0
- package/src/modules/catalog/service.ts +30 -0
- package/src/modules/customers/service.ts +6 -0
- package/src/modules/documents/module.ts +19 -0
- package/src/modules/documents/pdf.ts +101 -0
- package/src/modules/documents/render.ts +231 -0
- package/src/modules/documents/repository/index.ts +68 -0
- package/src/modules/documents/schema.ts +44 -0
- package/src/modules/documents/service.ts +176 -0
- package/src/modules/fulfillment/service.ts +139 -1
- package/src/modules/orders/repository/index.ts +127 -1
- package/src/modules/orders/schema.ts +58 -1
- package/src/modules/orders/service.ts +1041 -65
- package/src/modules/orders/stale-order-cleanup.ts +18 -4
- package/src/modules/pricing/repository/index.ts +34 -0
- package/src/modules/pricing/service.ts +76 -6
- package/src/modules/settings/module.ts +18 -0
- package/src/modules/settings/repository/index.ts +59 -0
- package/src/modules/settings/schema.ts +25 -0
- package/src/modules/settings/service.ts +82 -0
- package/src/modules/shipping/calculator.ts +27 -0
- package/src/modules/shipping/module.ts +6 -2
- package/src/modules/shipping/repository/index.ts +136 -2
- package/src/modules/shipping/schema.ts +58 -0
- package/src/modules/shipping/service.ts +260 -1
- package/src/modules/tax/adapter.ts +9 -0
- package/src/modules/tax/module.ts +6 -2
- package/src/modules/tax/repository/index.ts +169 -2
- package/src/modules/tax/schema.ts +57 -0
- package/src/modules/tax/service.ts +313 -2
- package/src/runtime/kernel-modules.ts +4 -0
- package/src/runtime/kernel-types.ts +6 -0
- package/src/runtime/server.ts +1 -1
- package/src/test-utils/create-plugin-test-app.ts +17 -4
- 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 +7 -0
- package/src/test-utils/test-actors.ts +1 -1
- package/src/testing.ts +1 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { resolveOrgId } from "../../auth/org.js";
|
|
2
2
|
import { assertOwnership, assertPermission } from "../../auth/permissions.js";
|
|
3
|
-
import { CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, toCommerceError, } from "../../kernel/errors.js";
|
|
3
|
+
import { CommerceForbiddenError, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, toCommerceError, } from "../../kernel/errors.js";
|
|
4
4
|
import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
|
|
5
5
|
import { createHookContext } from "../../kernel/hooks/create-context.js";
|
|
6
6
|
import { canTransition, orderStateMachine, } from "../../kernel/state-machine/machine.js";
|
|
7
7
|
import { Err, Ok } from "../../kernel/result.js";
|
|
8
8
|
import { createLogger } from "../../utils/logger.js";
|
|
9
9
|
import { paginate } from "../../utils/pagination.js";
|
|
10
|
+
import { withTransaction, } from "../../kernel/database/tx-context.js";
|
|
10
11
|
function context(actor, services, database, tx = null) {
|
|
11
12
|
return createHookContext({
|
|
12
13
|
actor,
|
|
@@ -30,7 +31,70 @@ export class OrderService {
|
|
|
30
31
|
const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
|
|
31
32
|
return { ...order, lineItems };
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Resolve every line item's price with org-integrity + price provenance,
|
|
36
|
+
* mirroring how our forefathers handle this (Vendure: order lines are always
|
|
37
|
+
* server-priced from the variant; Medusa: an admin override is honored but
|
|
38
|
+
* flagged `is_custom_price`). Three modes:
|
|
39
|
+
* - trusted caller (checkout / POS exchange, already server-priced) → prices honored as-is.
|
|
40
|
+
* - actor with `orders:manage` → explicit price honored as a manual override, flagged isCustomPrice.
|
|
41
|
+
* - otherwise → price re-derived server-side from the catalog; client prices ignored.
|
|
42
|
+
* The referenced entity MUST belong to the order's org in every mode.
|
|
43
|
+
*/
|
|
44
|
+
async resolveLines(input, actor, trusted, ctx) {
|
|
45
|
+
let canOverride = trusted;
|
|
46
|
+
if (!canOverride) {
|
|
47
|
+
try {
|
|
48
|
+
assertPermission(actor, "orders:manage");
|
|
49
|
+
canOverride = true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
canOverride = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const catalog = this.deps.services.catalog;
|
|
56
|
+
const pricing = this.deps.services.pricing;
|
|
57
|
+
const resolved = [];
|
|
58
|
+
for (const item of input.lineItems) {
|
|
59
|
+
// Org-integrity — the referenced entity must belong to this order's org.
|
|
60
|
+
const owned = await catalog.getById(item.entityId, { includeVariants: item.variantId !== undefined }, actor, ctx);
|
|
61
|
+
if (!owned.ok) {
|
|
62
|
+
return Err(new CommerceValidationError(`Line item entity ${item.entityId} does not belong to this organization.`));
|
|
63
|
+
}
|
|
64
|
+
if (item.variantId !== undefined &&
|
|
65
|
+
!owned.value?.variants?.some((variant) => variant.id === item.variantId)) {
|
|
66
|
+
return Err(new CommerceValidationError(`Line item variant ${item.variantId} does not belong to entity ${item.entityId}.`));
|
|
67
|
+
}
|
|
68
|
+
if (trusted || canOverride) {
|
|
69
|
+
// Trusted pipeline totals, or a staff manual/negotiated override.
|
|
70
|
+
resolved.push({ ...item, isCustomPrice: !trusted && canOverride });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Server-derive the price; the client-supplied unitPrice/totalPrice is ignored.
|
|
74
|
+
const priced = await pricing.resolve({
|
|
75
|
+
entityId: item.entityId,
|
|
76
|
+
currency: input.currency,
|
|
77
|
+
quantity: item.quantity,
|
|
78
|
+
...(item.variantId ? { variantId: item.variantId } : {}),
|
|
79
|
+
...(input.customerId ? { customerId: input.customerId } : {}),
|
|
80
|
+
}, actor, ctx);
|
|
81
|
+
if (!priced.ok || !priced.value) {
|
|
82
|
+
return Err(new CommerceValidationError(`Cannot resolve price for ${item.entityId}.`));
|
|
83
|
+
}
|
|
84
|
+
const unitPrice = priced.value.finalAmount;
|
|
85
|
+
resolved.push({
|
|
86
|
+
...item,
|
|
87
|
+
unitPrice,
|
|
88
|
+
totalPrice: unitPrice * item.quantity,
|
|
89
|
+
isCustomPrice: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return Ok(resolved);
|
|
93
|
+
}
|
|
94
|
+
async create(input, actor, ctx, opts) {
|
|
95
|
+
if (opts?.stockPolicy === "reserve" && !ctx) {
|
|
96
|
+
return withTransaction(this.deps.database, { actor }, (txCtx) => this.create(input, actor, txCtx, opts));
|
|
97
|
+
}
|
|
34
98
|
try {
|
|
35
99
|
assertPermission(actor, "orders:create");
|
|
36
100
|
}
|
|
@@ -44,34 +108,88 @@ export class OrderService {
|
|
|
44
108
|
const afterHooks = this.deps.hooks.resolve("orders.afterCreate");
|
|
45
109
|
const hookCtx = context(actor, this.deps.services, this.deps.database, ctx?.tx);
|
|
46
110
|
const processed = await runBeforeHooks(beforeHooks, input, "create", hookCtx);
|
|
47
|
-
const orderNumber = await this.repo.getNextOrderNumber(ctx);
|
|
48
111
|
const orgId = resolveOrgId(actor);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
// Idempotent replay: offline POS queues and network retries re-submit the
|
|
113
|
+
// same create — return the original order instead of double-creating.
|
|
114
|
+
if (processed.idempotencyKey) {
|
|
115
|
+
const existing = await this.repo.findByIdempotencyKey(orgId, processed.idempotencyKey, ctx);
|
|
116
|
+
if (existing) {
|
|
117
|
+
return Ok(await this.hydrateOrder(existing, ctx));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Resolve line prices with org-integrity + provenance (see resolveLines).
|
|
121
|
+
const trusted = opts?.trustedPricing === true;
|
|
122
|
+
const resolvedResult = await this.resolveLines(processed, actor, trusted, ctx);
|
|
123
|
+
if (!resolvedResult.ok)
|
|
124
|
+
return Err(resolvedResult.error);
|
|
125
|
+
const resolvedLines = resolvedResult.value;
|
|
126
|
+
// Order totals: trusted callers (checkout / exchange) supply rich totals
|
|
127
|
+
// (promotions, tax, shipping); otherwise derive from the server-priced lines.
|
|
128
|
+
const computedSubtotal = trusted
|
|
129
|
+
? processed.subtotal
|
|
130
|
+
: resolvedLines.reduce((sum, l) => sum + (l.totalPrice ?? l.unitPrice * l.quantity), 0);
|
|
131
|
+
const computedGrandTotal = trusted
|
|
132
|
+
? processed.grandTotal
|
|
133
|
+
: computedSubtotal +
|
|
134
|
+
processed.taxTotal +
|
|
135
|
+
processed.shippingTotal -
|
|
136
|
+
(processed.discountTotal ?? 0);
|
|
137
|
+
// Stock: order creation does NOT reserve by default. Stock is allocated at
|
|
138
|
+
// the lifecycle transition to a committed state — checkout reserves in its
|
|
139
|
+
// own pipeline, mirroring Vendure's StockAllocationStrategy (allocate on
|
|
140
|
+
// PaymentAuthorized, not on creation). A caller that represents an immediate
|
|
141
|
+
// committed sale (e.g. a POS exchange) opts in with stockPolicy: "reserve".
|
|
142
|
+
const stockPolicy = opts?.stockPolicy;
|
|
143
|
+
if (stockPolicy === "reserve") {
|
|
144
|
+
const inventory = this.deps.services.inventory;
|
|
145
|
+
for (const line of resolvedLines) {
|
|
146
|
+
const avail = await inventory.getAvailable(line.entityId, line.variantId, ctx, actor);
|
|
147
|
+
if (!avail.ok || (avail.value ?? 0) < line.quantity) {
|
|
148
|
+
return Err(new CommerceValidationError(`Insufficient stock for ${line.title ?? line.entityId}. Available: ${avail.ok ? (avail.value ?? 0) : 0}, requested: ${line.quantity}.`));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const orderNumber = await this.repo.getNextOrderNumber(ctx);
|
|
153
|
+
let order;
|
|
154
|
+
try {
|
|
155
|
+
order = await this.repo.create({
|
|
156
|
+
organizationId: orgId,
|
|
157
|
+
orderNumber,
|
|
158
|
+
status: "pending",
|
|
159
|
+
currency: processed.currency,
|
|
160
|
+
subtotal: computedSubtotal,
|
|
161
|
+
taxTotal: processed.taxTotal,
|
|
162
|
+
shippingTotal: processed.shippingTotal,
|
|
163
|
+
discountTotal: processed.discountTotal ?? 0,
|
|
164
|
+
grandTotal: computedGrandTotal,
|
|
165
|
+
...(processed.paymentIntentId != null ? { paymentIntentId: processed.paymentIntentId } : {}),
|
|
166
|
+
...(processed.paymentMethodId != null ? { paymentMethodId: processed.paymentMethodId } : {}),
|
|
167
|
+
...(processed.idempotencyKey != null ? { idempotencyKey: processed.idempotencyKey } : {}),
|
|
168
|
+
metadata: processed.metadata ?? {},
|
|
169
|
+
placedAt: new Date(),
|
|
170
|
+
...(processed.customerId !== undefined
|
|
171
|
+
? { customerId: processed.customerId }
|
|
172
|
+
: {}),
|
|
173
|
+
}, ctx);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Concurrent replay lost the unique-index race — return the winner.
|
|
177
|
+
if (processed.idempotencyKey) {
|
|
178
|
+
const winner = await this.repo.findByIdempotencyKey(orgId, processed.idempotencyKey, ctx);
|
|
179
|
+
if (winner)
|
|
180
|
+
return Ok(await this.hydrateOrder(winner, ctx));
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
const lineItemsData = resolvedLines.map((item) => ({
|
|
68
185
|
orderId: order.id,
|
|
69
186
|
entityId: item.entityId,
|
|
70
187
|
entityType: item.entityType,
|
|
71
188
|
title: item.title,
|
|
72
189
|
quantity: item.quantity,
|
|
73
190
|
unitPrice: item.unitPrice,
|
|
74
|
-
totalPrice: item.totalPrice,
|
|
191
|
+
totalPrice: item.totalPrice ?? item.unitPrice * item.quantity,
|
|
192
|
+
isCustomPrice: item.isCustomPrice,
|
|
75
193
|
taxAmount: item.taxAmount ?? 0,
|
|
76
194
|
discountAmount: item.discountAmount ?? 0,
|
|
77
195
|
fulfillmentStatus: "unfulfilled",
|
|
@@ -80,6 +198,23 @@ export class OrderService {
|
|
|
80
198
|
...(item.sku !== undefined ? { sku: item.sku } : {}),
|
|
81
199
|
}));
|
|
82
200
|
await this.repo.createLineItems(lineItemsData, ctx);
|
|
201
|
+
// Reserve stock for the resolved lines when the reserve policy is active
|
|
202
|
+
// (availability was verified above). Trusted callers reserve themselves.
|
|
203
|
+
if (stockPolicy === "reserve") {
|
|
204
|
+
const inventory = this.deps.services.inventory;
|
|
205
|
+
for (const line of resolvedLines) {
|
|
206
|
+
const reserved = await inventory.reserve({
|
|
207
|
+
entityId: line.entityId,
|
|
208
|
+
quantity: line.quantity,
|
|
209
|
+
orderId: order.id,
|
|
210
|
+
...(line.variantId ? { variantId: line.variantId } : {}),
|
|
211
|
+
}, actor, ctx);
|
|
212
|
+
if (!reserved.ok) {
|
|
213
|
+
return Err(toCommerceError(reserved.error ??
|
|
214
|
+
new CommerceValidationError(`Unable to reserve stock for ${line.title ?? line.entityId}.`)));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
83
218
|
await this.repo.createStatusHistory({
|
|
84
219
|
orderId: order.id,
|
|
85
220
|
fromStatus: "pending",
|
|
@@ -127,6 +262,14 @@ export class OrderService {
|
|
|
127
262
|
return Err(new CommerceNotFoundError("Order not found."));
|
|
128
263
|
return this.getById(order.id, actor, ctx);
|
|
129
264
|
}
|
|
265
|
+
/** Returns the order previously created with this idempotency key, or null. */
|
|
266
|
+
async getByIdempotencyKey(idempotencyKey, actor, ctx) {
|
|
267
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
268
|
+
const order = await this.repo.findByIdempotencyKey(orgId, idempotencyKey, ctx);
|
|
269
|
+
if (!order)
|
|
270
|
+
return Ok(null);
|
|
271
|
+
return Ok(await this.hydrateOrder(order, ctx));
|
|
272
|
+
}
|
|
130
273
|
async list(params, actor, ctx) {
|
|
131
274
|
try {
|
|
132
275
|
assertPermission(actor, "orders:read");
|
|
@@ -241,6 +384,12 @@ export class OrderService {
|
|
|
241
384
|
await runBeforeHooks(beforeHooks, statusHookInput, "statusChange", hookCtx);
|
|
242
385
|
const previous = order.status;
|
|
243
386
|
const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
|
|
387
|
+
// R-04: goods must not ship against refunded lines. Reject the fulfilled
|
|
388
|
+
// transition if any line has been (partially) refunded.
|
|
389
|
+
if (input.newStatus === "fulfilled" &&
|
|
390
|
+
lineItems.some((li) => li.refundedQuantity > 0)) {
|
|
391
|
+
return Err(new CommerceValidationError("Cannot fulfill an order that has refunded line items."));
|
|
392
|
+
}
|
|
244
393
|
// VAPT r2 (codex) finding: side effects (inventory release, payment
|
|
245
394
|
// refund, tax void) used to run BEFORE the atomic compare-and-swap on
|
|
246
395
|
// updateStatus. Two parallel cancel/refund requests on the same order
|
|
@@ -265,7 +414,7 @@ export class OrderService {
|
|
|
265
414
|
// Fulfilled items had their reservation released during the
|
|
266
415
|
// fulfilled transition — releasing again would double-release.
|
|
267
416
|
if (lineItem.fulfillmentStatus === "unfulfilled") {
|
|
268
|
-
await inventory.release({
|
|
417
|
+
const releaseResult = await inventory.release({
|
|
269
418
|
entityId: lineItem.entityId,
|
|
270
419
|
quantity: lineItem.quantity,
|
|
271
420
|
orderId: order.id,
|
|
@@ -273,7 +422,14 @@ export class OrderService {
|
|
|
273
422
|
...(lineItem.variantId != null
|
|
274
423
|
? { variantId: lineItem.variantId }
|
|
275
424
|
: {}),
|
|
276
|
-
});
|
|
425
|
+
}, actor, ctx);
|
|
426
|
+
if (!releaseResult.ok) {
|
|
427
|
+
const err = toCommerceError(releaseResult.error);
|
|
428
|
+
if (!(err instanceof CommerceValidationError &&
|
|
429
|
+
err.message === "No inventory record found for this entity.")) {
|
|
430
|
+
return Err(err);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
277
433
|
}
|
|
278
434
|
}
|
|
279
435
|
}
|
|
@@ -283,11 +439,19 @@ export class OrderService {
|
|
|
283
439
|
if (paymentIntentId) {
|
|
284
440
|
const payments = this.deps.services.payments;
|
|
285
441
|
if (payments?.refund) {
|
|
286
|
-
|
|
442
|
+
// R-01: subtract prior gateway refunds (line-level refundLines, and
|
|
443
|
+
// undone refunds — the gateway was never reversed) so this order-level
|
|
444
|
+
// refund can't double-pay on top of what has already been refunded.
|
|
445
|
+
const priorRefunds = await this.repo.findRefundsByOrderId(order.id, ctx);
|
|
446
|
+
const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
|
|
447
|
+
const maxRefund = Math.max(0, Math.min(order.grandTotal, order.amountCaptured ?? order.grandTotal) -
|
|
448
|
+
alreadyRefunded);
|
|
287
449
|
const refundAmount = input.refundAmount != null
|
|
288
450
|
? Math.min(input.refundAmount, maxRefund)
|
|
289
451
|
: maxRefund;
|
|
290
|
-
|
|
452
|
+
if (refundAmount > 0) {
|
|
453
|
+
await payments.refund(paymentIntentId, refundAmount, input.reason ?? `order_${input.newStatus}`);
|
|
454
|
+
}
|
|
291
455
|
}
|
|
292
456
|
}
|
|
293
457
|
// 3. Void tax transaction
|
|
@@ -320,7 +484,7 @@ export class OrderService {
|
|
|
320
484
|
: {}),
|
|
321
485
|
});
|
|
322
486
|
// Release reservation (no longer needed)
|
|
323
|
-
await inventory.release({
|
|
487
|
+
const releaseResult = await inventory.release({
|
|
324
488
|
entityId: lineItem.entityId,
|
|
325
489
|
quantity: lineItem.quantity,
|
|
326
490
|
orderId: order.id,
|
|
@@ -328,7 +492,14 @@ export class OrderService {
|
|
|
328
492
|
...(lineItem.variantId != null
|
|
329
493
|
? { variantId: lineItem.variantId }
|
|
330
494
|
: {}),
|
|
331
|
-
});
|
|
495
|
+
}, actor, ctx);
|
|
496
|
+
if (!releaseResult.ok) {
|
|
497
|
+
const err = toCommerceError(releaseResult.error);
|
|
498
|
+
if (!(err instanceof CommerceValidationError &&
|
|
499
|
+
err.message === "No inventory record found for this entity.")) {
|
|
500
|
+
return Err(err);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
332
503
|
// Mark line item as fulfilled
|
|
333
504
|
await this.repo.updateLineItem(lineItem.id, { fulfillmentStatus: "fulfilled" }, ctx);
|
|
334
505
|
}
|
|
@@ -360,6 +531,296 @@ export class OrderService {
|
|
|
360
531
|
...(opts?.amount != null ? { refundAmount: opts.amount } : {}),
|
|
361
532
|
}, actor, ctx);
|
|
362
533
|
}
|
|
534
|
+
// ── Line-level refund policy primitives (issue #52) ─────────────────────
|
|
535
|
+
async refundPolicies(orgId, ctx) {
|
|
536
|
+
const settings = this.deps.services.settings;
|
|
537
|
+
const policies = (await settings?.read(orgId, "policies", ctx)) ?? {};
|
|
538
|
+
const general = (await settings?.read(orgId, "general", ctx)) ?? {};
|
|
539
|
+
return {
|
|
540
|
+
cap: typeof policies.refundDailyCap === "number" ? policies.refundDailyCap : null,
|
|
541
|
+
undoWindowMinutes: typeof policies.refundUndoWindowMinutes === "number"
|
|
542
|
+
? policies.refundUndoWindowMinutes
|
|
543
|
+
: 15,
|
|
544
|
+
timezone: typeof general.timezone === "string" ? general.timezone : "UTC",
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Refunds specific line-item quantities (issue #52). Enforces per-line
|
|
549
|
+
* refundable quantity (`quantity - refundedQuantity`), the operator's daily
|
|
550
|
+
* refund cap (`policies.refundDailyCap`, 403 with the cap surfaced), moves
|
|
551
|
+
* money through the payment adapter when the order has a captured payment,
|
|
552
|
+
* and records an auditable `order_refunds` ledger row.
|
|
553
|
+
*/
|
|
554
|
+
async refundLines(orderId, input, actor, ctx) {
|
|
555
|
+
try {
|
|
556
|
+
assertPermission(actor, "orders:update");
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
return Err(toCommerceError(error));
|
|
560
|
+
}
|
|
561
|
+
const orgId = resolveOrgId(actor);
|
|
562
|
+
const found = await this.repo.findWithLineItems(orgId, orderId, ctx);
|
|
563
|
+
if (!found)
|
|
564
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
565
|
+
if (input.lines.length === 0) {
|
|
566
|
+
return Err(new CommerceValidationError("At least one line is required."));
|
|
567
|
+
}
|
|
568
|
+
// R-02: a terminal order (cancelled / fully refunded) cannot be line-refunded.
|
|
569
|
+
if (this.machine.terminal.includes(found.order.status)) {
|
|
570
|
+
return Err(new CommerceValidationError(`Cannot refund an order in terminal status "${found.order.status}".`));
|
|
571
|
+
}
|
|
572
|
+
// R-03: a refund moves collected money — reject an unpaid order (otherwise a
|
|
573
|
+
// "refund" is recorded against funds never taken). "Paid" is either a core
|
|
574
|
+
// captured payment (checkout: amountCaptured > 0) or the order having left
|
|
575
|
+
// the initial pending state (e.g. a completed POS/in-store sale). Only a
|
|
576
|
+
// still-pending, uncaptured order is treated as unpaid.
|
|
577
|
+
if ((found.order.amountCaptured ?? 0) <= 0 && found.order.status === "pending") {
|
|
578
|
+
return Err(new CommerceValidationError("Cannot refund an unpaid order (no captured payment and still pending)."));
|
|
579
|
+
}
|
|
580
|
+
const byId = new Map(found.lineItems.map((li) => [li.id, li]));
|
|
581
|
+
const refundLines = [];
|
|
582
|
+
for (const line of input.lines) {
|
|
583
|
+
const lineItem = byId.get(line.lineItemId);
|
|
584
|
+
if (!lineItem) {
|
|
585
|
+
return Err(new CommerceNotFoundError(`Line item ${line.lineItemId} not found on this order.`));
|
|
586
|
+
}
|
|
587
|
+
if (!Number.isInteger(line.quantity) || line.quantity < 1) {
|
|
588
|
+
return Err(new CommerceValidationError("Refund quantity must be a positive integer."));
|
|
589
|
+
}
|
|
590
|
+
const refundable = lineItem.quantity - lineItem.refundedQuantity;
|
|
591
|
+
if (line.quantity > refundable) {
|
|
592
|
+
return Err(new CommerceValidationError(`Line "${lineItem.title}" has ${refundable} refundable unit(s); requested ${line.quantity}.`));
|
|
593
|
+
}
|
|
594
|
+
// Effective paid per unit: line total + tax − discount, split evenly.
|
|
595
|
+
const lineValue = lineItem.totalPrice + lineItem.taxAmount - lineItem.discountAmount;
|
|
596
|
+
const amount = Math.round((lineValue * line.quantity) / lineItem.quantity);
|
|
597
|
+
refundLines.push({ lineItemId: lineItem.id, quantity: line.quantity, amount });
|
|
598
|
+
}
|
|
599
|
+
const totalAmount = refundLines.reduce((sum, l) => sum + l.amount, 0);
|
|
600
|
+
const performedBy = actor?.userId ?? "system";
|
|
601
|
+
const policies = await this.refundPolicies(orgId, ctx);
|
|
602
|
+
if (policies.cap != null) {
|
|
603
|
+
const usedToday = await this.repo.sumRefundsByOperatorToday(orgId, performedBy, policies.timezone, ctx);
|
|
604
|
+
if (usedToday + totalAmount > policies.cap) {
|
|
605
|
+
return Err(new CommerceForbiddenError(`Daily refund cap exceeded: cap ${policies.cap}, used ${usedToday} today, requested ${totalAmount}.`));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Move money if a captured payment exists — clamped to what remains.
|
|
609
|
+
if (found.order.paymentIntentId && (found.order.amountCaptured ?? 0) > 0) {
|
|
610
|
+
const payments = this.deps.services.payments;
|
|
611
|
+
if (payments?.refund) {
|
|
612
|
+
const priorRefunds = await this.repo.findRefundsByOrderId(orderId, ctx);
|
|
613
|
+
// Gross total incl. UNDONE refunds: undoRefund reverses the local ledger
|
|
614
|
+
// but not the payment gateway, so that money already left. Counting only
|
|
615
|
+
// "completed" would let refund → undo → refund re-issue a gateway refund
|
|
616
|
+
// (F-04 / R-05 / R-07). Capping on gross keeps total payout ≤ captured.
|
|
617
|
+
const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
|
|
618
|
+
const refundable = Math.max(0, (found.order.amountCaptured ?? 0) - alreadyRefunded);
|
|
619
|
+
const payAmount = Math.min(totalAmount, refundable);
|
|
620
|
+
if (payAmount > 0) {
|
|
621
|
+
await payments.refund(found.order.paymentIntentId, payAmount, input.reason ?? "line_refund");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
for (const line of refundLines) {
|
|
626
|
+
const lineItem = byId.get(line.lineItemId);
|
|
627
|
+
await this.repo.updateLineItem(line.lineItemId, { refundedQuantity: lineItem.refundedQuantity + line.quantity }, ctx);
|
|
628
|
+
}
|
|
629
|
+
const refund = await this.repo.createRefund({
|
|
630
|
+
organizationId: orgId,
|
|
631
|
+
orderId,
|
|
632
|
+
amount: totalAmount,
|
|
633
|
+
reason: input.reason ?? null,
|
|
634
|
+
lines: refundLines,
|
|
635
|
+
performedBy,
|
|
636
|
+
}, ctx);
|
|
637
|
+
await this.repo.createStatusHistory({
|
|
638
|
+
orderId,
|
|
639
|
+
fromStatus: found.order.status,
|
|
640
|
+
toStatus: found.order.status,
|
|
641
|
+
reason: `refund ${refund.id}: ${totalAmount} (${input.reason ?? "line refund"})`,
|
|
642
|
+
changedBy: performedBy,
|
|
643
|
+
}, ctx);
|
|
644
|
+
const hydrated = await this.hydrateOrder((await this.repo.findById(orgId, orderId, ctx)), ctx);
|
|
645
|
+
return Ok({ order: hydrated, refund });
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Undoes a refund within the configured window
|
|
649
|
+
* (`policies.refundUndoWindowMinutes`, default 15). Restores line
|
|
650
|
+
* refundedQuantity and marks the ledger row `undone` — an audited,
|
|
651
|
+
* compensating ledger operation; re-collecting the money (cash back into
|
|
652
|
+
* the drawer) is the operator's side of the exchange.
|
|
653
|
+
*/
|
|
654
|
+
async undoRefund(orderId, refundId, actor, ctx) {
|
|
655
|
+
try {
|
|
656
|
+
assertPermission(actor, "orders:update");
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
return Err(toCommerceError(error));
|
|
660
|
+
}
|
|
661
|
+
const orgId = resolveOrgId(actor);
|
|
662
|
+
const refund = await this.repo.findRefundById(orgId, refundId, ctx);
|
|
663
|
+
if (!refund || refund.orderId !== orderId) {
|
|
664
|
+
return Err(new CommerceNotFoundError("Refund not found."));
|
|
665
|
+
}
|
|
666
|
+
if (refund.status !== "completed") {
|
|
667
|
+
return Err(new CommerceValidationError("Refund has already been undone."));
|
|
668
|
+
}
|
|
669
|
+
const policies = await this.refundPolicies(orgId, ctx);
|
|
670
|
+
const ageMs = Date.now() - refund.createdAt.getTime();
|
|
671
|
+
if (ageMs > policies.undoWindowMinutes * 60_000) {
|
|
672
|
+
return Err(new CommerceValidationError(`Refund undo window (${policies.undoWindowMinutes} minutes) has passed.`));
|
|
673
|
+
}
|
|
674
|
+
const performedBy = actor?.userId ?? "system";
|
|
675
|
+
const undone = await this.repo.markRefundUndone(refundId, performedBy, ctx);
|
|
676
|
+
if (!undone) {
|
|
677
|
+
return Err(new CommerceValidationError("Refund has already been undone."));
|
|
678
|
+
}
|
|
679
|
+
for (const line of refund.lines) {
|
|
680
|
+
const lineItem = await this.repo.findLineItemById(line.lineItemId, ctx);
|
|
681
|
+
if (lineItem) {
|
|
682
|
+
await this.repo.updateLineItem(line.lineItemId, { refundedQuantity: Math.max(0, lineItem.refundedQuantity - line.quantity) }, ctx);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
await this.repo.createStatusHistory({
|
|
686
|
+
orderId,
|
|
687
|
+
fromStatus: "refund_completed",
|
|
688
|
+
toStatus: "refund_undone",
|
|
689
|
+
reason: `refund ${refundId} undone (${refund.amount})`,
|
|
690
|
+
changedBy: performedBy,
|
|
691
|
+
}, ctx);
|
|
692
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
693
|
+
const hydrated = await this.hydrateOrder(order, ctx);
|
|
694
|
+
return Ok({ order: hydrated, refund: undone });
|
|
695
|
+
}
|
|
696
|
+
async listRefunds(orderId, actor, ctx) {
|
|
697
|
+
try {
|
|
698
|
+
assertPermission(actor, "orders:read");
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
return Err(toCommerceError(error));
|
|
702
|
+
}
|
|
703
|
+
const orgId = resolveOrgId(actor);
|
|
704
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
705
|
+
if (!order)
|
|
706
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
707
|
+
return Ok(await this.repo.findRefundsByOrderId(orderId, ctx));
|
|
708
|
+
}
|
|
709
|
+
/** The acting operator's daily refund-cap status (issue #52). */
|
|
710
|
+
async refundCapStatus(actor, ctx) {
|
|
711
|
+
try {
|
|
712
|
+
assertPermission(actor, "orders:read");
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
return Err(toCommerceError(error));
|
|
716
|
+
}
|
|
717
|
+
const orgId = resolveOrgId(actor);
|
|
718
|
+
const policies = await this.refundPolicies(orgId, ctx);
|
|
719
|
+
const usedToday = await this.repo.sumRefundsByOperatorToday(orgId, actor?.userId ?? "system", policies.timezone, ctx);
|
|
720
|
+
return Ok({
|
|
721
|
+
cap: policies.cap,
|
|
722
|
+
usedToday,
|
|
723
|
+
remaining: policies.cap != null ? Math.max(0, policies.cap - usedToday) : null,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
// ── Order notes + activity timeline (issue #56) ─────────────────────────
|
|
727
|
+
async requireOrderAccess(orderId, actor, perm, ctx) {
|
|
728
|
+
try {
|
|
729
|
+
assertPermission(actor, perm);
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
return Err(toCommerceError(error));
|
|
733
|
+
}
|
|
734
|
+
const orgId = resolveOrgId(actor);
|
|
735
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
736
|
+
if (!order)
|
|
737
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
738
|
+
return Ok(order);
|
|
739
|
+
}
|
|
740
|
+
async addNote(orderId, input, actor, ctx) {
|
|
741
|
+
const order = await this.requireOrderAccess(orderId, actor, "orders:update", ctx);
|
|
742
|
+
if (!order.ok)
|
|
743
|
+
return order;
|
|
744
|
+
if (!input.body.trim()) {
|
|
745
|
+
return Err(new CommerceValidationError("Note body must not be empty."));
|
|
746
|
+
}
|
|
747
|
+
const note = await this.repo.createNote({
|
|
748
|
+
organizationId: order.value.organizationId,
|
|
749
|
+
orderId,
|
|
750
|
+
author: actor?.userId ?? "system",
|
|
751
|
+
body: input.body,
|
|
752
|
+
pinned: input.pinned ?? false,
|
|
753
|
+
}, ctx);
|
|
754
|
+
return Ok(note);
|
|
755
|
+
}
|
|
756
|
+
async listNotes(orderId, actor, ctx) {
|
|
757
|
+
const order = await this.requireOrderAccess(orderId, actor, "orders:read", ctx);
|
|
758
|
+
if (!order.ok)
|
|
759
|
+
return order;
|
|
760
|
+
return Ok(await this.repo.findNotesByOrderId(orderId, ctx));
|
|
761
|
+
}
|
|
762
|
+
async deleteNote(orderId, noteId, actor, ctx) {
|
|
763
|
+
const order = await this.requireOrderAccess(orderId, actor, "orders:update", ctx);
|
|
764
|
+
if (!order.ok)
|
|
765
|
+
return order;
|
|
766
|
+
const deleted = await this.repo.deleteNote(order.value.organizationId, orderId, noteId, ctx);
|
|
767
|
+
if (!deleted)
|
|
768
|
+
return Err(new CommerceNotFoundError("Note not found."));
|
|
769
|
+
return Ok({ deleted: true });
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* One merged per-order activity view (issue #56): status history + operator
|
|
773
|
+
* notes + refund ledger events (both directions), newest first.
|
|
774
|
+
*/
|
|
775
|
+
async timeline(orderId, actor, ctx) {
|
|
776
|
+
const order = await this.requireOrderAccess(orderId, actor, "orders:read", ctx);
|
|
777
|
+
if (!order.ok)
|
|
778
|
+
return order;
|
|
779
|
+
const [history, notes, refunds] = await Promise.all([
|
|
780
|
+
this.repo.findStatusHistoryByOrderId(orderId, ctx),
|
|
781
|
+
this.repo.findNotesByOrderId(orderId, ctx),
|
|
782
|
+
this.repo.findRefundsByOrderId(orderId, ctx),
|
|
783
|
+
]);
|
|
784
|
+
const events = [];
|
|
785
|
+
for (const entry of history) {
|
|
786
|
+
events.push({
|
|
787
|
+
type: "status",
|
|
788
|
+
at: entry.changedAt,
|
|
789
|
+
actor: entry.changedBy,
|
|
790
|
+
summary: `Status ${entry.fromStatus} → ${entry.toStatus}${entry.reason ? ` (${entry.reason})` : ""}`,
|
|
791
|
+
data: { fromStatus: entry.fromStatus, toStatus: entry.toStatus, reason: entry.reason },
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
for (const note of notes) {
|
|
795
|
+
events.push({
|
|
796
|
+
type: "note",
|
|
797
|
+
at: note.createdAt,
|
|
798
|
+
actor: note.author,
|
|
799
|
+
summary: note.body,
|
|
800
|
+
data: { noteId: note.id, pinned: note.pinned },
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
for (const refund of refunds) {
|
|
804
|
+
events.push({
|
|
805
|
+
type: "refund",
|
|
806
|
+
at: refund.createdAt,
|
|
807
|
+
actor: refund.performedBy,
|
|
808
|
+
summary: `Refund of ${refund.amount}${refund.reason ? ` (${refund.reason})` : ""}`,
|
|
809
|
+
data: { refundId: refund.id, amount: refund.amount, lines: refund.lines, status: refund.status },
|
|
810
|
+
});
|
|
811
|
+
if (refund.status === "undone" && refund.undoneAt) {
|
|
812
|
+
events.push({
|
|
813
|
+
type: "refund",
|
|
814
|
+
at: refund.undoneAt,
|
|
815
|
+
actor: refund.undoneBy ?? "system",
|
|
816
|
+
summary: `Refund of ${refund.amount} undone`,
|
|
817
|
+
data: { refundId: refund.id, amount: refund.amount, undone: true },
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
events.sort((a, b) => b.at.getTime() - a.at.getTime());
|
|
822
|
+
return Ok(events);
|
|
823
|
+
}
|
|
363
824
|
/**
|
|
364
825
|
* Capture an authorized payment for an order via the payment adapter and
|
|
365
826
|
* record the captured amount. Does not transition order status — capture is a
|
|
@@ -407,6 +868,169 @@ export class OrderService {
|
|
|
407
868
|
items.sort((a, b) => a.changedAt.getTime() - b.changedAt.getTime());
|
|
408
869
|
return Ok(items);
|
|
409
870
|
}
|
|
871
|
+
/** Line items may not be edited on terminal or fully-captured orders. */
|
|
872
|
+
lineItemEditGuard(order) {
|
|
873
|
+
if (this.machine.terminal.includes(order.status)) {
|
|
874
|
+
return new CommerceValidationError(`Order in terminal status "${order.status}" cannot be edited.`);
|
|
875
|
+
}
|
|
876
|
+
if (order.amountCaptured != null &&
|
|
877
|
+
order.amountCaptured >= order.grandTotal) {
|
|
878
|
+
return new CommerceValidationError("Order payment is fully captured; line items cannot be edited.");
|
|
879
|
+
}
|
|
880
|
+
// R-06: a checkout order carries a paymentIntentId. If amountCaptured is null
|
|
881
|
+
// (auth-only capture, or a mock adapter reporting 0), treat it as authorized —
|
|
882
|
+
// an order with a live payment must not have its lines silently edited/inflated.
|
|
883
|
+
if (order.paymentIntentId != null && order.amountCaptured == null) {
|
|
884
|
+
return new CommerceValidationError("Order has an authorized payment; line items cannot be edited.");
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Recomputes subtotal/taxTotal/grandTotal from the order's line items
|
|
890
|
+
* (shippingTotal and order-level discountTotal are preserved), persists
|
|
891
|
+
* them, and records an audit entry in the status history.
|
|
892
|
+
*/
|
|
893
|
+
async recalcOrderTotals(order, actor, reason, ctx) {
|
|
894
|
+
const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
|
|
895
|
+
const subtotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
|
|
896
|
+
const taxTotal = lineItems.reduce((sum, li) => sum + li.taxAmount, 0);
|
|
897
|
+
const grandTotal = subtotal + taxTotal + order.shippingTotal - order.discountTotal;
|
|
898
|
+
const updated = await this.repo.update(order.id, { subtotal, taxTotal, grandTotal }, ctx);
|
|
899
|
+
await this.repo.createStatusHistory({
|
|
900
|
+
orderId: order.id,
|
|
901
|
+
fromStatus: order.status,
|
|
902
|
+
toStatus: order.status,
|
|
903
|
+
reason,
|
|
904
|
+
changedBy: actor?.userId ?? "system",
|
|
905
|
+
}, ctx);
|
|
906
|
+
return { ...(updated ?? order), lineItems };
|
|
907
|
+
}
|
|
908
|
+
async addLineItem(orderId, input, actor, ctx) {
|
|
909
|
+
try {
|
|
910
|
+
assertPermission(actor, "orders:update");
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
return Err(toCommerceError(error));
|
|
914
|
+
}
|
|
915
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
916
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
917
|
+
if (!order)
|
|
918
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
919
|
+
const guard = this.lineItemEditGuard(order);
|
|
920
|
+
if (guard)
|
|
921
|
+
return Err(guard);
|
|
922
|
+
// Org-integrity + price provenance for the added line (same rules as create):
|
|
923
|
+
// the entity must belong to the order's org, and the price is server-derived
|
|
924
|
+
// unless the actor holds `orders:manage` (then honored + flagged isCustomPrice).
|
|
925
|
+
const resolvedResult = await this.resolveLines({
|
|
926
|
+
currency: order.currency,
|
|
927
|
+
subtotal: 0,
|
|
928
|
+
taxTotal: 0,
|
|
929
|
+
shippingTotal: 0,
|
|
930
|
+
grandTotal: 0,
|
|
931
|
+
lineItems: [
|
|
932
|
+
{
|
|
933
|
+
entityId: input.entityId,
|
|
934
|
+
entityType: input.entityType,
|
|
935
|
+
title: input.title,
|
|
936
|
+
quantity: input.quantity,
|
|
937
|
+
unitPrice: input.unitPrice,
|
|
938
|
+
totalPrice: input.totalPrice ?? input.unitPrice * input.quantity,
|
|
939
|
+
...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
|
|
940
|
+
...(input.sku !== undefined ? { sku: input.sku } : {}),
|
|
941
|
+
...(input.taxAmount !== undefined ? { taxAmount: input.taxAmount } : {}),
|
|
942
|
+
...(input.discountAmount !== undefined
|
|
943
|
+
? { discountAmount: input.discountAmount }
|
|
944
|
+
: {}),
|
|
945
|
+
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
...(order.customerId ? { customerId: order.customerId } : {}),
|
|
949
|
+
}, actor, false, ctx);
|
|
950
|
+
if (!resolvedResult.ok)
|
|
951
|
+
return Err(resolvedResult.error);
|
|
952
|
+
const line = resolvedResult.value[0];
|
|
953
|
+
await this.repo.createLineItems([
|
|
954
|
+
{
|
|
955
|
+
orderId: order.id,
|
|
956
|
+
entityId: line.entityId,
|
|
957
|
+
entityType: line.entityType,
|
|
958
|
+
title: line.title,
|
|
959
|
+
quantity: line.quantity,
|
|
960
|
+
unitPrice: line.unitPrice,
|
|
961
|
+
totalPrice: line.totalPrice ?? line.unitPrice * line.quantity,
|
|
962
|
+
isCustomPrice: line.isCustomPrice,
|
|
963
|
+
taxAmount: line.taxAmount ?? 0,
|
|
964
|
+
discountAmount: line.discountAmount ?? 0,
|
|
965
|
+
fulfillmentStatus: "unfulfilled",
|
|
966
|
+
metadata: line.metadata ?? {},
|
|
967
|
+
...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
|
|
968
|
+
...(line.sku !== undefined ? { sku: line.sku } : {}),
|
|
969
|
+
},
|
|
970
|
+
], ctx);
|
|
971
|
+
return Ok(await this.recalcOrderTotals(order, actor, "line_item_added", ctx));
|
|
972
|
+
}
|
|
973
|
+
async updateOrderLineItem(orderId, lineItemId, patch, actor, ctx) {
|
|
974
|
+
try {
|
|
975
|
+
assertPermission(actor, "orders:update");
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
return Err(toCommerceError(error));
|
|
979
|
+
}
|
|
980
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
981
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
982
|
+
if (!order)
|
|
983
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
984
|
+
const guard = this.lineItemEditGuard(order);
|
|
985
|
+
if (guard)
|
|
986
|
+
return Err(guard);
|
|
987
|
+
const line = await this.repo.findLineItemById(lineItemId, ctx);
|
|
988
|
+
if (!line || line.orderId !== order.id) {
|
|
989
|
+
return Err(new CommerceNotFoundError("Line item not found on this order."));
|
|
990
|
+
}
|
|
991
|
+
if (line.fulfillmentStatus !== "unfulfilled") {
|
|
992
|
+
return Err(new CommerceValidationError("Line items with fulfillment progress cannot be adjusted."));
|
|
993
|
+
}
|
|
994
|
+
if (patch.quantity < 1) {
|
|
995
|
+
return Err(new CommerceValidationError("Quantity must be at least 1."));
|
|
996
|
+
}
|
|
997
|
+
// Scale line totals with the quantity change (tax scales per-unit).
|
|
998
|
+
const perUnitTax = line.quantity > 0 ? line.taxAmount / line.quantity : 0;
|
|
999
|
+
await this.repo.updateLineItem(lineItemId, {
|
|
1000
|
+
quantity: patch.quantity,
|
|
1001
|
+
totalPrice: line.unitPrice * patch.quantity,
|
|
1002
|
+
taxAmount: Math.round(perUnitTax * patch.quantity),
|
|
1003
|
+
}, ctx);
|
|
1004
|
+
return Ok(await this.recalcOrderTotals(order, actor, "line_item_updated", ctx));
|
|
1005
|
+
}
|
|
1006
|
+
async removeLineItem(orderId, lineItemId, actor, ctx) {
|
|
1007
|
+
try {
|
|
1008
|
+
assertPermission(actor, "orders:update");
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
return Err(toCommerceError(error));
|
|
1012
|
+
}
|
|
1013
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
1014
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
1015
|
+
if (!order)
|
|
1016
|
+
return Err(new CommerceNotFoundError("Order not found."));
|
|
1017
|
+
const guard = this.lineItemEditGuard(order);
|
|
1018
|
+
if (guard)
|
|
1019
|
+
return Err(guard);
|
|
1020
|
+
const line = await this.repo.findLineItemById(lineItemId, ctx);
|
|
1021
|
+
if (!line || line.orderId !== order.id) {
|
|
1022
|
+
return Err(new CommerceNotFoundError("Line item not found on this order."));
|
|
1023
|
+
}
|
|
1024
|
+
if (line.fulfillmentStatus !== "unfulfilled") {
|
|
1025
|
+
return Err(new CommerceValidationError("Line items with fulfillment progress cannot be removed."));
|
|
1026
|
+
}
|
|
1027
|
+
const existing = await this.repo.findLineItemsByOrderId(order.id, ctx);
|
|
1028
|
+
if (existing.length <= 1) {
|
|
1029
|
+
return Err(new CommerceValidationError("An order must keep at least one line item."));
|
|
1030
|
+
}
|
|
1031
|
+
await this.repo.deleteLineItem(lineItemId, ctx);
|
|
1032
|
+
return Ok(await this.recalcOrderTotals(order, actor, "line_item_removed", ctx));
|
|
1033
|
+
}
|
|
410
1034
|
async updateOrder(orderId, data, actor, ctx) {
|
|
411
1035
|
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
412
1036
|
const order = await this.repo.findById(orgId, orderId, ctx);
|