@porulle/core 0.1.0 → 0.5.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 (165) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +17 -1
  3. package/dist/config/define-config.d.ts.map +1 -1
  4. package/dist/config/define-config.js +6 -0
  5. package/dist/config/types.d.ts +12 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/hooks/checkout.d.ts +3 -3
  8. package/dist/hooks/checkout.js +4 -4
  9. package/dist/index.d.ts +9 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
  13. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
  14. package/dist/interfaces/rest/audit-middleware.js +82 -0
  15. package/dist/interfaces/rest/parse-json.d.ts +39 -0
  16. package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
  17. package/dist/interfaces/rest/parse-json.js +45 -0
  18. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  19. package/dist/interfaces/rest/routes/catalog.js +17 -2
  20. package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
  21. package/dist/interfaces/rest/routes/customers.js +59 -4
  22. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  23. package/dist/interfaces/rest/routes/inventory.js +4 -2
  24. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  25. package/dist/interfaces/rest/routes/media.js +6 -0
  26. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  27. package/dist/interfaces/rest/routes/orders.js +23 -1
  28. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  29. package/dist/interfaces/rest/routes/promotions.js +21 -1
  30. package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
  31. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  32. package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
  33. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  34. package/dist/interfaces/rest/schemas/catalog.js +28 -1
  35. package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
  36. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  37. package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
  38. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  39. package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
  40. package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
  41. package/dist/interfaces/rest/schemas/customers.js +88 -0
  42. package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
  43. package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
  44. package/dist/interfaces/rest/schemas/media.d.ts +105 -0
  45. package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
  46. package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
  47. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  48. package/dist/interfaces/rest/schemas/orders.js +20 -0
  49. package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
  50. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  51. package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
  52. package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
  53. package/dist/interfaces/rest/schemas/promotions.js +25 -1
  54. package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
  55. package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
  56. package/dist/interfaces/rest/schemas/shared.js +8 -0
  57. package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
  58. package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
  59. package/dist/kernel/database/adapter.d.ts +6 -0
  60. package/dist/kernel/database/adapter.d.ts.map +1 -1
  61. package/dist/kernel/database/adapter.js +62 -1
  62. package/dist/kernel/database/migrate.d.ts +22 -2
  63. package/dist/kernel/database/migrate.d.ts.map +1 -1
  64. package/dist/kernel/database/migrate.js +42 -2
  65. package/dist/modules/analytics/hooks.d.ts +5 -5
  66. package/dist/modules/analytics/hooks.js +5 -5
  67. package/dist/modules/analytics/repository/index.d.ts.map +1 -1
  68. package/dist/modules/catalog/category-service.d.ts +6 -1
  69. package/dist/modules/catalog/category-service.d.ts.map +1 -1
  70. package/dist/modules/catalog/category-service.js +35 -5
  71. package/dist/modules/catalog/schema.d.ts +17 -0
  72. package/dist/modules/catalog/schema.d.ts.map +1 -1
  73. package/dist/modules/catalog/schema.js +2 -0
  74. package/dist/modules/catalog/service.d.ts +11 -2
  75. package/dist/modules/catalog/service.d.ts.map +1 -1
  76. package/dist/modules/catalog/service.js +8 -2
  77. package/dist/modules/customers/repository/index.d.ts +8 -1
  78. package/dist/modules/customers/repository/index.d.ts.map +1 -1
  79. package/dist/modules/customers/repository/index.js +42 -2
  80. package/dist/modules/customers/schema.d.ts +162 -0
  81. package/dist/modules/customers/schema.d.ts.map +1 -1
  82. package/dist/modules/customers/schema.js +18 -0
  83. package/dist/modules/customers/service.d.ts +32 -2
  84. package/dist/modules/customers/service.d.ts.map +1 -1
  85. package/dist/modules/customers/service.js +119 -3
  86. package/dist/modules/inventory/schemas.d.ts +7 -1
  87. package/dist/modules/inventory/schemas.d.ts.map +1 -1
  88. package/dist/modules/inventory/schemas.js +6 -1
  89. package/dist/modules/inventory/service.d.ts +18 -0
  90. package/dist/modules/inventory/service.d.ts.map +1 -1
  91. package/dist/modules/inventory/service.js +58 -21
  92. package/dist/modules/media/noop-adapter.d.ts +11 -0
  93. package/dist/modules/media/noop-adapter.d.ts.map +1 -0
  94. package/dist/modules/media/noop-adapter.js +31 -0
  95. package/dist/modules/orders/repository/index.d.ts +19 -0
  96. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  97. package/dist/modules/orders/repository/index.js +42 -1
  98. package/dist/modules/orders/service.d.ts +31 -0
  99. package/dist/modules/orders/service.d.ts.map +1 -1
  100. package/dist/modules/orders/service.js +52 -2
  101. package/dist/modules/payments/repository/index.d.ts.map +1 -1
  102. package/dist/modules/promotions/schemas.d.ts +46 -0
  103. package/dist/modules/promotions/schemas.d.ts.map +1 -1
  104. package/dist/modules/promotions/schemas.js +19 -8
  105. package/dist/modules/promotions/service.d.ts +8 -2
  106. package/dist/modules/promotions/service.d.ts.map +1 -1
  107. package/dist/modules/promotions/service.js +74 -9
  108. package/dist/modules/search/repository/index.d.ts.map +1 -1
  109. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  110. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  111. package/dist/runtime/client-ip.d.ts +31 -0
  112. package/dist/runtime/client-ip.d.ts.map +1 -0
  113. package/dist/runtime/client-ip.js +31 -0
  114. package/dist/runtime/server.d.ts +4 -0
  115. package/dist/runtime/server.d.ts.map +1 -1
  116. package/dist/runtime/server.js +35 -19
  117. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  118. package/dist/test-utils/create-plugin-test-app.js +4 -1
  119. package/dist/test-utils/test-actors.d.ts +3 -2
  120. package/dist/test-utils/test-actors.d.ts.map +1 -1
  121. package/dist/test-utils/test-actors.js +3 -2
  122. package/package.json +4 -4
  123. package/src/auth/middleware.ts +18 -1
  124. package/src/config/define-config.ts +7 -0
  125. package/src/config/types.ts +12 -0
  126. package/src/hooks/checkout.ts +4 -4
  127. package/src/index.ts +18 -1
  128. package/src/interfaces/rest/audit-middleware.ts +110 -0
  129. package/src/interfaces/rest/parse-json.ts +81 -0
  130. package/src/interfaces/rest/routes/catalog.ts +21 -1
  131. package/src/interfaces/rest/routes/customers.ts +64 -3
  132. package/src/interfaces/rest/routes/inventory.ts +4 -2
  133. package/src/interfaces/rest/routes/media.ts +7 -0
  134. package/src/interfaces/rest/routes/orders.ts +21 -1
  135. package/src/interfaces/rest/routes/promotions.ts +22 -1
  136. package/src/interfaces/rest/schemas/catalog.ts +30 -1
  137. package/src/interfaces/rest/schemas/customers.ts +99 -0
  138. package/src/interfaces/rest/schemas/orders.ts +21 -0
  139. package/src/interfaces/rest/schemas/promotions.ts +32 -1
  140. package/src/interfaces/rest/schemas/shared.ts +9 -0
  141. package/src/kernel/database/adapter.ts +64 -1
  142. package/src/kernel/database/migrate.ts +50 -2
  143. package/src/modules/analytics/hooks.ts +5 -5
  144. package/src/modules/analytics/repository/index.ts +0 -1
  145. package/src/modules/catalog/category-service.ts +26 -5
  146. package/src/modules/catalog/schema.ts +2 -0
  147. package/src/modules/catalog/service.ts +25 -3
  148. package/src/modules/customers/repository/index.ts +55 -1
  149. package/src/modules/customers/schema.ts +19 -0
  150. package/src/modules/customers/service.ts +137 -2
  151. package/src/modules/inventory/schemas.ts +6 -1
  152. package/src/modules/inventory/service.ts +84 -29
  153. package/src/modules/media/noop-adapter.ts +36 -0
  154. package/src/modules/orders/repository/index.ts +63 -1
  155. package/src/modules/orders/service.ts +86 -3
  156. package/src/modules/payments/repository/index.ts +0 -1
  157. package/src/modules/promotions/schemas.ts +21 -4
  158. package/src/modules/promotions/service.ts +90 -18
  159. package/src/modules/search/repository/index.ts +0 -1
  160. package/src/modules/shipping/repository/index.ts +0 -1
  161. package/src/modules/tax/repository/index.ts +0 -1
  162. package/src/runtime/client-ip.ts +49 -0
  163. package/src/runtime/server.ts +39 -18
  164. package/src/test-utils/create-plugin-test-app.ts +3 -1
  165. package/src/test-utils/test-actors.ts +3 -2
@@ -310,40 +310,87 @@ export class InventoryService {
310
310
  });
311
311
  }
312
312
 
313
- async adjust(
313
+ /**
314
+ * Adjust inventory with optional add/remove/set modes, returning before /
315
+ * after / delta and the movement id. `mode` omitted ⇒ signed-delta (legacy)
316
+ * behavior using `adjustment`. `remove` clamps at 0; `set` writes an absolute
317
+ * value. The lock, compute, write, and movement all happen in one transaction
318
+ * so concurrent adjustments can't lose updates.
319
+ */
320
+ async adjustDetailed(
314
321
  input: InventoryAdjustInput,
315
322
  actor?: Actor | null,
316
323
  ctx?: TxContext,
317
- ): Promise<Result<InventoryLevel>> {
324
+ ): Promise<
325
+ Result<{
326
+ level: InventoryLevel;
327
+ before: number;
328
+ after: number;
329
+ delta: number;
330
+ movementId: string;
331
+ }>
332
+ > {
318
333
  try {
319
334
  assertPermission(actor ?? null, "inventory:adjust");
320
335
  } catch (error) {
321
336
  return Err(toCommerceError(error));
322
337
  }
323
338
 
339
+ // Cross-field validation: mode form needs `amount`; legacy form needs `adjustment`.
340
+ if (input.mode) {
341
+ if (input.amount === undefined) {
342
+ return Err(new CommerceValidationError("`amount` is required when `mode` is set."));
343
+ }
344
+ } else if (input.adjustment === undefined) {
345
+ return Err(new CommerceValidationError("`adjustment` is required when `mode` is omitted."));
346
+ }
347
+
324
348
  const warehouseId = input.warehouseId ?? (await this.pickWarehouse(actor, ctx));
325
349
  const variantId = input.variantId ?? null;
326
350
  const performedBy = input.performedBy ?? actor?.userId ?? "system";
327
351
 
328
- const doAdjust = async (txCtx: TxContext): Promise<Result<InventoryLevel>> => {
329
- const orgId = resolveOrgId(
330
- actor ?? txCtx.actor ?? null,
331
- undefined,
332
- this.deps.config,
333
- );
334
- // Row-locked read + atomic SQL increment prevents lost updates
335
- const lockResult = await this.repo.adjustWithLock(
352
+ const doAdjust = async (txCtx: TxContext) => {
353
+ const orgId = resolveOrgId(actor ?? txCtx.actor ?? null, undefined, this.deps.config);
354
+
355
+ // Lock the level first so `before` and the write are atomic.
356
+ const existing = await this.repo.findLevelForUpdate(
336
357
  orgId,
337
358
  input.entityId,
338
359
  variantId,
339
360
  warehouseId,
340
- input.adjustment,
341
361
  txCtx,
342
362
  );
363
+ const before = existing?.quantityOnHand ?? 0;
364
+
365
+ const amount = input.amount ?? 0;
366
+ let effectiveAdjustment: number;
367
+ switch (input.mode) {
368
+ case "add":
369
+ effectiveAdjustment = amount;
370
+ break;
371
+ case "remove":
372
+ effectiveAdjustment = -amount;
373
+ break;
374
+ case "set":
375
+ effectiveAdjustment = amount - before;
376
+ break;
377
+ default:
378
+ effectiveAdjustment = input.adjustment ?? 0;
379
+ break;
380
+ }
343
381
 
344
382
  let level: InventoryLevel;
345
- if (lockResult.ok) {
346
- level = lockResult.level;
383
+ if (existing) {
384
+ // Row-locked atomic SQL increment (GREATEST(0, qoh + delta)).
385
+ const lockResult = await this.repo.adjustWithLock(
386
+ orgId,
387
+ input.entityId,
388
+ variantId,
389
+ warehouseId,
390
+ effectiveAdjustment,
391
+ txCtx,
392
+ );
393
+ level = lockResult.ok ? lockResult.level : existing;
347
394
  } else {
348
395
  // No existing level — create is safe; unique index on
349
396
  // (entityId, variantId, warehouseId) prevents duplicate inserts.
@@ -352,35 +399,30 @@ export class InventoryService {
352
399
  organizationId: orgId,
353
400
  entityId: input.entityId,
354
401
  warehouseId,
355
- quantityOnHand: Math.max(0, input.adjustment),
402
+ quantityOnHand: Math.max(0, effectiveAdjustment),
356
403
  quantityReserved: 0,
357
404
  quantityIncoming: 0,
358
- ...(input.variantId !== undefined
359
- ? { variantId: input.variantId }
360
- : {}),
405
+ ...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
361
406
  },
362
407
  txCtx,
363
408
  );
364
409
  }
365
410
 
366
- await this.repo.createMovement(
411
+ const after = level.quantityOnHand;
412
+ const delta = after - before;
413
+
414
+ const movement = await this.repo.createMovement(
367
415
  {
368
416
  organizationId: orgId,
369
417
  entityId: input.entityId,
370
418
  warehouseId,
371
419
  type: "adjustment",
372
- quantity: input.adjustment,
420
+ quantity: delta,
373
421
  reason: input.reason,
374
422
  performedBy,
375
- ...(input.variantId !== undefined
376
- ? { variantId: input.variantId }
377
- : {}),
378
- ...(input.referenceType !== undefined
379
- ? { referenceType: input.referenceType }
380
- : {}),
381
- ...(input.referenceId !== undefined
382
- ? { referenceId: input.referenceId }
383
- : {}),
423
+ ...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
424
+ ...(input.referenceType !== undefined ? { referenceType: input.referenceType } : {}),
425
+ ...(input.referenceId !== undefined ? { referenceId: input.referenceId } : {}),
384
426
  },
385
427
  txCtx,
386
428
  );
@@ -403,7 +445,7 @@ export class InventoryService {
403
445
  hookCtx,
404
446
  );
405
447
 
406
- return Ok(level);
448
+ return Ok({ level, before, after, delta, movementId: movement.id });
407
449
  };
408
450
 
409
451
  return this.withTransaction(ctx, async (tx) => {
@@ -412,6 +454,19 @@ export class InventoryService {
412
454
  });
413
455
  }
414
456
 
457
+ /**
458
+ * Back-compat wrapper: adjust inventory and return just the level. Existing
459
+ * callers and the signed-delta `adjustment` form are unchanged.
460
+ */
461
+ async adjust(
462
+ input: InventoryAdjustInput,
463
+ actor?: Actor | null,
464
+ ctx?: TxContext,
465
+ ): Promise<Result<InventoryLevel>> {
466
+ const result = await this.adjustDetailed(input, actor, ctx);
467
+ return result.ok ? Ok(result.value.level) : result;
468
+ }
469
+
415
470
  /**
416
471
  * Set inventory to an absolute quantity (not a delta).
417
472
  *
@@ -0,0 +1,36 @@
1
+ import type { CommerceError } from "../../kernel/errors.js";
2
+ import { Ok, Err, type Result } from "../../kernel/result.js";
3
+ import type { StorageAdapter } from "./adapter.js";
4
+
5
+ const notSupported = (op: string): Result<never, CommerceError> =>
6
+ Err({
7
+ code: "STORAGE_NOT_SUPPORTED",
8
+ message: `storage.${op} is disabled — no storage adapter is configured.`,
9
+ });
10
+
11
+ /**
12
+ * The default `StorageAdapter` used when `defineConfig` is given no `storage`.
13
+ *
14
+ * Lets a catalog-only deployment boot with zero storage config: read-only
15
+ * `getUrl` passes the key through (so catalog projections keep working), while
16
+ * the mutating media operations return a typed `STORAGE_NOT_SUPPORTED` error.
17
+ * Configure a real adapter (local/S3/R2) to enable media.
18
+ */
19
+ export const noopStorageAdapter: StorageAdapter = {
20
+ providerId: "noop",
21
+ async upload() {
22
+ return notSupported("upload");
23
+ },
24
+ async getUrl(key) {
25
+ return Ok(key);
26
+ },
27
+ async getSignedUrl() {
28
+ return notSupported("getSignedUrl");
29
+ },
30
+ async delete() {
31
+ return Ok(undefined);
32
+ },
33
+ async list() {
34
+ return Ok([]);
35
+ },
36
+ };
@@ -1,10 +1,23 @@
1
- import { eq, and, desc, sql } from "drizzle-orm";
1
+ import { eq, and, or, gte, lte, desc, sql } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import type {
4
4
  DrizzleDatabase,
5
5
  DbOrTx,
6
6
  } from "../../../kernel/database/drizzle-db.js";
7
7
  import { orders, orderLineItems, orderStatusHistory } from "../schema.js";
8
+ import { customers } from "../../customers/schema.js";
9
+
10
+ export interface OrderLookupRow {
11
+ id: string;
12
+ orderNumber: string;
13
+ placedAt: Date;
14
+ status: string;
15
+ grandTotal: number;
16
+ customerId: string | null;
17
+ firstName: string | null;
18
+ lastName: string | null;
19
+ phone: string | null;
20
+ }
8
21
 
9
22
  // Infer types from Drizzle schema
10
23
  export type Order = typeof orders.$inferSelect;
@@ -66,6 +79,55 @@ export class OrdersRepository {
66
79
  .orderBy(desc(orders.placedAt));
67
80
  }
68
81
 
82
+ /**
83
+ * Fuzzy lookup across order number, customer email/name/phone, and the
84
+ * walk-in label. Phone matches ignore non-digits on both sides. Org-scoped.
85
+ */
86
+ async lookup(
87
+ orgId: string,
88
+ q: string,
89
+ opts: { from?: Date; to?: Date },
90
+ ctx?: TxContext,
91
+ ): Promise<OrderLookupRow[]> {
92
+ const db = this.getDb(ctx);
93
+ const like = `%${q}%`;
94
+ const qDigits = q.replace(/\D/g, "");
95
+
96
+ const matchers = [
97
+ sql`${orders.orderNumber} ILIKE ${like}`,
98
+ sql`${customers.email} ILIKE ${like}`,
99
+ sql`(coalesce(${customers.firstName}, '') || ' ' || coalesce(${customers.lastName}, '')) ILIKE ${like}`,
100
+ sql`(${orders.metadata} ->> 'customerLabel') ILIKE ${like}`,
101
+ ];
102
+ if (qDigits.length >= 3) {
103
+ matchers.push(
104
+ sql`regexp_replace(coalesce(${customers.phone}, ''), '[^0-9]', '', 'g') ILIKE ${`%${qDigits}%`}`,
105
+ );
106
+ }
107
+
108
+ const filters = [eq(orders.organizationId, orgId), or(...matchers)];
109
+ if (opts.from) filters.push(gte(orders.placedAt, opts.from));
110
+ if (opts.to) filters.push(lte(orders.placedAt, opts.to));
111
+
112
+ return db
113
+ .select({
114
+ id: orders.id,
115
+ orderNumber: orders.orderNumber,
116
+ placedAt: orders.placedAt,
117
+ status: orders.status,
118
+ grandTotal: orders.grandTotal,
119
+ customerId: customers.id,
120
+ firstName: customers.firstName,
121
+ lastName: customers.lastName,
122
+ phone: customers.phone,
123
+ })
124
+ .from(orders)
125
+ .leftJoin(customers, eq(orders.customerId, customers.id))
126
+ .where(and(...filters))
127
+ .orderBy(desc(orders.placedAt))
128
+ .limit(50) as Promise<OrderLookupRow[]>;
129
+ }
130
+
69
131
  async findByStatus(orgId: string, status: string, ctx?: TxContext): Promise<Order[]> {
70
132
  const db = this.getDb(ctx);
71
133
  return db
@@ -64,6 +64,14 @@ export interface ListOrdersParams {
64
64
  page?: number;
65
65
  limit?: number;
66
66
  status?: string;
67
+ /** When true, listByCustomer also returns a lifetime-spend rollup. */
68
+ includeTotals?: boolean;
69
+ }
70
+
71
+ export interface CustomerOrderTotals {
72
+ count: number;
73
+ lifetimeSpend: number;
74
+ averageBasket: number;
67
75
  }
68
76
 
69
77
  export interface ChangeStatusInput {
@@ -82,7 +90,11 @@ export interface OrderServiceDeps {
82
90
  }
83
91
 
84
92
  export type HydratedOrder = Order & { lineItems: OrderLineItem[] };
85
- type OrderListResult = { items: HydratedOrder[]; pagination: Pagination };
93
+ type OrderListResult = {
94
+ items: HydratedOrder[];
95
+ pagination: Pagination;
96
+ totals?: CustomerOrderTotals;
97
+ };
86
98
  type BeforeCreateOrderHook = BeforeHook<CreateOrderInput>;
87
99
  type AfterCreateOrderHook = AfterHook<HydratedOrder>;
88
100
  type StatusChangeHookInput = {
@@ -324,6 +336,23 @@ export class OrderService {
324
336
  }
325
337
 
326
338
  items.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime());
339
+
340
+ // Lifetime rollup over the full (un-paginated) set, computed server-side.
341
+ // Refunds/voids are excluded from lifetimeSpend.
342
+ let totals: CustomerOrderTotals | undefined;
343
+ if (params.includeTotals) {
344
+ const excluded = new Set(["refunded", "voided"]);
345
+ const count = items.length;
346
+ const lifetimeSpend = items
347
+ .filter((order) => !excluded.has(order.status))
348
+ .reduce((sum, order) => sum + order.grandTotal, 0);
349
+ totals = {
350
+ count,
351
+ lifetimeSpend,
352
+ averageBasket: count > 0 ? Math.round(lifetimeSpend / count) : 0,
353
+ };
354
+ }
355
+
327
356
  const paged = paginate(items, params.page ?? 1, params.limit ?? 20);
328
357
  const hydratedItems = await Promise.all(
329
358
  paged.items.map((order) => this.hydrateOrder(order, ctx)),
@@ -332,9 +361,63 @@ export class OrderService {
332
361
  return Ok({
333
362
  items: hydratedItems,
334
363
  pagination: paged.pagination,
364
+ ...(totals ? { totals } : {}),
335
365
  });
336
366
  }
337
367
 
368
+ /**
369
+ * Fuzzy order lookup for receipt-less returns / support: matches across
370
+ * order number, customer email/name/phone (digits-normalized), and the
371
+ * walk-in label. Returns a compact result; <3 chars returns a hint.
372
+ */
373
+ async lookup(
374
+ q: string,
375
+ opts: { from?: Date; to?: Date },
376
+ actor?: Actor | null,
377
+ ctx?: TxContext,
378
+ ): Promise<
379
+ Result<{
380
+ items: Array<{
381
+ id: string;
382
+ orderNumber: string;
383
+ placedAt: Date;
384
+ status: string;
385
+ grandTotal: number;
386
+ customer: { id: string; name: string | null; phone: string | null } | null;
387
+ }>;
388
+ hint?: string;
389
+ }>
390
+ > {
391
+ try {
392
+ assertPermission(actor ?? null, "orders:read");
393
+ } catch (error) {
394
+ return Err(toCommerceError(error));
395
+ }
396
+
397
+ const term = (q ?? "").trim();
398
+ if (term.length < 3) {
399
+ return Ok({ items: [], hint: "Enter at least 3 characters to search." });
400
+ }
401
+
402
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
403
+ const rows = await this.repo.lookup(orgId, term, opts, ctx);
404
+ const items = rows.map((r) => ({
405
+ id: r.id,
406
+ orderNumber: r.orderNumber,
407
+ placedAt: r.placedAt,
408
+ status: r.status,
409
+ grandTotal: r.grandTotal,
410
+ customer: r.customerId
411
+ ? {
412
+ id: r.customerId,
413
+ name: `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || null,
414
+ phone: r.phone ?? null,
415
+ }
416
+ : null,
417
+ }));
418
+ return Ok({ items });
419
+ }
420
+
338
421
  async changeStatus(
339
422
  input: ChangeStatusInput,
340
423
  actor: Actor | null,
@@ -556,8 +639,8 @@ export class OrderService {
556
639
  ctx,
557
640
  );
558
641
 
559
- // Audit logging is now automatic via audit hooks (RFC-005)
560
- // registered in kernel boot — no manual audit.record() needed.
642
+ // Audit logging is automatic via the audit hooks registered at kernel
643
+ // boot — no manual audit.record() call needed here.
561
644
 
562
645
  const hydrated = await this.hydrateOrder(cas, ctx);
563
646
  const report = await runAfterHooks(
@@ -1,6 +1,5 @@
1
1
  import type { TxContext } from "../../../kernel/database/tx-context.js";
2
2
 
3
3
  export interface PaymentsRepository {
4
- // RFC-002 scaffold: module repositories become the only persistence boundary.
5
4
  ping(ctx: TxContext): Promise<void>;
6
5
  }
@@ -2,16 +2,29 @@ import { z } from "@hono/zod-openapi";
2
2
 
3
3
  // ─── Zod Body Schemas (single source of truth) ─────────────────────────────
4
4
 
5
- export const CreatePromotionBodySchema = z.object({
6
- name: z.string().openapi({ example: "Summer Sale" }),
7
- type: z.enum([
5
+ /**
6
+ * The valid promotion `type` values. Single source of truth for the REST
7
+ * body schema, the OpenAPI enum, the service-layer validation, and the
8
+ * exported {@link PromotionType} union.
9
+ */
10
+ export const promotionTypeEnum = z
11
+ .enum([
8
12
  "percentage_off_order",
9
13
  "fixed_off_order",
10
14
  "percentage_off_item",
11
15
  "fixed_off_item",
12
16
  "free_shipping",
13
17
  "buy_x_get_y",
14
- ]).openapi({ example: "percentage_off_order" }),
18
+ ])
19
+ .openapi("PromotionType", { example: "percentage_off_order" });
20
+
21
+ /** Valid promotion type discriminator. @see promotionTypeEnum */
22
+ export type PromotionType = z.infer<typeof promotionTypeEnum>;
23
+
24
+ export const CreatePromotionBodySchema = z.object({
25
+ name: z.string().openapi({ example: "Summer Sale" }),
26
+ /** @see PromotionType for valid values */
27
+ type: promotionTypeEnum,
15
28
  value: z.number().openapi({ example: 10 }),
16
29
  code: z.string().optional().openapi({ example: "SUMMER10" }),
17
30
  buyQuantity: z.number().int().optional(),
@@ -33,6 +46,10 @@ export const CreatePromotionBodySchema = z.object({
33
46
  metadata: z.record(z.string(), z.unknown()).optional(),
34
47
  }).openapi("CreatePromotionRequest");
35
48
 
49
+ // Edit any subset of the create body. Validated the same way create is.
50
+ export const UpdatePromotionBodySchema = CreatePromotionBodySchema.partial();
51
+
36
52
  // ─── Derived Input Types ────────────────────────────────────────────────────
37
53
 
38
54
  export type CreatePromotionInput = z.infer<typeof CreatePromotionBodySchema>;
55
+ export type UpdatePromotionInput = z.infer<typeof UpdatePromotionBodySchema>;
@@ -15,20 +15,17 @@ import type {
15
15
  PromotionsRepository,
16
16
  Promotion,
17
17
  PromotionUsage,
18
+ PromotionInsert,
18
19
  } from "./repository/index.js";
19
20
  import type { CatalogRepository } from "../catalog/repository/index.js";
20
21
  import type { OrdersRepository } from "../orders/repository/index.js";
21
22
  import { resolveOrgId } from "../../auth/org.js";
22
23
  import type { Actor } from "../../auth/types.js";
23
24
 
24
- // Re-export PromotionType for external use
25
- export type PromotionType =
26
- | "percentage_off_order"
27
- | "fixed_off_order"
28
- | "percentage_off_item"
29
- | "fixed_off_item"
30
- | "free_shipping"
31
- | "buy_x_get_y";
25
+ // PromotionType is derived from the zod enum (single source of truth) in
26
+ // ./schemas.js; re-export it here for backwards-compatible imports.
27
+ import { promotionTypeEnum, type PromotionType } from "./schemas.js";
28
+ export type { PromotionType };
32
29
 
33
30
  /** Filter status for listing promotions. Used by the REST API and service layer. */
34
31
  export type PromotionStatusFilter = "active" | "inactive" | "expired" | "scheduled";
@@ -88,7 +85,7 @@ export interface PromotionConditions {
88
85
  }
89
86
 
90
87
  export type { CreatePromotionInput } from "./schemas.js";
91
- import type { CreatePromotionInput } from "./schemas.js";
88
+ import type { CreatePromotionInput, UpdatePromotionInput } from "./schemas.js";
92
89
 
93
90
  export interface AppliedPromotion {
94
91
  promotionId: string;
@@ -182,15 +179,8 @@ export class PromotionService {
182
179
  );
183
180
  }
184
181
 
185
- // Validate promotion type
186
- const validTypes: PromotionType[] = [
187
- "percentage_off_order",
188
- "fixed_off_order",
189
- "percentage_off_item",
190
- "fixed_off_item",
191
- "free_shipping",
192
- "buy_x_get_y",
193
- ];
182
+ // Validate promotion type against the single-source enum
183
+ const validTypes = promotionTypeEnum.options;
194
184
  if (!validTypes.includes(input.type)) {
195
185
  return Err(
196
186
  new CommerceValidationError(
@@ -269,6 +259,88 @@ export class PromotionService {
269
259
  return Ok(updated);
270
260
  }
271
261
 
262
+ /**
263
+ * Edit any subset of a promotion's fields. Validated the same way create is
264
+ * (value bounds, type enum, code uniqueness). Only provided keys are written.
265
+ */
266
+ async update(
267
+ orgId: string,
268
+ id: string,
269
+ input: UpdatePromotionInput,
270
+ actor?: Actor | null,
271
+ ctx?: TxContext,
272
+ ): Promise<Result<Promotion>> {
273
+ const existing = await this.repo.findById(orgId, id, ctx);
274
+ if (!existing) {
275
+ return Err(new CommerceNotFoundError("Promotion not found."));
276
+ }
277
+
278
+ if (input.value !== undefined && input.value < 0) {
279
+ return Err(new CommerceValidationError("Promotion value cannot be negative."));
280
+ }
281
+
282
+ if (input.type !== undefined) {
283
+ const validTypes = promotionTypeEnum.options;
284
+ if (!validTypes.includes(input.type)) {
285
+ return Err(
286
+ new CommerceValidationError(
287
+ `Invalid promotion type "${input.type}". Must be one of: ${validTypes.join(", ")}`,
288
+ ),
289
+ );
290
+ }
291
+ }
292
+
293
+ let normalizedCode: string | null | undefined;
294
+ if (input.code !== undefined) {
295
+ normalizedCode = input.code ? input.code.trim().toUpperCase() : null;
296
+ if (normalizedCode) {
297
+ const other = await this.repo.findByCode(orgId, normalizedCode, ctx);
298
+ if (other && other.id !== id) {
299
+ return Err(
300
+ new CommerceValidationError(`Promotion code ${normalizedCode} already exists.`),
301
+ );
302
+ }
303
+ }
304
+ }
305
+
306
+ const patch: Partial<Omit<PromotionInsert, "id">> = {};
307
+ if (input.name !== undefined) patch.name = input.name;
308
+ if (input.type !== undefined) patch.type = input.type;
309
+ if (input.value !== undefined) patch.value = roundMoney(input.value);
310
+ if (input.isAutomatic !== undefined) patch.isAutomatic = input.isAutomatic;
311
+ if (input.isActive !== undefined) patch.isActive = input.isActive;
312
+ if (input.priority !== undefined) patch.priority = input.priority;
313
+ if (input.conditions !== undefined) patch.conditions = input.conditions as Record<string, unknown>;
314
+ if (input.metadata !== undefined) patch.metadata = input.metadata;
315
+ if (normalizedCode !== undefined) patch.code = normalizedCode;
316
+ if (input.buyQuantity !== undefined) patch.buyQuantity = input.buyQuantity;
317
+ if (input.getQuantity !== undefined) patch.getQuantity = input.getQuantity;
318
+ if (input.usageLimitTotal !== undefined) patch.usageLimitTotal = input.usageLimitTotal;
319
+ if (input.usageLimitPerCustomer !== undefined) {
320
+ patch.usageLimitPerCustomer = input.usageLimitPerCustomer;
321
+ }
322
+ if (input.validFrom !== undefined) patch.validFrom = input.validFrom;
323
+ if (input.validUntil !== undefined) patch.validUntil = input.validUntil;
324
+
325
+ const updated = await this.repo.update(id, patch, ctx);
326
+ if (!updated) {
327
+ return Err(new CommerceNotFoundError("Promotion not found."));
328
+ }
329
+
330
+ const afterHooks = this.deps.hooks.resolve(
331
+ "promotions.afterUpdate",
332
+ ) as AfterHook<Promotion>[];
333
+ const hctx = hookContext(
334
+ actor ?? ctx?.actor ?? null,
335
+ this.deps.services,
336
+ this.deps.database,
337
+ ctx?.tx ?? null,
338
+ );
339
+ await runAfterHooks(afterHooks, existing, updated, "update", hctx);
340
+
341
+ return Ok(updated);
342
+ }
343
+
272
344
  async list(
273
345
  filter?: { status?: PromotionStatusFilter },
274
346
  actor?: Actor | null,
@@ -1,6 +1,5 @@
1
1
  import type { TxContext } from "../../../kernel/database/tx-context.js";
2
2
 
3
3
  export interface SearchRepository {
4
- // RFC-002 scaffold: module repositories become the only persistence boundary.
5
4
  ping(ctx: TxContext): Promise<void>;
6
5
  }
@@ -1,6 +1,5 @@
1
1
  import type { TxContext } from "../../../kernel/database/tx-context.js";
2
2
 
3
3
  export interface ShippingRepository {
4
- // RFC-002 scaffold: module repositories become the only persistence boundary.
5
4
  ping(ctx: TxContext): Promise<void>;
6
5
  }
@@ -1,6 +1,5 @@
1
1
  import type { TxContext } from "../../../kernel/database/tx-context.js";
2
2
 
3
3
  export interface TaxRepository {
4
- // RFC-002 scaffold: module repositories become the only persistence boundary.
5
4
  ping(ctx: TxContext): Promise<void>;
6
5
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client-IP resolution for rate limiting.
3
+ *
4
+ * The Node default reads `c.req.raw.socket.remoteAddress` — which is always
5
+ * undefined on edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge),
6
+ * collapsing every client onto a single rate-limit key (a trivial DoS). Edge
7
+ * deployments inject `runtime.getClientIp` to read the platform header instead
8
+ * (CF: `cf-connecting-ip`, Vercel Edge: `x-real-ip`, Fly: `fly-client-ip`).
9
+ */
10
+
11
+ export interface ClientIpContext {
12
+ req: { raw: unknown; header(name: string): string | undefined };
13
+ }
14
+
15
+ export type ClientIpResolver = (c: ClientIpContext) => string;
16
+
17
+ export interface RuntimeConfig {
18
+ /** Resolve the client IP from the request (used as the rate-limit key). */
19
+ getClientIp?: ClientIpResolver;
20
+ /** Direct-connection IP of the trusted reverse proxy (Node default only). */
21
+ trustedProxyIp?: string;
22
+ }
23
+
24
+ /**
25
+ * Build the client-IP resolver. Prefers an injected `runtime.getClientIp`;
26
+ * otherwise falls back to the Node behavior (socket address, trusting
27
+ * X-Forwarded-For only from `trustedProxyIp` / `TRUSTED_PROXY_IP`).
28
+ */
29
+ export function createClientIpResolver(config: {
30
+ runtime?: RuntimeConfig;
31
+ }): ClientIpResolver {
32
+ if (config.runtime?.getClientIp) {
33
+ return config.runtime.getClientIp;
34
+ }
35
+
36
+ const trustedProxyIp =
37
+ config.runtime?.trustedProxyIp ??
38
+ (typeof process !== "undefined" ? process.env?.TRUSTED_PROXY_IP : undefined);
39
+
40
+ return (c: ClientIpContext): string => {
41
+ const raw = c.req.raw as { socket?: { remoteAddress?: string } } | undefined;
42
+ const remoteAddress = raw?.socket?.remoteAddress;
43
+ if (trustedProxyIp && remoteAddress === trustedProxyIp) {
44
+ const xff = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
45
+ if (xff) return xff;
46
+ }
47
+ return remoteAddress ?? "unknown";
48
+ };
49
+ }