@porulle/core 0.7.0 → 0.8.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 (289) hide show
  1. package/dist/auth/setup.d.ts.map +1 -1
  2. package/dist/auth/setup.js +1 -0
  3. package/dist/config/defaults.d.ts.map +1 -1
  4. package/dist/config/defaults.js +5 -0
  5. package/dist/config/types.d.ts +17 -1
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/generated/plugin-manifest.d.ts +3 -0
  8. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.js +1 -0
  10. package/dist/hooks/checkout.d.ts.map +1 -1
  11. package/dist/hooks/checkout.js +16 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/interfaces/rest/index.d.ts.map +1 -1
  16. package/dist/interfaces/rest/index.js +12 -0
  17. package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
  18. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
  19. package/dist/interfaces/rest/routes/admin/staff.js +172 -0
  20. package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
  21. package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
  22. package/dist/interfaces/rest/routes/analytics.js +24 -0
  23. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  24. package/dist/interfaces/rest/routes/carts.js +39 -2
  25. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  26. package/dist/interfaces/rest/routes/catalog.js +17 -1
  27. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/checkout.js +17 -0
  29. package/dist/interfaces/rest/routes/documents.d.ts +10 -0
  30. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
  31. package/dist/interfaces/rest/routes/documents.js +40 -0
  32. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  33. package/dist/interfaces/rest/routes/orders.js +98 -1
  34. package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
  35. package/dist/interfaces/rest/routes/pricing.js +36 -1
  36. package/dist/interfaces/rest/routes/settings.d.ts +5 -0
  37. package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
  38. package/dist/interfaces/rest/routes/settings.js +50 -0
  39. package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
  40. package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
  41. package/dist/interfaces/rest/routes/shipping.js +68 -0
  42. package/dist/interfaces/rest/routes/tax.d.ts +5 -0
  43. package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
  44. package/dist/interfaces/rest/routes/tax.js +68 -0
  45. package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
  46. package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
  47. package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
  48. package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
  49. package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
  50. package/dist/interfaces/rest/schemas/analytics.js +33 -0
  51. package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
  52. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  53. package/dist/interfaces/rest/schemas/carts.js +38 -0
  54. package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
  55. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  56. package/dist/interfaces/rest/schemas/catalog.js +48 -0
  57. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  58. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  59. package/dist/interfaces/rest/schemas/checkout.js +4 -0
  60. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  61. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  62. package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
  63. package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
  64. package/dist/interfaces/rest/schemas/documents.js +65 -0
  65. package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
  66. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  67. package/dist/interfaces/rest/schemas/orders.js +233 -0
  68. package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
  69. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  70. package/dist/interfaces/rest/schemas/pricing.js +70 -0
  71. package/dist/interfaces/rest/schemas/responses.d.ts +156 -0
  72. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  73. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  74. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  75. package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
  76. package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
  77. package/dist/interfaces/rest/schemas/settings.js +79 -0
  78. package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
  79. package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
  80. package/dist/interfaces/rest/schemas/shipping.js +146 -0
  81. package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
  82. package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
  83. package/dist/interfaces/rest/schemas/tax.js +132 -0
  84. package/dist/kernel/database/schema.d.ts +4 -0
  85. package/dist/kernel/database/schema.d.ts.map +1 -1
  86. package/dist/kernel/database/schema.js +8 -0
  87. package/dist/kernel/hooks/types.d.ts +1 -1
  88. package/dist/kernel/hooks/types.d.ts.map +1 -1
  89. package/dist/kernel/plugin/manifest.d.ts +23 -0
  90. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  91. package/dist/kernel/plugin/manifest.js +17 -2
  92. package/dist/modules/analytics/module.d.ts.map +1 -1
  93. package/dist/modules/analytics/module.js +6 -0
  94. package/dist/modules/analytics/reports.d.ts +58 -0
  95. package/dist/modules/analytics/reports.d.ts.map +1 -0
  96. package/dist/modules/analytics/reports.js +249 -0
  97. package/dist/modules/analytics/service.d.ts +10 -0
  98. package/dist/modules/analytics/service.d.ts.map +1 -1
  99. package/dist/modules/analytics/service.js +14 -0
  100. package/dist/modules/cart/repository/index.d.ts +7 -0
  101. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  102. package/dist/modules/cart/repository/index.js +25 -1
  103. package/dist/modules/cart/schema.d.ts +17 -0
  104. package/dist/modules/cart/schema.d.ts.map +1 -1
  105. package/dist/modules/cart/schema.js +2 -0
  106. package/dist/modules/cart/schemas.d.ts +1 -0
  107. package/dist/modules/cart/schemas.d.ts.map +1 -1
  108. package/dist/modules/cart/schemas.js +1 -0
  109. package/dist/modules/cart/service.d.ts +32 -0
  110. package/dist/modules/cart/service.d.ts.map +1 -1
  111. package/dist/modules/cart/service.js +58 -0
  112. package/dist/modules/catalog/entity-service.d.ts +21 -0
  113. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  114. package/dist/modules/catalog/entity-service.js +132 -3
  115. package/dist/modules/catalog/schema.d.ts +34 -0
  116. package/dist/modules/catalog/schema.d.ts.map +1 -1
  117. package/dist/modules/catalog/schema.js +5 -0
  118. package/dist/modules/catalog/schemas.d.ts +3 -0
  119. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  120. package/dist/modules/catalog/schemas.js +3 -0
  121. package/dist/modules/catalog/service.d.ts +36 -0
  122. package/dist/modules/catalog/service.d.ts.map +1 -1
  123. package/dist/modules/catalog/service.js +6 -0
  124. package/dist/modules/documents/module.d.ts +7 -0
  125. package/dist/modules/documents/module.d.ts.map +1 -0
  126. package/dist/modules/documents/module.js +12 -0
  127. package/dist/modules/documents/pdf.d.ts +25 -0
  128. package/dist/modules/documents/pdf.d.ts.map +1 -0
  129. package/dist/modules/documents/pdf.js +80 -0
  130. package/dist/modules/documents/render.d.ts +32 -0
  131. package/dist/modules/documents/render.d.ts.map +1 -0
  132. package/dist/modules/documents/render.js +190 -0
  133. package/dist/modules/documents/repository/index.d.ts +25 -0
  134. package/dist/modules/documents/repository/index.d.ts.map +1 -0
  135. package/dist/modules/documents/repository/index.js +47 -0
  136. package/dist/modules/documents/schema.d.ts +214 -0
  137. package/dist/modules/documents/schema.d.ts.map +1 -0
  138. package/dist/modules/documents/schema.js +42 -0
  139. package/dist/modules/documents/service.d.ts +51 -0
  140. package/dist/modules/documents/service.d.ts.map +1 -0
  141. package/dist/modules/documents/service.js +119 -0
  142. package/dist/modules/fulfillment/service.d.ts +18 -0
  143. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  144. package/dist/modules/fulfillment/service.js +85 -1
  145. package/dist/modules/orders/repository/index.d.ts +18 -1
  146. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  147. package/dist/modules/orders/repository/index.js +86 -1
  148. package/dist/modules/orders/schema.d.ts +374 -0
  149. package/dist/modules/orders/schema.d.ts.map +1 -1
  150. package/dist/modules/orders/schema.js +52 -1
  151. package/dist/modules/orders/service.d.ts +86 -1
  152. package/dist/modules/orders/service.d.ts.map +1 -1
  153. package/dist/modules/orders/service.js +447 -20
  154. package/dist/modules/pricing/repository/index.d.ts +5 -0
  155. package/dist/modules/pricing/repository/index.d.ts.map +1 -1
  156. package/dist/modules/pricing/repository/index.js +19 -0
  157. package/dist/modules/pricing/service.d.ts +18 -0
  158. package/dist/modules/pricing/service.d.ts.map +1 -1
  159. package/dist/modules/pricing/service.js +38 -0
  160. package/dist/modules/settings/module.d.ts +6 -0
  161. package/dist/modules/settings/module.d.ts.map +1 -0
  162. package/dist/modules/settings/module.js +11 -0
  163. package/dist/modules/settings/repository/index.d.ts +16 -0
  164. package/dist/modules/settings/repository/index.d.ts.map +1 -0
  165. package/dist/modules/settings/repository/index.js +41 -0
  166. package/dist/modules/settings/schema.d.ts +121 -0
  167. package/dist/modules/settings/schema.d.ts.map +1 -0
  168. package/dist/modules/settings/schema.js +24 -0
  169. package/dist/modules/settings/service.d.ts +26 -0
  170. package/dist/modules/settings/service.d.ts.map +1 -0
  171. package/dist/modules/settings/service.js +51 -0
  172. package/dist/modules/shipping/calculator.d.ts +5 -0
  173. package/dist/modules/shipping/calculator.d.ts.map +1 -1
  174. package/dist/modules/shipping/calculator.js +10 -0
  175. package/dist/modules/shipping/module.d.ts +5 -1
  176. package/dist/modules/shipping/module.d.ts.map +1 -1
  177. package/dist/modules/shipping/module.js +4 -1
  178. package/dist/modules/shipping/repository/index.d.ts +27 -2
  179. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  180. package/dist/modules/shipping/repository/index.js +105 -1
  181. package/dist/modules/shipping/schema.d.ts +419 -0
  182. package/dist/modules/shipping/schema.d.ts.map +1 -0
  183. package/dist/modules/shipping/schema.js +56 -0
  184. package/dist/modules/shipping/service.d.ts +54 -0
  185. package/dist/modules/shipping/service.d.ts.map +1 -1
  186. package/dist/modules/shipping/service.js +161 -2
  187. package/dist/modules/tax/adapter.d.ts +13 -0
  188. package/dist/modules/tax/adapter.d.ts.map +1 -1
  189. package/dist/modules/tax/module.d.ts +5 -1
  190. package/dist/modules/tax/module.d.ts.map +1 -1
  191. package/dist/modules/tax/module.js +4 -1
  192. package/dist/modules/tax/repository/index.d.ts +38 -2
  193. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  194. package/dist/modules/tax/repository/index.js +138 -1
  195. package/dist/modules/tax/schema.d.ts +355 -0
  196. package/dist/modules/tax/schema.d.ts.map +1 -0
  197. package/dist/modules/tax/schema.js +55 -0
  198. package/dist/modules/tax/service.d.ts +60 -1
  199. package/dist/modules/tax/service.d.ts.map +1 -1
  200. package/dist/modules/tax/service.js +227 -2
  201. package/dist/runtime/kernel-modules.d.ts +15 -2
  202. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  203. package/dist/runtime/kernel-modules.js +4 -0
  204. package/dist/runtime/kernel-types.d.ts +5 -1
  205. package/dist/runtime/kernel-types.d.ts.map +1 -1
  206. package/dist/runtime/kernel-types.js +2 -0
  207. package/dist/runtime/server.js +1 -1
  208. package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
  209. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  210. package/dist/test-utils/create-plugin-test-app.js +15 -3
  211. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  212. package/dist/test-utils/rest-api-test-utils.js +6 -0
  213. package/package.json +3 -3
  214. package/src/auth/setup.ts +1 -0
  215. package/src/config/defaults.ts +5 -0
  216. package/src/config/types.ts +17 -1
  217. package/src/generated/plugin-capabilities.d.ts +1 -0
  218. package/src/generated/plugin-manifest.ts +1 -0
  219. package/src/generated/plugin-repositories.d.ts +1 -0
  220. package/src/hooks/checkout.ts +25 -3
  221. package/src/index.ts +1 -0
  222. package/src/interfaces/rest/index.ts +12 -0
  223. package/src/interfaces/rest/routes/admin/staff.ts +218 -0
  224. package/src/interfaces/rest/routes/analytics.ts +33 -0
  225. package/src/interfaces/rest/routes/carts.ts +52 -2
  226. package/src/interfaces/rest/routes/catalog.ts +34 -0
  227. package/src/interfaces/rest/routes/checkout.ts +22 -0
  228. package/src/interfaces/rest/routes/documents.ts +61 -0
  229. package/src/interfaces/rest/routes/orders.ts +137 -1
  230. package/src/interfaces/rest/routes/pricing.ts +44 -1
  231. package/src/interfaces/rest/routes/settings.ts +61 -0
  232. package/src/interfaces/rest/routes/shipping.ts +92 -0
  233. package/src/interfaces/rest/routes/tax.ts +90 -0
  234. package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
  235. package/src/interfaces/rest/schemas/analytics.ts +39 -0
  236. package/src/interfaces/rest/schemas/carts.ts +40 -0
  237. package/src/interfaces/rest/schemas/catalog.ts +53 -0
  238. package/src/interfaces/rest/schemas/checkout.ts +4 -0
  239. package/src/interfaces/rest/schemas/documents.ts +72 -0
  240. package/src/interfaces/rest/schemas/orders.ts +257 -0
  241. package/src/interfaces/rest/schemas/pricing.ts +75 -0
  242. package/src/interfaces/rest/schemas/settings.ts +93 -0
  243. package/src/interfaces/rest/schemas/shipping.ts +164 -0
  244. package/src/interfaces/rest/schemas/tax.ts +148 -0
  245. package/src/kernel/database/schema.ts +12 -0
  246. package/src/kernel/hooks/types.ts +1 -0
  247. package/src/kernel/plugin/manifest.ts +38 -2
  248. package/src/modules/analytics/module.ts +10 -0
  249. package/src/modules/analytics/reports.ts +311 -0
  250. package/src/modules/analytics/service.ts +23 -0
  251. package/src/modules/cart/repository/index.ts +30 -1
  252. package/src/modules/cart/schema.ts +2 -0
  253. package/src/modules/cart/schemas.ts +1 -0
  254. package/src/modules/cart/service.ts +101 -0
  255. package/src/modules/catalog/entity-service.ts +168 -3
  256. package/src/modules/catalog/schema.ts +5 -0
  257. package/src/modules/catalog/schemas.ts +3 -0
  258. package/src/modules/catalog/service.ts +30 -0
  259. package/src/modules/documents/module.ts +19 -0
  260. package/src/modules/documents/pdf.ts +101 -0
  261. package/src/modules/documents/render.ts +231 -0
  262. package/src/modules/documents/repository/index.ts +68 -0
  263. package/src/modules/documents/schema.ts +44 -0
  264. package/src/modules/documents/service.ts +176 -0
  265. package/src/modules/fulfillment/service.ts +139 -1
  266. package/src/modules/orders/repository/index.ts +127 -1
  267. package/src/modules/orders/schema.ts +54 -1
  268. package/src/modules/orders/service.ts +654 -22
  269. package/src/modules/pricing/repository/index.ts +34 -0
  270. package/src/modules/pricing/service.ts +71 -0
  271. package/src/modules/settings/module.ts +18 -0
  272. package/src/modules/settings/repository/index.ts +59 -0
  273. package/src/modules/settings/schema.ts +25 -0
  274. package/src/modules/settings/service.ts +82 -0
  275. package/src/modules/shipping/calculator.ts +27 -0
  276. package/src/modules/shipping/module.ts +6 -2
  277. package/src/modules/shipping/repository/index.ts +136 -2
  278. package/src/modules/shipping/schema.ts +58 -0
  279. package/src/modules/shipping/service.ts +260 -1
  280. package/src/modules/tax/adapter.ts +9 -0
  281. package/src/modules/tax/module.ts +6 -2
  282. package/src/modules/tax/repository/index.ts +169 -2
  283. package/src/modules/tax/schema.ts +57 -0
  284. package/src/modules/tax/service.ts +313 -2
  285. package/src/runtime/kernel-modules.ts +4 -0
  286. package/src/runtime/kernel-types.ts +6 -0
  287. package/src/runtime/server.ts +1 -1
  288. package/src/test-utils/create-plugin-test-app.ts +17 -4
  289. package/src/test-utils/rest-api-test-utils.ts +6 -0
@@ -0,0 +1,311 @@
1
+ import { sql, type SQL } from "drizzle-orm";
2
+ import type { DrizzleDatabase } from "../../kernel/database/drizzle-db.js";
3
+ import { CommerceValidationError } from "../../kernel/errors.js";
4
+ import { Err, Ok, type Result } from "../../kernel/result.js";
5
+
6
+ /**
7
+ * Canned retail reports (issue #48).
8
+ *
9
+ * Parameterized SQL over core tables, org-scoped, with all calendar math done
10
+ * by Postgres in the store's timezone (settings.general.timezone, default
11
+ * UTC). Range reports include a prior period of equal length with deltas.
12
+ */
13
+
14
+ export interface ReportParams {
15
+ /** Local calendar date (YYYY-MM-DD) for single-day reports. Default: today in store tz. */
16
+ date?: string;
17
+ /** Local range start (YYYY-MM-DD, inclusive). Default: first of current month in store tz. */
18
+ from?: string;
19
+ /** Local range end (YYYY-MM-DD, inclusive). Default: today in store tz. */
20
+ to?: string;
21
+ }
22
+
23
+ export const RETAIL_REPORTS = [
24
+ { name: "daily-journal", description: "Per-day sales journal: order rows + summary with prior-day deltas." },
25
+ { name: "tax-summary", description: "Tax collected over a range, bucketed per local day, with prior-period deltas." },
26
+ { name: "inventory-aging", description: "On-hand stock bucketed by days since last restock (0-30/31-60/61-90/90+)." },
27
+ { name: "sell-through", description: "Units sold in the range vs current on-hand, per entity." },
28
+ { name: "reorder-needed", description: "Inventory rows at or below their reorder threshold." },
29
+ { name: "staff-sales", description: "Orders and revenue grouped by orders.metadata.staffId." },
30
+ ] as const;
31
+
32
+ export type RetailReportName = (typeof RETAIL_REPORTS)[number]["name"];
33
+
34
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
+ // Orders excluded from financial reports.
36
+ const EXCLUDED_STATUSES = ["cancelled", "voided"];
37
+
38
+ type SettingsReader = (orgId: string, group: string) => Promise<Record<string, unknown>>;
39
+
40
+ interface JournalSummary {
41
+ orderCount: number;
42
+ grossSales: number;
43
+ subtotal: number;
44
+ discounts: number;
45
+ tax: number;
46
+ shipping: number;
47
+ }
48
+
49
+ export class RetailReportsEngine {
50
+ constructor(
51
+ private readonly db: DrizzleDatabase,
52
+ private readonly readSettings: SettingsReader,
53
+ ) {}
54
+
55
+ async run(
56
+ name: string,
57
+ params: ReportParams,
58
+ orgId: string,
59
+ ): Promise<Result<Record<string, unknown>>> {
60
+ for (const value of [params.date, params.from, params.to]) {
61
+ if (value !== undefined && !DATE_RE.test(value)) {
62
+ return Err(new CommerceValidationError("Dates must be YYYY-MM-DD."));
63
+ }
64
+ }
65
+ const tz = await this.timezoneFor(orgId);
66
+
67
+ switch (name) {
68
+ case "daily-journal":
69
+ return Ok(await this.dailyJournal(orgId, tz, params.date ?? localToday(tz)));
70
+ case "tax-summary": {
71
+ const [from, to] = this.rangeOrDefault(params, tz);
72
+ return Ok(await this.taxSummary(orgId, tz, from, to));
73
+ }
74
+ case "inventory-aging":
75
+ return Ok(await this.inventoryAging(orgId));
76
+ case "sell-through": {
77
+ const [from, to] = this.rangeOrDefault(params, tz);
78
+ return Ok(await this.sellThrough(orgId, tz, from, to));
79
+ }
80
+ case "reorder-needed":
81
+ return Ok(await this.reorderNeeded(orgId));
82
+ case "staff-sales": {
83
+ const [from, to] = this.rangeOrDefault(params, tz);
84
+ return Ok(await this.staffSales(orgId, tz, from, to));
85
+ }
86
+ default:
87
+ return Err(
88
+ new CommerceValidationError(
89
+ `Unknown report "${name}". Available: ${RETAIL_REPORTS.map((r) => r.name).join(", ")}.`,
90
+ ),
91
+ );
92
+ }
93
+ }
94
+
95
+ private async timezoneFor(orgId: string): Promise<string> {
96
+ const general = await this.readSettings(orgId, "general");
97
+ return typeof general.timezone === "string" ? general.timezone : "UTC";
98
+ }
99
+
100
+ private rangeOrDefault(params: ReportParams, tz: string): [string, string] {
101
+ const today = localToday(tz);
102
+ return [params.from ?? `${today.slice(0, 8)}01`, params.to ?? today];
103
+ }
104
+
105
+ private async rows(query: SQL): Promise<Record<string, unknown>[]> {
106
+ const result = await this.db.execute(query);
107
+ const raw: Record<string, unknown>[] = Array.isArray(result)
108
+ ? result
109
+ : ((result as { rows?: Record<string, unknown>[] }).rows ?? []);
110
+ return raw.map((row) => {
111
+ const mapped: Record<string, unknown> = {};
112
+ for (const [key, value] of Object.entries(row)) {
113
+ mapped[key] = typeof value === "bigint" ? Number(value) : value;
114
+ }
115
+ return mapped;
116
+ });
117
+ }
118
+
119
+ private async journalSummary(
120
+ orgId: string,
121
+ tz: string,
122
+ from: string,
123
+ to: string,
124
+ ): Promise<JournalSummary> {
125
+ const rows = await this.rows(sql`
126
+ SELECT count(*)::int AS "orderCount",
127
+ COALESCE(sum(grand_total), 0)::int AS "grossSales",
128
+ COALESCE(sum(subtotal), 0)::int AS "subtotal",
129
+ COALESCE(sum(discount_total), 0)::int AS "discounts",
130
+ COALESCE(sum(tax_total), 0)::int AS "tax",
131
+ COALESCE(sum(shipping_total), 0)::int AS "shipping"
132
+ FROM orders
133
+ WHERE organization_id = ${orgId}
134
+ AND status NOT IN (${sql.join(EXCLUDED_STATUSES.map((s) => sql`${s}`), sql`, `)})
135
+ AND (placed_at AT TIME ZONE ${tz})::date BETWEEN ${from}::date AND ${to}::date
136
+ `);
137
+ return rows[0] as unknown as JournalSummary;
138
+ }
139
+
140
+ private async dailyJournal(orgId: string, tz: string, date: string) {
141
+ const summary = await this.journalSummary(orgId, tz, date, date);
142
+ const previousDate = shiftDate(date, -1);
143
+ const previous = await this.journalSummary(orgId, tz, previousDate, previousDate);
144
+ const orders = await this.rows(sql`
145
+ SELECT order_number AS "orderNumber", status, currency,
146
+ grand_total AS "grandTotal", tax_total AS "tax",
147
+ discount_total AS "discounts", placed_at AS "placedAt"
148
+ FROM orders
149
+ WHERE organization_id = ${orgId}
150
+ AND status NOT IN (${sql.join(EXCLUDED_STATUSES.map((s) => sql`${s}`), sql`, `)})
151
+ AND (placed_at AT TIME ZONE ${tz})::date = ${date}::date
152
+ ORDER BY placed_at ASC
153
+ `);
154
+ return {
155
+ report: "daily-journal",
156
+ timezone: tz,
157
+ date,
158
+ summary,
159
+ previous,
160
+ delta: deltas(summary, previous),
161
+ orders,
162
+ };
163
+ }
164
+
165
+ private async taxSummary(orgId: string, tz: string, from: string, to: string) {
166
+ const days = await this.rows(sql`
167
+ SELECT to_char((placed_at AT TIME ZONE ${tz})::date, 'YYYY-MM-DD') AS "day",
168
+ count(*)::int AS "orderCount",
169
+ COALESCE(sum(tax_total), 0)::int AS "tax",
170
+ COALESCE(sum(grand_total), 0)::int AS "gross"
171
+ FROM orders
172
+ WHERE organization_id = ${orgId}
173
+ AND status NOT IN (${sql.join(EXCLUDED_STATUSES.map((s) => sql`${s}`), sql`, `)})
174
+ AND (placed_at AT TIME ZONE ${tz})::date BETWEEN ${from}::date AND ${to}::date
175
+ GROUP BY 1
176
+ ORDER BY 1
177
+ `);
178
+ const totals = await this.journalSummary(orgId, tz, from, to);
179
+ const lengthDays = daysBetween(from, to) + 1;
180
+ const prevFrom = shiftDate(from, -lengthDays);
181
+ const prevTo = shiftDate(to, -lengthDays);
182
+ const previous = await this.journalSummary(orgId, tz, prevFrom, prevTo);
183
+ return {
184
+ report: "tax-summary",
185
+ timezone: tz,
186
+ from,
187
+ to,
188
+ totals,
189
+ previous: { from: prevFrom, to: prevTo, ...previous },
190
+ delta: deltas(totals, previous),
191
+ days,
192
+ };
193
+ }
194
+
195
+ private async inventoryAging(orgId: string) {
196
+ const rows = await this.rows(sql`
197
+ SELECT il.entity_id AS "entityId", il.variant_id AS "variantId",
198
+ e.slug AS "slug",
199
+ il.quantity_on_hand AS "quantityOnHand",
200
+ il.quantity_reserved AS "quantityReserved",
201
+ GREATEST(0, EXTRACT(day FROM now() - COALESCE(il.last_restocked_at, il.updated_at)))::int AS "ageDays"
202
+ FROM inventory_levels il
203
+ LEFT JOIN sellable_entities e ON e.id = il.entity_id
204
+ WHERE il.organization_id = ${orgId} AND il.quantity_on_hand > 0
205
+ ORDER BY "ageDays" DESC
206
+ `);
207
+ const buckets = [
208
+ { bucket: "0-30", min: 0, max: 30 },
209
+ { bucket: "31-60", min: 31, max: 60 },
210
+ { bucket: "61-90", min: 61, max: 90 },
211
+ { bucket: "90+", min: 91, max: Infinity },
212
+ ].map(({ bucket, min, max }) => {
213
+ const inBucket = rows.filter((r) => (r.ageDays as number) >= min && (r.ageDays as number) <= max);
214
+ return {
215
+ bucket,
216
+ skuCount: inBucket.length,
217
+ quantityOnHand: inBucket.reduce((sum, r) => sum + (r.quantityOnHand as number), 0),
218
+ };
219
+ });
220
+ return { report: "inventory-aging", buckets, rows };
221
+ }
222
+
223
+ private async sellThrough(orgId: string, tz: string, from: string, to: string) {
224
+ const rows = await this.rows(sql`
225
+ SELECT li.entity_id AS "entityId",
226
+ min(li.title) AS "title",
227
+ sum(li.quantity)::int AS "unitsSold",
228
+ COALESCE((SELECT sum(quantity_on_hand)::int FROM inventory_levels il
229
+ WHERE il.entity_id = li.entity_id AND il.organization_id = ${orgId}), 0) AS "onHand"
230
+ FROM order_line_items li
231
+ JOIN orders o ON o.id = li.order_id
232
+ WHERE o.organization_id = ${orgId}
233
+ AND o.status NOT IN (${sql.join(EXCLUDED_STATUSES.map((s) => sql`${s}`), sql`, `)})
234
+ AND (o.placed_at AT TIME ZONE ${tz})::date BETWEEN ${from}::date AND ${to}::date
235
+ GROUP BY li.entity_id
236
+ ORDER BY "unitsSold" DESC
237
+ `);
238
+ return {
239
+ report: "sell-through",
240
+ timezone: tz,
241
+ from,
242
+ to,
243
+ rows: rows.map((r) => {
244
+ const sold = r.unitsSold as number;
245
+ const onHand = r.onHand as number;
246
+ return { ...r, sellThroughRate: sold + onHand > 0 ? sold / (sold + onHand) : 0 };
247
+ }),
248
+ };
249
+ }
250
+
251
+ private async reorderNeeded(orgId: string) {
252
+ const rows = await this.rows(sql`
253
+ SELECT il.entity_id AS "entityId", il.variant_id AS "variantId",
254
+ il.warehouse_id AS "warehouseId", e.slug AS "slug",
255
+ il.quantity_on_hand AS "quantityOnHand",
256
+ il.quantity_reserved AS "quantityReserved",
257
+ (il.quantity_on_hand - il.quantity_reserved) AS "available",
258
+ il.reorder_threshold AS "reorderThreshold",
259
+ il.reorder_quantity AS "reorderQuantity"
260
+ FROM inventory_levels il
261
+ LEFT JOIN sellable_entities e ON e.id = il.entity_id
262
+ WHERE il.organization_id = ${orgId}
263
+ AND il.reorder_threshold IS NOT NULL
264
+ AND (il.quantity_on_hand - il.quantity_reserved) <= il.reorder_threshold
265
+ ORDER BY "available" ASC
266
+ `);
267
+ return { report: "reorder-needed", rows };
268
+ }
269
+
270
+ private async staffSales(orgId: string, tz: string, from: string, to: string) {
271
+ const rows = await this.rows(sql`
272
+ SELECT COALESCE(metadata->>'staffId', 'unattributed') AS "staffId",
273
+ count(*)::int AS "orderCount",
274
+ COALESCE(sum(grand_total), 0)::int AS "revenue",
275
+ COALESCE(sum(discount_total), 0)::int AS "discounts"
276
+ FROM orders
277
+ WHERE organization_id = ${orgId}
278
+ AND status NOT IN (${sql.join(EXCLUDED_STATUSES.map((s) => sql`${s}`), sql`, `)})
279
+ AND (placed_at AT TIME ZONE ${tz})::date BETWEEN ${from}::date AND ${to}::date
280
+ GROUP BY 1
281
+ ORDER BY "revenue" DESC
282
+ `);
283
+ return { report: "staff-sales", timezone: tz, from, to, rows };
284
+ }
285
+ }
286
+
287
+ // ── Pure date helpers (calendar dates, no time component) ──────────────────
288
+
289
+ /** Today's local calendar date (YYYY-MM-DD) in an IANA timezone. */
290
+ export function localToday(tz: string): string {
291
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(new Date());
292
+ }
293
+
294
+ function shiftDate(date: string, days: number): string {
295
+ const d = new Date(`${date}T00:00:00Z`);
296
+ d.setUTCDate(d.getUTCDate() + days);
297
+ return d.toISOString().slice(0, 10);
298
+ }
299
+
300
+ function daysBetween(from: string, to: string): number {
301
+ const ms = new Date(`${to}T00:00:00Z`).getTime() - new Date(`${from}T00:00:00Z`).getTime();
302
+ return Math.round(ms / 86_400_000);
303
+ }
304
+
305
+ function deltas(current: JournalSummary, previous: JournalSummary): Record<string, number> {
306
+ const out: Record<string, number> = {};
307
+ for (const key of Object.keys(current) as Array<keyof JournalSummary>) {
308
+ out[key] = (current[key] ?? 0) - (previous[key] ?? 0);
309
+ }
310
+ return out;
311
+ }
@@ -9,6 +9,10 @@
9
9
  import type { CommerceConfig } from "../../config/types.js";
10
10
  import { CommerceValidationError } from "../../kernel/errors.js";
11
11
  import { Err, Ok, type Result } from "../../kernel/result.js";
12
+ import { resolveOrgId } from "../../auth/org.js";
13
+ import type { Actor } from "../../auth/types.js";
14
+ import type { ReportParams, RetailReportsEngine } from "./reports.js";
15
+ import { RETAIL_REPORTS } from "./reports.js";
12
16
  import type {
13
17
  AnalyticsAdapter,
14
18
  AnalyticsMeta,
@@ -31,6 +35,7 @@ export type {
31
35
  export interface AnalyticsServiceDeps {
32
36
  adapter: AnalyticsAdapter;
33
37
  config: CommerceConfig;
38
+ reports?: RetailReportsEngine;
34
39
  }
35
40
 
36
41
  export class AnalyticsService {
@@ -116,6 +121,24 @@ export class AnalyticsService {
116
121
  );
117
122
  }
118
123
 
124
+ /** The canned retail reports pack (issue #48). */
125
+ listReports(): Array<{ name: string; description: string }> {
126
+ return RETAIL_REPORTS.map((r) => ({ name: r.name, description: r.description }));
127
+ }
128
+
129
+ /** Runs a canned retail report (issue #48), org-scoped to the actor. */
130
+ async getReport(
131
+ name: string,
132
+ params: ReportParams,
133
+ actor?: Actor | null,
134
+ ): Promise<Result<Record<string, unknown>>> {
135
+ if (!this.deps.reports) {
136
+ return Err(new CommerceValidationError("Retail reports are not available."));
137
+ }
138
+ const orgId = resolveOrgId(actor ?? null);
139
+ return this.deps.reports.run(name, params, orgId);
140
+ }
141
+
119
142
  async getMeta(): Promise<Result<AnalyticsMeta>> {
120
143
  await this.ensureCustomSchemaModelsLoaded();
121
144
 
@@ -1,10 +1,11 @@
1
- import { eq, and, lt } from "drizzle-orm";
1
+ import { eq, and, lt, desc, isNull, isNotNull } 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 { carts, cartLineItems } from "../schema.js";
8
+ import { customers } from "../../customers/schema.js";
8
9
 
9
10
  // Infer types from Drizzle schema
10
11
  export type Cart = typeof carts.$inferSelect;
@@ -64,6 +65,34 @@ export class CartRepository {
64
65
  return rows[0];
65
66
  }
66
67
 
68
+ async list(
69
+ orgId: string,
70
+ filter?: { status?: string; olderThan?: Date; hasCustomer?: boolean },
71
+ ctx?: TxContext,
72
+ ): Promise<Array<Cart & { customerEmail: string | null }>> {
73
+ const db = this.getDb(ctx);
74
+ const conditions = [eq(carts.organizationId, orgId)];
75
+ if (filter?.status) {
76
+ conditions.push(eq(carts.status, filter.status as Cart["status"]));
77
+ }
78
+ if (filter?.olderThan) {
79
+ conditions.push(lt(carts.updatedAt, filter.olderThan));
80
+ }
81
+ if (filter?.hasCustomer === true) {
82
+ conditions.push(isNotNull(carts.customerId));
83
+ }
84
+ if (filter?.hasCustomer === false) {
85
+ conditions.push(isNull(carts.customerId));
86
+ }
87
+ const rows = await db
88
+ .select({ cart: carts, customerEmail: customers.email })
89
+ .from(carts)
90
+ .leftJoin(customers, eq(carts.customerId, customers.id))
91
+ .where(and(...conditions))
92
+ .orderBy(desc(carts.updatedAt));
93
+ return rows.map((r) => ({ ...r.cart, customerEmail: r.customerEmail ?? null }));
94
+ }
95
+
67
96
  async findExpiredCarts(ctx?: TxContext): Promise<Cart[]> {
68
97
  const db = this.getDb(ctx);
69
98
  return db
@@ -12,6 +12,8 @@ export const carts = pgTable("carts", {
12
12
  .notNull()
13
13
  .default("active"),
14
14
  currency: text("currency").notNull().default("USD"),
15
+ // Shopper contact for guest carts — enables abandoned-checkout recovery
16
+ email: text("email"),
15
17
  secret: text("secret"),
16
18
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
17
19
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
@@ -5,6 +5,7 @@ import { z } from "@hono/zod-openapi";
5
5
  export const CreateCartBodySchema = z.object({
6
6
  customerId: z.string().optional().openapi({ example: "customer-uuid" }),
7
7
  currency: z.string().length(3).optional().openapi({ example: "USD" }),
8
+ email: z.email().optional().openapi({ example: "shopper@example.com" }),
8
9
  }).openapi("CreateCartRequest");
9
10
 
10
11
  export const AddCartItemBodySchema = z.object({
@@ -18,6 +18,7 @@ import type {
18
18
  import type { HookRegistry } from "../../kernel/hooks/registry.js";
19
19
  import { Err, Ok, type Result } from "../../kernel/result.js";
20
20
  import { createLogger } from "../../utils/logger.js";
21
+ import { paginate, type Pagination } from "../../utils/pagination.js";
21
22
  import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
22
23
  import type { PluginDb } from "../../kernel/database/plugin-types.js";
23
24
  import type { TxContext } from "../../kernel/database/tx-context.js";
@@ -152,6 +153,7 @@ export class CartService {
152
153
  metadata: input.metadata ?? {},
153
154
  expiresAt: new Date(now.getTime() + ttlMinutes * 60 * 1000),
154
155
  ...(resolvedCustomerId !== null ? { customerId: resolvedCustomerId } : {}),
156
+ ...(input.email !== undefined ? { email: input.email } : {}),
155
157
  },
156
158
  ctx,
157
159
  );
@@ -455,6 +457,105 @@ export class CartService {
455
457
  return Ok(undefined);
456
458
  }
457
459
 
460
+ /**
461
+ * Admin listing of carts with abandoned-checkout recovery filters:
462
+ * status, olderThan (not touched since), hasCustomer, plus pagination.
463
+ * Returns shopper identity (cart email + linked customer email).
464
+ */
465
+ async list(
466
+ filter?: {
467
+ status?: string;
468
+ olderThan?: Date;
469
+ hasCustomer?: boolean;
470
+ page?: number;
471
+ limit?: number;
472
+ },
473
+ actor?: Actor | null,
474
+ ctx?: TxContext,
475
+ ): Promise<Result<{ items: Array<Cart & { customerEmail: string | null }>; pagination: Pagination }>> {
476
+ try {
477
+ assertPermission(actor ?? null, "cart:manage");
478
+ } catch (error) {
479
+ return Err(toCommerceError(error));
480
+ }
481
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
482
+ const rows = await this.repo.list(
483
+ orgId,
484
+ {
485
+ ...(filter?.status !== undefined ? { status: filter.status } : {}),
486
+ ...(filter?.olderThan !== undefined ? { olderThan: filter.olderThan } : {}),
487
+ ...(filter?.hasCustomer !== undefined ? { hasCustomer: filter.hasCustomer } : {}),
488
+ },
489
+ ctx,
490
+ );
491
+ const paged = paginate(rows, filter?.page ?? 1, filter?.limit ?? 20);
492
+ return Ok({ items: paged.items, pagination: paged.pagination });
493
+ }
494
+
495
+ /**
496
+ * Abandoned-checkout recovery primitive: reactivates the cart, extends its
497
+ * expiry, and returns a resume secret that gates guest access to the cart —
498
+ * enough to build a recovery email with a resume/checkout link.
499
+ * Fires the `cart.afterRecover` hook so plugins/webhooks can react.
500
+ */
501
+ async recover(
502
+ cartId: string,
503
+ actor?: Actor | null,
504
+ ctx?: TxContext,
505
+ ): Promise<
506
+ Result<{
507
+ cartId: string;
508
+ secret: string;
509
+ status: string;
510
+ expiresAt: string;
511
+ email: string | null;
512
+ customerId: string | null;
513
+ }>
514
+ > {
515
+ try {
516
+ assertPermission(actor ?? null, "cart:manage");
517
+ } catch (error) {
518
+ return Err(toCommerceError(error));
519
+ }
520
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
521
+ const cart = await this.repo.findById(orgId, cartId, ctx);
522
+ if (!cart) return Err(new CommerceNotFoundError("Cart not found."));
523
+ if (cart.status === "checked_out" || cart.status === "merged" || cart.status === "checking_out") {
524
+ return Err(
525
+ new CommerceValidationError(
526
+ `Cart with status "${cart.status}" cannot be recovered.`,
527
+ ),
528
+ );
529
+ }
530
+
531
+ const secret = cart.secret ?? crypto.randomUUID();
532
+ const ttlMinutes = this.deps.config.cart?.ttlMinutes ?? 60 * 24 * 7;
533
+ const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);
534
+ const updated = await this.repo.update(
535
+ cart.id,
536
+ { status: "active", secret, expiresAt },
537
+ ctx,
538
+ );
539
+
540
+ const afterHooks = this.deps.hooks.resolve("cart.afterRecover") as AfterHook<Cart>[];
541
+ await runAfterHooks(
542
+ afterHooks,
543
+ null,
544
+ updated ?? cart,
545
+ "recover",
546
+ makeContext(actor ?? null, this.deps.services, this.deps.database, ctx?.tx ?? null),
547
+ );
548
+
549
+ return Ok({
550
+ cartId: cart.id,
551
+ secret,
552
+ status: "active",
553
+ expiresAt: expiresAt.toISOString(),
554
+ email: cart.email ?? null,
555
+ customerId: cart.customerId ?? null,
556
+ });
557
+ }
558
+
458
559
  async abandon(cartId: string, actor?: Actor | null, ctx?: TxContext): Promise<Result<void>> {
459
560
  const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
460
561
  const cart = await this.repo.findById(orgId, cartId, ctx);