@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.
Files changed (325) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +8 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +7 -0
  7. package/dist/config/types.d.ts +26 -1
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.d.ts +3 -0
  10. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  11. package/dist/generated/plugin-manifest.js +1 -0
  12. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  13. package/dist/hooks/checkout-completion.js +2 -2
  14. package/dist/hooks/checkout.d.ts.map +1 -1
  15. package/dist/hooks/checkout.js +17 -2
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/interfaces/rest/index.d.ts.map +1 -1
  20. package/dist/interfaces/rest/index.js +12 -0
  21. package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
  22. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
  23. package/dist/interfaces/rest/routes/admin/staff.js +206 -0
  24. package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
  25. package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
  26. package/dist/interfaces/rest/routes/analytics.js +24 -0
  27. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/carts.js +39 -2
  29. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  30. package/dist/interfaces/rest/routes/catalog.js +17 -1
  31. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  32. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  33. package/dist/interfaces/rest/routes/checkout.js +97 -21
  34. package/dist/interfaces/rest/routes/documents.d.ts +10 -0
  35. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
  36. package/dist/interfaces/rest/routes/documents.js +40 -0
  37. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  38. package/dist/interfaces/rest/routes/inventory.js +13 -0
  39. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  40. package/dist/interfaces/rest/routes/orders.js +111 -1
  41. package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
  42. package/dist/interfaces/rest/routes/pricing.js +36 -1
  43. package/dist/interfaces/rest/routes/settings.d.ts +5 -0
  44. package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
  45. package/dist/interfaces/rest/routes/settings.js +50 -0
  46. package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
  47. package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
  48. package/dist/interfaces/rest/routes/shipping.js +68 -0
  49. package/dist/interfaces/rest/routes/tax.d.ts +5 -0
  50. package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
  51. package/dist/interfaces/rest/routes/tax.js +68 -0
  52. package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
  53. package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
  54. package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
  55. package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
  56. package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
  57. package/dist/interfaces/rest/schemas/analytics.js +33 -0
  58. package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
  59. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  60. package/dist/interfaces/rest/schemas/carts.js +38 -0
  61. package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
  62. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  63. package/dist/interfaces/rest/schemas/catalog.js +48 -0
  64. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  65. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  66. package/dist/interfaces/rest/schemas/checkout.js +4 -0
  67. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  68. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  69. package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
  70. package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
  71. package/dist/interfaces/rest/schemas/documents.js +65 -0
  72. package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
  73. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  74. package/dist/interfaces/rest/schemas/orders.js +234 -1
  75. package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
  76. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  77. package/dist/interfaces/rest/schemas/pricing.js +70 -0
  78. package/dist/interfaces/rest/schemas/responses.d.ts +190 -0
  79. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  80. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  81. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  82. package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
  83. package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
  84. package/dist/interfaces/rest/schemas/settings.js +79 -0
  85. package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
  86. package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
  87. package/dist/interfaces/rest/schemas/shipping.js +146 -0
  88. package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
  89. package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
  90. package/dist/interfaces/rest/schemas/tax.js +132 -0
  91. package/dist/kernel/database/schema.d.ts +4 -0
  92. package/dist/kernel/database/schema.d.ts.map +1 -1
  93. package/dist/kernel/database/schema.js +8 -0
  94. package/dist/kernel/database/scoped-db.d.ts +2 -1
  95. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  96. package/dist/kernel/database/scoped-db.js +62 -1
  97. package/dist/kernel/hooks/types.d.ts +1 -1
  98. package/dist/kernel/hooks/types.d.ts.map +1 -1
  99. package/dist/kernel/plugin/manifest.d.ts +26 -0
  100. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  101. package/dist/kernel/plugin/manifest.js +17 -2
  102. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  103. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  104. package/dist/modules/analytics/module.d.ts.map +1 -1
  105. package/dist/modules/analytics/module.js +6 -0
  106. package/dist/modules/analytics/reports.d.ts +58 -0
  107. package/dist/modules/analytics/reports.d.ts.map +1 -0
  108. package/dist/modules/analytics/reports.js +249 -0
  109. package/dist/modules/analytics/service.d.ts +10 -0
  110. package/dist/modules/analytics/service.d.ts.map +1 -1
  111. package/dist/modules/analytics/service.js +14 -0
  112. package/dist/modules/cart/repository/index.d.ts +7 -0
  113. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  114. package/dist/modules/cart/repository/index.js +25 -1
  115. package/dist/modules/cart/schema.d.ts +17 -0
  116. package/dist/modules/cart/schema.d.ts.map +1 -1
  117. package/dist/modules/cart/schema.js +2 -0
  118. package/dist/modules/cart/schemas.d.ts +1 -0
  119. package/dist/modules/cart/schemas.d.ts.map +1 -1
  120. package/dist/modules/cart/schemas.js +1 -0
  121. package/dist/modules/cart/service.d.ts +32 -0
  122. package/dist/modules/cart/service.d.ts.map +1 -1
  123. package/dist/modules/cart/service.js +61 -2
  124. package/dist/modules/catalog/entity-service.d.ts +21 -0
  125. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  126. package/dist/modules/catalog/entity-service.js +166 -5
  127. package/dist/modules/catalog/schema.d.ts +34 -0
  128. package/dist/modules/catalog/schema.d.ts.map +1 -1
  129. package/dist/modules/catalog/schema.js +5 -0
  130. package/dist/modules/catalog/schemas.d.ts +3 -0
  131. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  132. package/dist/modules/catalog/schemas.js +3 -0
  133. package/dist/modules/catalog/service.d.ts +36 -0
  134. package/dist/modules/catalog/service.d.ts.map +1 -1
  135. package/dist/modules/catalog/service.js +6 -0
  136. package/dist/modules/customers/service.d.ts.map +1 -1
  137. package/dist/modules/customers/service.js +6 -0
  138. package/dist/modules/documents/module.d.ts +7 -0
  139. package/dist/modules/documents/module.d.ts.map +1 -0
  140. package/dist/modules/documents/module.js +12 -0
  141. package/dist/modules/documents/pdf.d.ts +25 -0
  142. package/dist/modules/documents/pdf.d.ts.map +1 -0
  143. package/dist/modules/documents/pdf.js +80 -0
  144. package/dist/modules/documents/render.d.ts +32 -0
  145. package/dist/modules/documents/render.d.ts.map +1 -0
  146. package/dist/modules/documents/render.js +190 -0
  147. package/dist/modules/documents/repository/index.d.ts +25 -0
  148. package/dist/modules/documents/repository/index.d.ts.map +1 -0
  149. package/dist/modules/documents/repository/index.js +47 -0
  150. package/dist/modules/documents/schema.d.ts +214 -0
  151. package/dist/modules/documents/schema.d.ts.map +1 -0
  152. package/dist/modules/documents/schema.js +42 -0
  153. package/dist/modules/documents/service.d.ts +51 -0
  154. package/dist/modules/documents/service.d.ts.map +1 -0
  155. package/dist/modules/documents/service.js +119 -0
  156. package/dist/modules/fulfillment/service.d.ts +18 -0
  157. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  158. package/dist/modules/fulfillment/service.js +85 -1
  159. package/dist/modules/orders/repository/index.d.ts +18 -1
  160. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  161. package/dist/modules/orders/repository/index.js +86 -1
  162. package/dist/modules/orders/schema.d.ts +391 -0
  163. package/dist/modules/orders/schema.d.ts.map +1 -1
  164. package/dist/modules/orders/schema.js +56 -1
  165. package/dist/modules/orders/service.d.ts +102 -3
  166. package/dist/modules/orders/service.d.ts.map +1 -1
  167. package/dist/modules/orders/service.js +653 -29
  168. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  169. package/dist/modules/orders/stale-order-cleanup.js +13 -1
  170. package/dist/modules/pricing/repository/index.d.ts +5 -0
  171. package/dist/modules/pricing/repository/index.d.ts.map +1 -1
  172. package/dist/modules/pricing/repository/index.js +19 -0
  173. package/dist/modules/pricing/service.d.ts +18 -0
  174. package/dist/modules/pricing/service.d.ts.map +1 -1
  175. package/dist/modules/pricing/service.js +43 -5
  176. package/dist/modules/settings/module.d.ts +6 -0
  177. package/dist/modules/settings/module.d.ts.map +1 -0
  178. package/dist/modules/settings/module.js +11 -0
  179. package/dist/modules/settings/repository/index.d.ts +16 -0
  180. package/dist/modules/settings/repository/index.d.ts.map +1 -0
  181. package/dist/modules/settings/repository/index.js +41 -0
  182. package/dist/modules/settings/schema.d.ts +121 -0
  183. package/dist/modules/settings/schema.d.ts.map +1 -0
  184. package/dist/modules/settings/schema.js +24 -0
  185. package/dist/modules/settings/service.d.ts +26 -0
  186. package/dist/modules/settings/service.d.ts.map +1 -0
  187. package/dist/modules/settings/service.js +51 -0
  188. package/dist/modules/shipping/calculator.d.ts +5 -0
  189. package/dist/modules/shipping/calculator.d.ts.map +1 -1
  190. package/dist/modules/shipping/calculator.js +10 -0
  191. package/dist/modules/shipping/module.d.ts +5 -1
  192. package/dist/modules/shipping/module.d.ts.map +1 -1
  193. package/dist/modules/shipping/module.js +4 -1
  194. package/dist/modules/shipping/repository/index.d.ts +27 -2
  195. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  196. package/dist/modules/shipping/repository/index.js +105 -1
  197. package/dist/modules/shipping/schema.d.ts +419 -0
  198. package/dist/modules/shipping/schema.d.ts.map +1 -0
  199. package/dist/modules/shipping/schema.js +56 -0
  200. package/dist/modules/shipping/service.d.ts +54 -0
  201. package/dist/modules/shipping/service.d.ts.map +1 -1
  202. package/dist/modules/shipping/service.js +161 -2
  203. package/dist/modules/tax/adapter.d.ts +13 -0
  204. package/dist/modules/tax/adapter.d.ts.map +1 -1
  205. package/dist/modules/tax/module.d.ts +5 -1
  206. package/dist/modules/tax/module.d.ts.map +1 -1
  207. package/dist/modules/tax/module.js +4 -1
  208. package/dist/modules/tax/repository/index.d.ts +38 -2
  209. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  210. package/dist/modules/tax/repository/index.js +138 -1
  211. package/dist/modules/tax/schema.d.ts +355 -0
  212. package/dist/modules/tax/schema.d.ts.map +1 -0
  213. package/dist/modules/tax/schema.js +55 -0
  214. package/dist/modules/tax/service.d.ts +60 -1
  215. package/dist/modules/tax/service.d.ts.map +1 -1
  216. package/dist/modules/tax/service.js +227 -2
  217. package/dist/runtime/kernel-modules.d.ts +15 -2
  218. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  219. package/dist/runtime/kernel-modules.js +4 -0
  220. package/dist/runtime/kernel-types.d.ts +5 -1
  221. package/dist/runtime/kernel-types.d.ts.map +1 -1
  222. package/dist/runtime/kernel-types.js +2 -0
  223. package/dist/runtime/server.js +1 -1
  224. package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
  225. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  226. package/dist/test-utils/create-plugin-test-app.js +15 -3
  227. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  228. package/dist/test-utils/create-test-config.js +2 -0
  229. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  230. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  231. package/dist/test-utils/order-test-helpers.js +18 -0
  232. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  233. package/dist/test-utils/rest-api-test-utils.js +7 -0
  234. package/dist/test-utils/test-actors.js +1 -1
  235. package/dist/testing.d.ts +1 -0
  236. package/dist/testing.d.ts.map +1 -1
  237. package/dist/testing.js +1 -0
  238. package/package.json +3 -3
  239. package/src/auth/middleware.ts +26 -7
  240. package/src/auth/setup.ts +11 -4
  241. package/src/config/defaults.ts +7 -0
  242. package/src/config/types.ts +26 -1
  243. package/src/generated/plugin-capabilities.d.ts +1 -0
  244. package/src/generated/plugin-manifest.ts +1 -0
  245. package/src/generated/plugin-repositories.d.ts +1 -0
  246. package/src/hooks/checkout-completion.ts +22 -16
  247. package/src/hooks/checkout.ts +36 -11
  248. package/src/index.ts +1 -0
  249. package/src/interfaces/rest/index.ts +12 -0
  250. package/src/interfaces/rest/routes/admin/staff.ts +260 -0
  251. package/src/interfaces/rest/routes/analytics.ts +33 -0
  252. package/src/interfaces/rest/routes/carts.ts +52 -2
  253. package/src/interfaces/rest/routes/catalog.ts +34 -0
  254. package/src/interfaces/rest/routes/checkout.ts +114 -27
  255. package/src/interfaces/rest/routes/documents.ts +61 -0
  256. package/src/interfaces/rest/routes/inventory.ts +14 -0
  257. package/src/interfaces/rest/routes/orders.ts +148 -1
  258. package/src/interfaces/rest/routes/pricing.ts +44 -1
  259. package/src/interfaces/rest/routes/settings.ts +61 -0
  260. package/src/interfaces/rest/routes/shipping.ts +92 -0
  261. package/src/interfaces/rest/routes/tax.ts +90 -0
  262. package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
  263. package/src/interfaces/rest/schemas/analytics.ts +39 -0
  264. package/src/interfaces/rest/schemas/carts.ts +40 -0
  265. package/src/interfaces/rest/schemas/catalog.ts +53 -0
  266. package/src/interfaces/rest/schemas/checkout.ts +4 -0
  267. package/src/interfaces/rest/schemas/documents.ts +72 -0
  268. package/src/interfaces/rest/schemas/orders.ts +258 -1
  269. package/src/interfaces/rest/schemas/pricing.ts +75 -0
  270. package/src/interfaces/rest/schemas/settings.ts +93 -0
  271. package/src/interfaces/rest/schemas/shipping.ts +164 -0
  272. package/src/interfaces/rest/schemas/tax.ts +148 -0
  273. package/src/kernel/database/schema.ts +12 -0
  274. package/src/kernel/database/scoped-db.ts +70 -1
  275. package/src/kernel/hooks/types.ts +1 -0
  276. package/src/kernel/plugin/manifest.ts +41 -2
  277. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  278. package/src/modules/analytics/module.ts +10 -0
  279. package/src/modules/analytics/reports.ts +311 -0
  280. package/src/modules/analytics/service.ts +23 -0
  281. package/src/modules/cart/repository/index.ts +30 -1
  282. package/src/modules/cart/schema.ts +2 -0
  283. package/src/modules/cart/schemas.ts +1 -0
  284. package/src/modules/cart/service.ts +104 -2
  285. package/src/modules/catalog/entity-service.ts +196 -4
  286. package/src/modules/catalog/schema.ts +5 -0
  287. package/src/modules/catalog/schemas.ts +3 -0
  288. package/src/modules/catalog/service.ts +30 -0
  289. package/src/modules/customers/service.ts +6 -0
  290. package/src/modules/documents/module.ts +19 -0
  291. package/src/modules/documents/pdf.ts +101 -0
  292. package/src/modules/documents/render.ts +231 -0
  293. package/src/modules/documents/repository/index.ts +68 -0
  294. package/src/modules/documents/schema.ts +44 -0
  295. package/src/modules/documents/service.ts +176 -0
  296. package/src/modules/fulfillment/service.ts +139 -1
  297. package/src/modules/orders/repository/index.ts +127 -1
  298. package/src/modules/orders/schema.ts +58 -1
  299. package/src/modules/orders/service.ts +1041 -65
  300. package/src/modules/orders/stale-order-cleanup.ts +18 -4
  301. package/src/modules/pricing/repository/index.ts +34 -0
  302. package/src/modules/pricing/service.ts +76 -6
  303. package/src/modules/settings/module.ts +18 -0
  304. package/src/modules/settings/repository/index.ts +59 -0
  305. package/src/modules/settings/schema.ts +25 -0
  306. package/src/modules/settings/service.ts +82 -0
  307. package/src/modules/shipping/calculator.ts +27 -0
  308. package/src/modules/shipping/module.ts +6 -2
  309. package/src/modules/shipping/repository/index.ts +136 -2
  310. package/src/modules/shipping/schema.ts +58 -0
  311. package/src/modules/shipping/service.ts +260 -1
  312. package/src/modules/tax/adapter.ts +9 -0
  313. package/src/modules/tax/module.ts +6 -2
  314. package/src/modules/tax/repository/index.ts +169 -2
  315. package/src/modules/tax/schema.ts +57 -0
  316. package/src/modules/tax/service.ts +313 -2
  317. package/src/runtime/kernel-modules.ts +4 -0
  318. package/src/runtime/kernel-types.ts +6 -0
  319. package/src/runtime/server.ts +1 -1
  320. package/src/test-utils/create-plugin-test-app.ts +17 -4
  321. package/src/test-utils/create-test-config.ts +2 -0
  322. package/src/test-utils/order-test-helpers.ts +26 -0
  323. package/src/test-utils/rest-api-test-utils.ts +7 -0
  324. package/src/test-utils/test-actors.ts +1 -1
  325. package/src/testing.ts +1 -0
@@ -0,0 +1,231 @@
1
+ import type { HydratedOrder } from "../orders/service.js";
2
+ import { A4, type PdfTextOp } from "./pdf.js";
3
+
4
+ /** Branding values read from the settings module's `branding` group. */
5
+ export interface DocumentBranding {
6
+ storeName?: string;
7
+ receiptHeader?: string;
8
+ receiptFooter?: string;
9
+ logoUrl?: string;
10
+ address?: string;
11
+ phone?: string;
12
+ email?: string;
13
+ taxId?: string;
14
+ }
15
+
16
+ export interface DocumentContext {
17
+ order: HydratedOrder;
18
+ branding: DocumentBranding;
19
+ invoiceNumber: string;
20
+ }
21
+
22
+ export function escapeHtml(value: unknown): string {
23
+ return String(value ?? "")
24
+ .replaceAll("&", "&")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;")
27
+ .replaceAll('"', "&quot;")
28
+ .replaceAll("'", "&#39;");
29
+ }
30
+
31
+ /** Minor units → "1,234.50 LKR". */
32
+ export function formatAmount(minor: number, currency: string): string {
33
+ const major = minor / 100;
34
+ const formatted = major.toLocaleString("en-US", {
35
+ minimumFractionDigits: 2,
36
+ maximumFractionDigits: 2,
37
+ });
38
+ return `${formatted} ${currency}`;
39
+ }
40
+
41
+ function formatDate(date: Date | string | null | undefined): string {
42
+ if (!date) return "";
43
+ const d = typeof date === "string" ? new Date(date) : date;
44
+ return d.toISOString().slice(0, 10);
45
+ }
46
+
47
+ function sellerBlockHtml(branding: DocumentBranding): string {
48
+ const lines = [
49
+ branding.storeName && `<strong>${escapeHtml(branding.storeName)}</strong>`,
50
+ branding.address && escapeHtml(branding.address),
51
+ branding.phone && escapeHtml(branding.phone),
52
+ branding.email && escapeHtml(branding.email),
53
+ branding.taxId && `Tax ID: ${escapeHtml(branding.taxId)}`,
54
+ ].filter(Boolean);
55
+ return lines.join("<br>");
56
+ }
57
+
58
+ function lineRowsHtml(order: HydratedOrder): string {
59
+ return order.lineItems
60
+ .map(
61
+ (li) => `<tr>
62
+ <td>${escapeHtml(li.title)}${li.sku ? `<br><small>${escapeHtml(li.sku)}</small>` : ""}</td>
63
+ <td class="num">${li.quantity}</td>
64
+ <td class="num">${escapeHtml(formatAmount(li.unitPrice, order.currency))}</td>
65
+ <td class="num">${escapeHtml(formatAmount(li.totalPrice, order.currency))}</td>
66
+ </tr>`,
67
+ )
68
+ .join("\n");
69
+ }
70
+
71
+ function totalsRowsHtml(order: HydratedOrder): string {
72
+ const rows: Array<[string, number]> = [
73
+ ["Subtotal", order.subtotal],
74
+ ...(order.discountTotal ? ([["Discount", -order.discountTotal]] as Array<[string, number]>) : []),
75
+ ["Tax", order.taxTotal],
76
+ ...(order.shippingTotal ? ([["Shipping", order.shippingTotal]] as Array<[string, number]>) : []),
77
+ ];
78
+ const body = rows
79
+ .map(([label, amount]) => `<tr><td>${label}</td><td class="num">${escapeHtml(formatAmount(amount, order.currency))}</td></tr>`)
80
+ .join("\n");
81
+ return `${body}\n<tr class="grand"><td>Total</td><td class="num">${escapeHtml(formatAmount(order.grandTotal, order.currency))}</td></tr>`;
82
+ }
83
+
84
+ const BASE_STYLE = `body{font-family:system-ui,-apple-system,sans-serif;color:#111;margin:2rem auto;line-height:1.45}
85
+ table{width:100%;border-collapse:collapse;margin:1rem 0}
86
+ th,td{text-align:left;padding:.4rem .5rem;border-bottom:1px solid #ddd}
87
+ td.num,th.num{text-align:right}
88
+ tr.grand td{font-weight:700;border-top:2px solid #111}
89
+ small{color:#666}`;
90
+
91
+ /** A4-style HTML invoice with seller branding and fiscal invoice number. */
92
+ export function invoiceHtml(ctx: DocumentContext): string {
93
+ const { order, branding, invoiceNumber } = ctx;
94
+ return `<!doctype html>
95
+ <html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoiceNumber)}</title>
96
+ <style>${BASE_STYLE}
97
+ body{max-width:48rem}</style></head>
98
+ <body>
99
+ <table style="border:none"><tr>
100
+ <td style="border:none">${sellerBlockHtml(branding)}</td>
101
+ <td style="border:none;text-align:right">
102
+ <h1 style="margin:0">INVOICE</h1>
103
+ <div>Invoice #: <strong>${escapeHtml(invoiceNumber)}</strong></div>
104
+ <div>Order #: ${escapeHtml(order.orderNumber)}</div>
105
+ <div>Date: ${escapeHtml(formatDate(order.placedAt))}</div>
106
+ </td>
107
+ </tr></table>
108
+ <table>
109
+ <thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
110
+ <tbody>${lineRowsHtml(order)}</tbody>
111
+ </table>
112
+ <table style="max-width:20rem;margin-left:auto"><tbody>${totalsRowsHtml(order)}</tbody></table>
113
+ ${branding.receiptFooter ? `<p><small>${escapeHtml(branding.receiptFooter)}</small></p>` : ""}
114
+ </body></html>`;
115
+ }
116
+
117
+ /** Narrow receipt-style HTML with the configured header/footer. */
118
+ export function receiptHtml(ctx: Omit<DocumentContext, "invoiceNumber"> & { invoiceNumber?: string }): string {
119
+ const { order, branding } = ctx;
120
+ return `<!doctype html>
121
+ <html><head><meta charset="utf-8"><title>Receipt ${escapeHtml(order.orderNumber)}</title>
122
+ <style>${BASE_STYLE}
123
+ body{max-width:22rem;font-size:.9rem}
124
+ h2,p.center{text-align:center}</style></head>
125
+ <body>
126
+ ${branding.storeName ? `<h2>${escapeHtml(branding.storeName)}</h2>` : ""}
127
+ ${branding.receiptHeader ? `<p class="center">${escapeHtml(branding.receiptHeader)}</p>` : ""}
128
+ ${branding.taxId ? `<p class="center"><small>Tax ID: ${escapeHtml(branding.taxId)}</small></p>` : ""}
129
+ <p>Order #: ${escapeHtml(order.orderNumber)}<br>Date: ${escapeHtml(formatDate(order.placedAt))}</p>
130
+ <table>
131
+ <tbody>${lineRowsHtml(order)}</tbody>
132
+ </table>
133
+ <table><tbody>${totalsRowsHtml(order)}</tbody></table>
134
+ ${branding.receiptFooter ? `<p class="center"><small>${escapeHtml(branding.receiptFooter)}</small></p>` : ""}
135
+ </body></html>`;
136
+ }
137
+
138
+ // ── PDF layout ─────────────────────────────────────────────────────────────
139
+
140
+ const MARGIN = 50;
141
+ const LINE_HEIGHT = 16;
142
+ const COL = {
143
+ item: MARGIN,
144
+ qty: 380,
145
+ unit: 440,
146
+ total: 515,
147
+ };
148
+
149
+ function truncate(text: string, max: number): string {
150
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
151
+ }
152
+
153
+ /**
154
+ * Lays an invoice out as pages of text ops for the minimal PDF writer.
155
+ */
156
+ export function invoicePdfOps(ctx: DocumentContext): PdfTextOp[][] {
157
+ const { order, branding, invoiceNumber } = ctx;
158
+ const pages: PdfTextOp[][] = [];
159
+ let ops: PdfTextOp[] = [];
160
+ let y = A4.height - MARGIN;
161
+
162
+ const write = (text: string, x: number, opts?: { size?: number | undefined; bold?: boolean | undefined }) => {
163
+ ops.push({ x, y, size: opts?.size ?? 10, bold: opts?.bold, text });
164
+ };
165
+ const newline = (lines = 1) => {
166
+ y -= LINE_HEIGHT * lines;
167
+ if (y < MARGIN + LINE_HEIGHT) {
168
+ pages.push(ops);
169
+ ops = [];
170
+ y = A4.height - MARGIN;
171
+ }
172
+ };
173
+
174
+ write("INVOICE", MARGIN, { size: 20, bold: true });
175
+ newline(2);
176
+ write(`Invoice #: ${invoiceNumber}`, MARGIN, { bold: true });
177
+ newline();
178
+ write(`Order #: ${order.orderNumber}`, MARGIN);
179
+ newline();
180
+ write(`Date: ${formatDate(order.placedAt)}`, MARGIN);
181
+ newline(2);
182
+
183
+ const seller = [
184
+ branding.storeName,
185
+ branding.address,
186
+ branding.phone,
187
+ branding.email,
188
+ branding.taxId ? `Tax ID: ${branding.taxId}` : undefined,
189
+ ].filter((v): v is string => Boolean(v));
190
+ for (const [i, line] of seller.entries()) {
191
+ write(line, MARGIN, { bold: i === 0 });
192
+ newline();
193
+ }
194
+ newline();
195
+
196
+ write("Item", COL.item, { bold: true });
197
+ write("Qty", COL.qty, { bold: true });
198
+ write("Unit", COL.unit, { bold: true });
199
+ write("Amount", COL.total, { bold: true });
200
+ newline();
201
+
202
+ for (const li of order.lineItems) {
203
+ write(truncate(li.title, 52), COL.item);
204
+ write(String(li.quantity), COL.qty);
205
+ write(formatAmount(li.unitPrice, order.currency), COL.unit);
206
+ write(formatAmount(li.totalPrice, order.currency), COL.total);
207
+ newline();
208
+ }
209
+ newline();
210
+
211
+ const totals: Array<[string, number, boolean?]> = [
212
+ ["Subtotal", order.subtotal],
213
+ ...(order.discountTotal ? ([["Discount", -order.discountTotal]] as Array<[string, number]>) : []),
214
+ ["Tax", order.taxTotal],
215
+ ...(order.shippingTotal ? ([["Shipping", order.shippingTotal]] as Array<[string, number]>) : []),
216
+ ["Total", order.grandTotal, true],
217
+ ];
218
+ for (const [label, amount, bold] of totals) {
219
+ write(label, COL.qty, { bold });
220
+ write(formatAmount(amount, order.currency), COL.total, { bold });
221
+ newline();
222
+ }
223
+
224
+ if (branding.receiptFooter) {
225
+ newline();
226
+ write(branding.receiptFooter, MARGIN, { size: 8 });
227
+ }
228
+
229
+ pages.push(ops);
230
+ return pages;
231
+ }
@@ -0,0 +1,68 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import type { TxContext } from "../../../kernel/database/tx-context.js";
3
+ import type {
4
+ DrizzleDatabase,
5
+ DbOrTx,
6
+ } from "../../../kernel/database/drizzle-db.js";
7
+ import { invoiceSequences, orderDocuments } from "../schema.js";
8
+
9
+ export type OrderDocument = typeof orderDocuments.$inferSelect;
10
+
11
+ /**
12
+ * Persistence for fiscal sequences and issued documents (issue #47).
13
+ */
14
+ export class DocumentsRepository {
15
+ constructor(private readonly db: DrizzleDatabase) {}
16
+
17
+ private getDb(ctx?: TxContext): DbOrTx {
18
+ return (ctx?.tx as DbOrTx | undefined) ?? this.db;
19
+ }
20
+
21
+ /**
22
+ * Atomically allocates the next sequence value for (org, series).
23
+ * Single upsert-returning statement — safe under concurrency.
24
+ */
25
+ async allocate(orgId: string, series: string, ctx?: TxContext): Promise<number> {
26
+ const db = this.getDb(ctx);
27
+ const rows = await db
28
+ .insert(invoiceSequences)
29
+ .values({ organizationId: orgId, series, nextValue: 2 })
30
+ .onConflictDoUpdate({
31
+ target: [invoiceSequences.organizationId, invoiceSequences.series],
32
+ set: {
33
+ nextValue: sql`${invoiceSequences.nextValue} + 1`,
34
+ updatedAt: new Date(),
35
+ },
36
+ })
37
+ .returning({ nextValue: invoiceSequences.nextValue });
38
+ // nextValue is post-increment; the allocated number is one less.
39
+ return rows[0]!.nextValue - 1;
40
+ }
41
+
42
+ async findDocument(
43
+ orgId: string,
44
+ orderId: string,
45
+ type: string,
46
+ ctx?: TxContext,
47
+ ): Promise<OrderDocument | undefined> {
48
+ const db = this.getDb(ctx);
49
+ const rows = await db
50
+ .select()
51
+ .from(orderDocuments)
52
+ .where(and(
53
+ eq(orderDocuments.organizationId, orgId),
54
+ eq(orderDocuments.orderId, orderId),
55
+ eq(orderDocuments.type, type),
56
+ ));
57
+ return rows[0];
58
+ }
59
+
60
+ async createDocument(
61
+ data: { organizationId: string; orderId: string; type: string; documentNumber: string },
62
+ ctx?: TxContext,
63
+ ): Promise<OrderDocument> {
64
+ const db = this.getDb(ctx);
65
+ const rows = await db.insert(orderDocuments).values(data).returning();
66
+ return rows[0]!;
67
+ }
68
+ }
@@ -0,0 +1,44 @@
1
+ import { index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+ import { organization } from "../../auth/auth-schema.js";
3
+ import { orders } from "../orders/schema.js";
4
+
5
+ /**
6
+ * Fiscal document sequences (issue #47).
7
+ *
8
+ * Per-(org, series) counters for legally sequential invoice numbers.
9
+ * Allocation is a single atomic upsert-returning statement, so concurrent
10
+ * requests never observe the same value.
11
+ */
12
+ export const invoiceSequences = pgTable("invoice_sequences", {
13
+ id: uuid("id").defaultRandom().primaryKey(),
14
+ organizationId: text("organization_id")
15
+ .notNull()
16
+ .references(() => organization.id, { onDelete: "cascade" }),
17
+ series: text("series").notNull().default("default"),
18
+ // The NEXT value to hand out; allocation returns the pre-increment value.
19
+ nextValue: integer("next_value").notNull().default(1),
20
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
21
+ }, (table) => ({
22
+ orgSeriesUnique: uniqueIndex("invoice_sequences_org_series_unique").on(table.organizationId, table.series),
23
+ }));
24
+
25
+ /**
26
+ * Issued documents (issue #47). One row per (org, order, type) — re-rendering
27
+ * an order's invoice returns the number issued the first time, so a fiscal
28
+ * number never changes once handed out.
29
+ */
30
+ export const orderDocuments = pgTable("order_documents", {
31
+ id: uuid("id").defaultRandom().primaryKey(),
32
+ organizationId: text("organization_id")
33
+ .notNull()
34
+ .references(() => organization.id, { onDelete: "cascade" }),
35
+ orderId: uuid("order_id")
36
+ .notNull()
37
+ .references(() => orders.id, { onDelete: "cascade" }),
38
+ type: text("type").notNull(), // "invoice" | "receipt"
39
+ documentNumber: text("document_number").notNull(),
40
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
41
+ }, (table) => ({
42
+ orgOrderTypeUnique: uniqueIndex("order_documents_org_order_type_unique").on(table.organizationId, table.orderId, table.type),
43
+ orgIdx: index("idx_order_documents_org").on(table.organizationId),
44
+ }));
@@ -0,0 +1,176 @@
1
+ import { resolveOrgId } from "../../auth/org.js";
2
+ import type { Actor } from "../../auth/types.js";
3
+ import type { TxContext } from "../../kernel/database/tx-context.js";
4
+ import { CommerceValidationError } from "../../kernel/errors.js";
5
+ import { Err, Ok, type Result } from "../../kernel/result.js";
6
+ import type { HydratedOrder, OrderService } from "../orders/service.js";
7
+ import type { SettingsService } from "../settings/service.js";
8
+ import { buildPdf } from "./pdf.js";
9
+ import {
10
+ invoiceHtml,
11
+ invoicePdfOps,
12
+ receiptHtml,
13
+ type DocumentBranding,
14
+ } from "./render.js";
15
+ import type { DocumentsRepository, OrderDocument } from "./repository/index.js";
16
+
17
+ interface DocumentsServiceDeps {
18
+ repository: DocumentsRepository;
19
+ // The shared kernel service container — orders/settings/email are resolved
20
+ // at call time (they may instantiate after this module).
21
+ services: Record<string, unknown>;
22
+ }
23
+
24
+ interface EmailAdapter {
25
+ send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void>;
26
+ }
27
+
28
+ const DEFAULT_INVOICE_PREFIX = "INV-";
29
+
30
+ /**
31
+ * Order document rendering + fiscal numbering (issue #47).
32
+ */
33
+ export class DocumentsService {
34
+ private repository: DocumentsRepository;
35
+ private services: Record<string, unknown>;
36
+
37
+ constructor(deps: DocumentsServiceDeps) {
38
+ this.repository = deps.repository;
39
+ this.services = deps.services;
40
+ }
41
+
42
+ private get orders(): OrderService {
43
+ return this.services.orders as OrderService;
44
+ }
45
+
46
+ private get settings(): SettingsService {
47
+ return this.services.settings as SettingsService;
48
+ }
49
+
50
+ private async brandingFor(orgId: string, ctx?: TxContext): Promise<DocumentBranding> {
51
+ return (await this.settings.read(orgId, "branding", ctx)) as DocumentBranding;
52
+ }
53
+
54
+ /**
55
+ * Issues (or returns the previously issued) fiscal invoice number for an
56
+ * order. Idempotent per (org, order): once handed out, the number never
57
+ * changes. Sequence allocation is atomic; a lost creation race falls back
58
+ * to the winner's document.
59
+ */
60
+ async issueInvoiceNumber(
61
+ order: HydratedOrder,
62
+ ctx?: TxContext,
63
+ ): Promise<OrderDocument> {
64
+ const orgId = order.organizationId;
65
+ const existing = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
66
+ if (existing) return existing;
67
+
68
+ const documentsSettings = await this.settings.read(orgId, "documents", ctx);
69
+ const prefix =
70
+ typeof documentsSettings.invoicePrefix === "string"
71
+ ? documentsSettings.invoicePrefix
72
+ : DEFAULT_INVOICE_PREFIX;
73
+
74
+ const value = await this.repository.allocate(orgId, "invoice", ctx);
75
+ const documentNumber = `${prefix}${String(value).padStart(6, "0")}`;
76
+ try {
77
+ return await this.repository.createDocument(
78
+ { organizationId: orgId, orderId: order.id, type: "invoice", documentNumber },
79
+ ctx,
80
+ );
81
+ } catch (error) {
82
+ // Unique (org, order, type) — a concurrent request won; use its number.
83
+ const winner = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
84
+ if (winner) return winner;
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ private async loadOrder(
90
+ orderId: string,
91
+ actor: Actor | null,
92
+ ctx?: TxContext,
93
+ ): Promise<Result<HydratedOrder>> {
94
+ return this.orders.getById(orderId, actor, ctx);
95
+ }
96
+
97
+ async renderInvoiceHtml(
98
+ orderId: string,
99
+ actor: Actor | null,
100
+ ctx?: TxContext,
101
+ ): Promise<Result<{ html: string; invoiceNumber: string }>> {
102
+ const order = await this.loadOrder(orderId, actor, ctx);
103
+ if (!order.ok) return order;
104
+ const branding = await this.brandingFor(order.value.organizationId, ctx);
105
+ const doc = await this.issueInvoiceNumber(order.value, ctx);
106
+ return Ok({
107
+ html: invoiceHtml({ order: order.value, branding, invoiceNumber: doc.documentNumber }),
108
+ invoiceNumber: doc.documentNumber,
109
+ });
110
+ }
111
+
112
+ async renderInvoicePdf(
113
+ orderId: string,
114
+ actor: Actor | null,
115
+ ctx?: TxContext,
116
+ ): Promise<Result<{ pdf: Uint8Array; invoiceNumber: string }>> {
117
+ const order = await this.loadOrder(orderId, actor, ctx);
118
+ if (!order.ok) return order;
119
+ const branding = await this.brandingFor(order.value.organizationId, ctx);
120
+ const doc = await this.issueInvoiceNumber(order.value, ctx);
121
+ const pages = invoicePdfOps({
122
+ order: order.value,
123
+ branding,
124
+ invoiceNumber: doc.documentNumber,
125
+ });
126
+ return Ok({ pdf: buildPdf(pages), invoiceNumber: doc.documentNumber });
127
+ }
128
+
129
+ async renderReceiptHtml(
130
+ orderId: string,
131
+ actor: Actor | null,
132
+ ctx?: TxContext,
133
+ ): Promise<Result<{ html: string }>> {
134
+ const order = await this.loadOrder(orderId, actor, ctx);
135
+ if (!order.ok) return order;
136
+ const branding = await this.brandingFor(order.value.organizationId, ctx);
137
+ return Ok({ html: receiptHtml({ order: order.value, branding }) });
138
+ }
139
+
140
+ /**
141
+ * Emails the invoice via the configured email adapter. The adapter's
142
+ * template contract is (template, to, data) — the rendered HTML and the
143
+ * fiscal number ride in `data` so template-based adapters can use either.
144
+ */
145
+ async emailInvoice(
146
+ orderId: string,
147
+ to: string,
148
+ actor: Actor | null,
149
+ ctx?: TxContext,
150
+ ): Promise<Result<{ sent: true; invoiceNumber: string; to: string }>> {
151
+ const email = this.services.email as EmailAdapter | undefined;
152
+ if (!email) {
153
+ return Err(new CommerceValidationError("No email adapter is configured."));
154
+ }
155
+ const rendered = await this.renderInvoiceHtml(orderId, actor, ctx);
156
+ if (!rendered.ok) return rendered;
157
+
158
+ const order = await this.loadOrder(orderId, actor, ctx);
159
+ if (!order.ok) return order;
160
+
161
+ await email.send({
162
+ template: "order-invoice",
163
+ to,
164
+ data: {
165
+ orgId: resolveOrgId(actor ?? ctx?.actor ?? null),
166
+ orderId,
167
+ orderNumber: order.value.orderNumber,
168
+ invoiceNumber: rendered.value.invoiceNumber,
169
+ grandTotal: order.value.grandTotal,
170
+ currency: order.value.currency,
171
+ html: rendered.value.html,
172
+ },
173
+ });
174
+ return Ok({ sent: true, invoiceNumber: rendered.value.invoiceNumber, to });
175
+ }
176
+ }
@@ -4,7 +4,7 @@ import type { Actor } from "../../auth/types.js";
4
4
  import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
5
5
  import type { PluginDb } from "../../kernel/database/plugin-types.js";
6
6
  import type { TxContext } from "../../kernel/database/tx-context.js";
7
- import { CommerceNotFoundError } from "../../kernel/errors.js";
7
+ import { CommerceNotFoundError, CommerceValidationError } from "../../kernel/errors.js";
8
8
  import { runAfterHooks } from "../../kernel/hooks/executor.js";
9
9
  import { createHookContext } from "../../kernel/hooks/create-context.js";
10
10
  import type { HookRegistry } from "../../kernel/hooks/registry.js";
@@ -346,6 +346,144 @@ export class FulfillmentService {
346
346
  return Ok(undefined);
347
347
  }
348
348
 
349
+ /**
350
+ * Records a fulfillment (e.g. a shipment) for a subset of an order's line
351
+ * items, with optional carrier/tracking details. Supports partial
352
+ * fulfillment (per-line quantities) and multiple fulfillments per order.
353
+ */
354
+ async createFulfillment(
355
+ input: {
356
+ orderId: string;
357
+ lineItems: Array<{ orderLineItemId: string; quantity: number }>;
358
+ carrier?: string;
359
+ trackingNumber?: string;
360
+ trackingUrl?: string;
361
+ type?: string;
362
+ status?: string;
363
+ metadata?: Record<string, unknown>;
364
+ },
365
+ actor?: Actor | null,
366
+ ctx?: TxContext,
367
+ ): Promise<Result<FulfillmentRecord>> {
368
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
369
+ const order = await this.deps.ordersRepository.findById(orgId, input.orderId, ctx);
370
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
371
+
372
+ const orderLines = await this.deps.ordersRepository.findLineItemsByOrderId(
373
+ input.orderId,
374
+ ctx,
375
+ );
376
+ const linesById = new Map(orderLines.map((li) => [li.id, li]));
377
+
378
+ // Validate every requested line before writing anything: it must belong
379
+ // to the order, and requested + already-fulfilled must not exceed ordered.
380
+ const alreadyFulfilled = new Map<string, number>();
381
+ for (const req of input.lineItems) {
382
+ const line = linesById.get(req.orderLineItemId);
383
+ if (!line) {
384
+ return Err(
385
+ new CommerceValidationError(
386
+ `Line item ${req.orderLineItemId} does not belong to this order.`,
387
+ ),
388
+ );
389
+ }
390
+ const fulfilled = await this.deps.repository.getFulfilledQuantity(
391
+ req.orderLineItemId,
392
+ ctx,
393
+ );
394
+ alreadyFulfilled.set(req.orderLineItemId, fulfilled);
395
+ if (fulfilled + req.quantity > line.quantity) {
396
+ return Err(
397
+ new CommerceValidationError(
398
+ `Cannot fulfill ${req.quantity} of "${line.title}" — only ${line.quantity - fulfilled} remaining.`,
399
+ ),
400
+ );
401
+ }
402
+ }
403
+
404
+ const status = input.status ?? "shipped";
405
+ const created = await this.deps.repository.create(
406
+ {
407
+ orderId: order.id,
408
+ type: input.type ?? "physical",
409
+ status,
410
+ carrier: input.carrier ?? null,
411
+ trackingNumber: input.trackingNumber ?? null,
412
+ trackingUrl: input.trackingUrl ?? null,
413
+ ...(status === "shipped" ? { shippedAt: new Date() } : {}),
414
+ ...(status === "delivered" ? { deliveredAt: new Date() } : {}),
415
+ ...(order.customerId != null ? { customerId: order.customerId } : {}),
416
+ metadata: input.metadata ?? {},
417
+ },
418
+ ctx,
419
+ );
420
+
421
+ await this.deps.repository.createLineItems(
422
+ input.lineItems.map((li) => ({
423
+ fulfillmentId: created.id,
424
+ orderLineItemId: li.orderLineItemId,
425
+ quantity: li.quantity,
426
+ })),
427
+ ctx,
428
+ );
429
+
430
+ await this.deps.repository.createEvent(
431
+ {
432
+ fulfillmentId: created.id,
433
+ eventType: "created",
434
+ toStatus: status,
435
+ ...(actor?.userId !== undefined ? { actorId: actor.userId } : {}),
436
+ description: input.trackingNumber
437
+ ? `Shipment recorded (${input.trackingNumber})`
438
+ : "Fulfillment recorded",
439
+ },
440
+ ctx,
441
+ );
442
+
443
+ // Reflect per-line fulfillment progress on the order line items
444
+ for (const req of input.lineItems) {
445
+ const line = linesById.get(req.orderLineItemId)!;
446
+ const total = (alreadyFulfilled.get(req.orderLineItemId) ?? 0) + req.quantity;
447
+ await this.deps.ordersRepository.updateLineItem(
448
+ req.orderLineItemId,
449
+ { fulfillmentStatus: total >= line.quantity ? "fulfilled" : "partial" },
450
+ ctx,
451
+ );
452
+ }
453
+
454
+ const record: FulfillmentRecord = {
455
+ ...toServiceRecord(
456
+ created,
457
+ input.lineItems.map((li) => ({
458
+ id: "",
459
+ fulfillmentId: created.id,
460
+ orderLineItemId: li.orderLineItemId,
461
+ quantity: li.quantity,
462
+ })),
463
+ ),
464
+ lineItems: input.lineItems.map((li) => ({
465
+ id: li.orderLineItemId,
466
+ title: linesById.get(li.orderLineItemId)?.title ?? "",
467
+ quantity: li.quantity,
468
+ })),
469
+ };
470
+
471
+ const afterHooks = this.deps.hooks.resolve(
472
+ "fulfillment.afterCreate",
473
+ ) as AfterHook<FulfillmentRecord>[];
474
+ const hookCtx: HookContext = createHookContext({
475
+ actor: actor ?? ctx?.actor ?? null,
476
+ tx: ctx?.tx ?? null,
477
+ logger: createLogger("fulfillment"),
478
+ services: this.deps.services,
479
+ context: { moduleName: "fulfillment" },
480
+ database: { db: this.deps.database.db as PluginDb },
481
+ });
482
+ await runAfterHooks(afterHooks, null, record, "create", hookCtx);
483
+
484
+ return Ok(record);
485
+ }
486
+
349
487
  async getByOrderId(
350
488
  orderId: string,
351
489
  ctx?: TxContext,