@open-mercato/core 0.6.8-develop.6998.1.d2bc46c56c → 0.6.8-develop.7001.1.03608ab252

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 (651) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/warranty_claim/index.js +105 -0
  3. package/dist/generated/entities/warranty_claim/index.js.map +7 -0
  4. package/dist/generated/entities/warranty_claim_event/index.js +25 -0
  5. package/dist/generated/entities/warranty_claim_event/index.js.map +7 -0
  6. package/dist/generated/entities/warranty_claim_line/index.js +77 -0
  7. package/dist/generated/entities/warranty_claim_line/index.js.map +7 -0
  8. package/dist/generated/entities/warranty_claim_registration/index.js +43 -0
  9. package/dist/generated/entities/warranty_claim_registration/index.js.map +7 -0
  10. package/dist/generated/entities/warranty_claim_sequence/index.js +17 -0
  11. package/dist/generated/entities/warranty_claim_sequence/index.js.map +7 -0
  12. package/dist/generated/entities/warranty_claim_settings/index.js +43 -0
  13. package/dist/generated/entities/warranty_claim_settings/index.js.map +7 -0
  14. package/dist/generated/entities/warranty_claim_sla_signal/index.js +25 -0
  15. package/dist/generated/entities/warranty_claim_sla_signal/index.js.map +7 -0
  16. package/dist/generated/entities/warranty_troubleshooting_guide/index.js +25 -0
  17. package/dist/generated/entities/warranty_troubleshooting_guide/index.js.map +7 -0
  18. package/dist/generated/entities/warranty_vendor_policy/index.js +31 -0
  19. package/dist/generated/entities/warranty_vendor_policy/index.js.map +7 -0
  20. package/dist/generated/entities.ids.generated.js +12 -0
  21. package/dist/generated/entities.ids.generated.js.map +2 -2
  22. package/dist/generated/entity-fields-registry.js +200 -0
  23. package/dist/generated/entity-fields-registry.js.map +2 -2
  24. package/dist/helpers/integration/api.js +3 -2
  25. package/dist/helpers/integration/api.js.map +2 -2
  26. package/dist/helpers/integration/salesFixtures.js +13 -6
  27. package/dist/helpers/integration/salesFixtures.js.map +2 -2
  28. package/dist/modules/attachments/di.js +18 -0
  29. package/dist/modules/attachments/di.js.map +2 -2
  30. package/dist/modules/attachments/fields/attachment.js +186 -26
  31. package/dist/modules/attachments/fields/attachment.js.map +2 -2
  32. package/dist/modules/attachments/lib/scoped-upload-service.js +202 -0
  33. package/dist/modules/attachments/lib/scoped-upload-service.js.map +7 -0
  34. package/dist/modules/attachments/lib/target-access-service.js +39 -0
  35. package/dist/modules/attachments/lib/target-access-service.js.map +7 -0
  36. package/dist/modules/auth/api/logout.js +37 -10
  37. package/dist/modules/auth/api/logout.js.map +2 -2
  38. package/dist/modules/auth/api/reset/confirm.js +7 -0
  39. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  40. package/dist/modules/auth/api/reset.js +7 -0
  41. package/dist/modules/auth/api/reset.js.map +2 -2
  42. package/dist/modules/auth/commands/users.js +13 -0
  43. package/dist/modules/auth/commands/users.js.map +2 -2
  44. package/dist/modules/auth/events.js +15 -0
  45. package/dist/modules/auth/events.js.map +2 -2
  46. package/dist/modules/customers/api/companies/route.js +1 -0
  47. package/dist/modules/customers/api/companies/route.js.map +2 -2
  48. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +2 -2
  49. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  50. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +2 -2
  51. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  52. package/dist/modules/customers/components/detail/assignableStaff.js +6 -69
  53. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  54. package/dist/modules/customers/lib/assignableStaff.js +76 -0
  55. package/dist/modules/customers/lib/assignableStaff.js.map +7 -0
  56. package/dist/modules/data_sync/lib/batch-stream.js +56 -0
  57. package/dist/modules/data_sync/lib/batch-stream.js.map +7 -0
  58. package/dist/modules/data_sync/lib/sync-engine.js +165 -103
  59. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  60. package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js +4 -0
  61. package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js.map +2 -2
  62. package/dist/modules/inbox_ops/api/extract/route.js +4 -0
  63. package/dist/modules/inbox_ops/api/extract/route.js.map +2 -2
  64. package/dist/modules/inbox_ops/api/webhook/inbound.js +4 -0
  65. package/dist/modules/inbox_ops/api/webhook/inbound.js.map +2 -2
  66. package/dist/modules/sales/commands/documents.js +8 -8
  67. package/dist/modules/sales/commands/documents.js.map +2 -2
  68. package/dist/modules/warranty_claims/acl.js +75 -0
  69. package/dist/modules/warranty_claims/acl.js.map +7 -0
  70. package/dist/modules/warranty_claims/ai-agents.js +161 -0
  71. package/dist/modules/warranty_claims/ai-agents.js.map +7 -0
  72. package/dist/modules/warranty_claims/ai-tools.js +591 -0
  73. package/dist/modules/warranty_claims/ai-tools.js.map +7 -0
  74. package/dist/modules/warranty_claims/api/ai/assess/route.js +304 -0
  75. package/dist/modules/warranty_claims/api/ai/assess/route.js.map +7 -0
  76. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +127 -0
  77. package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +7 -0
  78. package/dist/modules/warranty_claims/api/ai/suggest/route.js +177 -0
  79. package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +7 -0
  80. package/dist/modules/warranty_claims/api/assign/route.js +124 -0
  81. package/dist/modules/warranty_claims/api/assign/route.js.map +7 -0
  82. package/dist/modules/warranty_claims/api/assignees/route.js +97 -0
  83. package/dist/modules/warranty_claims/api/assignees/route.js.map +7 -0
  84. package/dist/modules/warranty_claims/api/credit-memo/route.js +152 -0
  85. package/dist/modules/warranty_claims/api/credit-memo/route.js.map +7 -0
  86. package/dist/modules/warranty_claims/api/entitlement/route.js +145 -0
  87. package/dist/modules/warranty_claims/api/entitlement/route.js.map +7 -0
  88. package/dist/modules/warranty_claims/api/events/route.js +199 -0
  89. package/dist/modules/warranty_claims/api/events/route.js.map +7 -0
  90. package/dist/modules/warranty_claims/api/external/claims/route.js +446 -0
  91. package/dist/modules/warranty_claims/api/external/claims/route.js.map +7 -0
  92. package/dist/modules/warranty_claims/api/lines/route.js +258 -0
  93. package/dist/modules/warranty_claims/api/lines/route.js.map +7 -0
  94. package/dist/modules/warranty_claims/api/openapi.js +27 -0
  95. package/dist/modules/warranty_claims/api/openapi.js.map +7 -0
  96. package/dist/modules/warranty_claims/api/portal/attachments/route.js +490 -0
  97. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +7 -0
  98. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js +178 -0
  99. package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js.map +7 -0
  100. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js +101 -0
  101. package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js.map +7 -0
  102. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +78 -0
  103. package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +7 -0
  104. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +81 -0
  105. package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +7 -0
  106. package/dist/modules/warranty_claims/api/portal/claims/route.js +462 -0
  107. package/dist/modules/warranty_claims/api/portal/claims/route.js.map +7 -0
  108. package/dist/modules/warranty_claims/api/portal/events/route.js +228 -0
  109. package/dist/modules/warranty_claims/api/portal/events/route.js.map +7 -0
  110. package/dist/modules/warranty_claims/api/portal/options/route.js +91 -0
  111. package/dist/modules/warranty_claims/api/portal/options/route.js.map +7 -0
  112. package/dist/modules/warranty_claims/api/portal/orders/lines/route.js +172 -0
  113. package/dist/modules/warranty_claims/api/portal/orders/lines/route.js.map +7 -0
  114. package/dist/modules/warranty_claims/api/portal/orders/route.js +151 -0
  115. package/dist/modules/warranty_claims/api/portal/orders/route.js.map +7 -0
  116. package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js +121 -0
  117. package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js.map +7 -0
  118. package/dist/modules/warranty_claims/api/receiving/route.js +176 -0
  119. package/dist/modules/warranty_claims/api/receiving/route.js.map +7 -0
  120. package/dist/modules/warranty_claims/api/registrations/route.js +382 -0
  121. package/dist/modules/warranty_claims/api/registrations/route.js.map +7 -0
  122. package/dist/modules/warranty_claims/api/replacement-order/route.js +150 -0
  123. package/dist/modules/warranty_claims/api/replacement-order/route.js.map +7 -0
  124. package/dist/modules/warranty_claims/api/return-label/route.js +235 -0
  125. package/dist/modules/warranty_claims/api/return-label/route.js.map +7 -0
  126. package/dist/modules/warranty_claims/api/risk/route.js +101 -0
  127. package/dist/modules/warranty_claims/api/risk/route.js.map +7 -0
  128. package/dist/modules/warranty_claims/api/route.js +482 -0
  129. package/dist/modules/warranty_claims/api/route.js.map +7 -0
  130. package/dist/modules/warranty_claims/api/sales-return/route.js +151 -0
  131. package/dist/modules/warranty_claims/api/sales-return/route.js.map +7 -0
  132. package/dist/modules/warranty_claims/api/settings-general/route.js +195 -0
  133. package/dist/modules/warranty_claims/api/settings-general/route.js.map +7 -0
  134. package/dist/modules/warranty_claims/api/stats/route.js +199 -0
  135. package/dist/modules/warranty_claims/api/stats/route.js.map +7 -0
  136. package/dist/modules/warranty_claims/api/submit/route.js +120 -0
  137. package/dist/modules/warranty_claims/api/submit/route.js.map +7 -0
  138. package/dist/modules/warranty_claims/api/transition/route.js +126 -0
  139. package/dist/modules/warranty_claims/api/transition/route.js.map +7 -0
  140. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js +261 -0
  141. package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js.map +7 -0
  142. package/dist/modules/warranty_claims/api/vendor-policies/route.js +311 -0
  143. package/dist/modules/warranty_claims/api/vendor-policies/route.js.map +7 -0
  144. package/dist/modules/warranty_claims/api/vendor-recovery/route.js +130 -0
  145. package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +7 -0
  146. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +153 -0
  147. package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +7 -0
  148. package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js +268 -0
  149. package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js.map +7 -0
  150. package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js +54 -0
  151. package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js.map +7 -0
  152. package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js +87 -0
  153. package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js.map +7 -0
  154. package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js +120 -0
  155. package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js.map +7 -0
  156. package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js +135 -0
  157. package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js.map +7 -0
  158. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +218 -0
  159. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +7 -0
  160. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +231 -0
  161. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +7 -0
  162. package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js +82 -0
  163. package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js.map +7 -0
  164. package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js +136 -0
  165. package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js.map +7 -0
  166. package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js +41 -0
  167. package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js.map +7 -0
  168. package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js +45 -0
  169. package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js.map +7 -0
  170. package/dist/modules/warranty_claims/backend/components/claimSla.js +92 -0
  171. package/dist/modules/warranty_claims/backend/components/claimSla.js.map +7 -0
  172. package/dist/modules/warranty_claims/backend/components/orderLookup.js +81 -0
  173. package/dist/modules/warranty_claims/backend/components/orderLookup.js.map +7 -0
  174. package/dist/modules/warranty_claims/backend/components/productLookup.js +317 -0
  175. package/dist/modules/warranty_claims/backend/components/productLookup.js.map +7 -0
  176. package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js +61 -0
  177. package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js.map +7 -0
  178. package/dist/modules/warranty_claims/backend/page.js +1088 -0
  179. package/dist/modules/warranty_claims/backend/page.js.map +7 -0
  180. package/dist/modules/warranty_claims/backend/page.meta.js +16 -0
  181. package/dist/modules/warranty_claims/backend/page.meta.js.map +7 -0
  182. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js +427 -0
  183. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js.map +7 -0
  184. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js +16 -0
  185. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js.map +7 -0
  186. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +2101 -0
  187. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +7 -0
  188. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js +16 -0
  189. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js.map +7 -0
  190. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js +1249 -0
  191. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js.map +7 -0
  192. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js +16 -0
  193. package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js.map +7 -0
  194. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js +110 -0
  195. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js.map +7 -0
  196. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js +17 -0
  197. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js.map +7 -0
  198. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js +49 -0
  199. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js.map +7 -0
  200. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js +17 -0
  201. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js.map +7 -0
  202. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js +485 -0
  203. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js.map +7 -0
  204. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js +19 -0
  205. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js.map +7 -0
  206. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js +253 -0
  207. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js.map +7 -0
  208. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +1565 -0
  209. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +7 -0
  210. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js +19 -0
  211. package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js.map +7 -0
  212. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js +472 -0
  213. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js.map +7 -0
  214. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js +115 -0
  215. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js.map +7 -0
  216. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js +19 -0
  217. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js.map +7 -0
  218. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js +54 -0
  219. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js.map +7 -0
  220. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js +19 -0
  221. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js.map +7 -0
  222. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js +414 -0
  223. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js.map +7 -0
  224. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js +21 -0
  225. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js.map +7 -0
  226. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js +171 -0
  227. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js.map +7 -0
  228. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js +111 -0
  229. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js.map +7 -0
  230. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js +19 -0
  231. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js.map +7 -0
  232. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js +48 -0
  233. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js.map +7 -0
  234. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js +19 -0
  235. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js.map +7 -0
  236. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js +429 -0
  237. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js.map +7 -0
  238. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js +21 -0
  239. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js.map +7 -0
  240. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js +182 -0
  241. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js.map +7 -0
  242. package/dist/modules/warranty_claims/ce.js +48 -0
  243. package/dist/modules/warranty_claims/ce.js.map +7 -0
  244. package/dist/modules/warranty_claims/commands/claim-lines.js +911 -0
  245. package/dist/modules/warranty_claims/commands/claim-lines.js.map +7 -0
  246. package/dist/modules/warranty_claims/commands/claims.js +2577 -0
  247. package/dist/modules/warranty_claims/commands/claims.js.map +7 -0
  248. package/dist/modules/warranty_claims/commands/index.js +4 -0
  249. package/dist/modules/warranty_claims/commands/index.js.map +7 -0
  250. package/dist/modules/warranty_claims/commands/settings.js +169 -0
  251. package/dist/modules/warranty_claims/commands/settings.js.map +7 -0
  252. package/dist/modules/warranty_claims/commands/shared.js +162 -0
  253. package/dist/modules/warranty_claims/commands/shared.js.map +7 -0
  254. package/dist/modules/warranty_claims/data/constants.js +35 -0
  255. package/dist/modules/warranty_claims/data/constants.js.map +7 -0
  256. package/dist/modules/warranty_claims/data/enrichers.js +97 -0
  257. package/dist/modules/warranty_claims/data/enrichers.js.map +7 -0
  258. package/dist/modules/warranty_claims/data/entities.js +731 -0
  259. package/dist/modules/warranty_claims/data/entities.js.map +7 -0
  260. package/dist/modules/warranty_claims/data/validators.js +488 -0
  261. package/dist/modules/warranty_claims/data/validators.js.map +7 -0
  262. package/dist/modules/warranty_claims/di.js +29 -0
  263. package/dist/modules/warranty_claims/di.js.map +7 -0
  264. package/dist/modules/warranty_claims/encryption.js +28 -0
  265. package/dist/modules/warranty_claims/encryption.js.map +7 -0
  266. package/dist/modules/warranty_claims/events.js +81 -0
  267. package/dist/modules/warranty_claims/events.js.map +7 -0
  268. package/dist/modules/warranty_claims/extension-points.js +19 -0
  269. package/dist/modules/warranty_claims/extension-points.js.map +7 -0
  270. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +825 -0
  271. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +7 -0
  272. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js +11 -0
  273. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js.map +7 -0
  274. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +1139 -0
  275. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +7 -0
  276. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js +11 -0
  277. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js.map +7 -0
  278. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +253 -0
  279. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +7 -0
  280. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js +18 -0
  281. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js.map +7 -0
  282. package/dist/modules/warranty_claims/index.js +15 -0
  283. package/dist/modules/warranty_claims/index.js.map +7 -0
  284. package/dist/modules/warranty_claims/lib/aiAssist.js +496 -0
  285. package/dist/modules/warranty_claims/lib/aiAssist.js.map +7 -0
  286. package/dist/modules/warranty_claims/lib/apiIdsFilter.js +11 -0
  287. package/dist/modules/warranty_claims/lib/apiIdsFilter.js.map +7 -0
  288. package/dist/modules/warranty_claims/lib/assigneeNames.js +153 -0
  289. package/dist/modules/warranty_claims/lib/assigneeNames.js.map +7 -0
  290. package/dist/modules/warranty_claims/lib/attachmentVisibility.js +9 -0
  291. package/dist/modules/warranty_claims/lib/attachmentVisibility.js.map +7 -0
  292. package/dist/modules/warranty_claims/lib/bulkFeedback.js +8 -0
  293. package/dist/modules/warranty_claims/lib/bulkFeedback.js.map +7 -0
  294. package/dist/modules/warranty_claims/lib/businessHours.js +250 -0
  295. package/dist/modules/warranty_claims/lib/businessHours.js.map +7 -0
  296. package/dist/modules/warranty_claims/lib/businessHoursForm.js +146 -0
  297. package/dist/modules/warranty_claims/lib/businessHoursForm.js.map +7 -0
  298. package/dist/modules/warranty_claims/lib/claimTypeConfig.js +39 -0
  299. package/dist/modules/warranty_claims/lib/claimTypeConfig.js.map +7 -0
  300. package/dist/modules/warranty_claims/lib/decryptionSafety.js +17 -0
  301. package/dist/modules/warranty_claims/lib/decryptionSafety.js.map +7 -0
  302. package/dist/modules/warranty_claims/lib/deskFilters.js +88 -0
  303. package/dist/modules/warranty_claims/lib/deskFilters.js.map +7 -0
  304. package/dist/modules/warranty_claims/lib/dictionaries.js +157 -0
  305. package/dist/modules/warranty_claims/lib/dictionaries.js.map +7 -0
  306. package/dist/modules/warranty_claims/lib/dictionaryLabels.js +12 -0
  307. package/dist/modules/warranty_claims/lib/dictionaryLabels.js.map +7 -0
  308. package/dist/modules/warranty_claims/lib/emailIntake.js +87 -0
  309. package/dist/modules/warranty_claims/lib/emailIntake.js.map +7 -0
  310. package/dist/modules/warranty_claims/lib/escalation.js +58 -0
  311. package/dist/modules/warranty_claims/lib/escalation.js.map +7 -0
  312. package/dist/modules/warranty_claims/lib/externalIntake.js +233 -0
  313. package/dist/modules/warranty_claims/lib/externalIntake.js.map +7 -0
  314. package/dist/modules/warranty_claims/lib/grading.js +21 -0
  315. package/dist/modules/warranty_claims/lib/grading.js.map +7 -0
  316. package/dist/modules/warranty_claims/lib/listSegments.js +18 -0
  317. package/dist/modules/warranty_claims/lib/listSegments.js.map +7 -0
  318. package/dist/modules/warranty_claims/lib/orderOwnership.js +25 -0
  319. package/dist/modules/warranty_claims/lib/orderOwnership.js.map +7 -0
  320. package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js +11 -0
  321. package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js.map +7 -0
  322. package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js +108 -0
  323. package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js.map +7 -0
  324. package/dist/modules/warranty_claims/lib/portalClaimAccess.js +26 -0
  325. package/dist/modules/warranty_claims/lib/portalClaimAccess.js.map +7 -0
  326. package/dist/modules/warranty_claims/lib/quantity.js +19 -0
  327. package/dist/modules/warranty_claims/lib/quantity.js.map +7 -0
  328. package/dist/modules/warranty_claims/lib/risk.js +204 -0
  329. package/dist/modules/warranty_claims/lib/risk.js.map +7 -0
  330. package/dist/modules/warranty_claims/lib/settings.js +55 -0
  331. package/dist/modules/warranty_claims/lib/settings.js.map +7 -0
  332. package/dist/modules/warranty_claims/lib/stateMachine.js +88 -0
  333. package/dist/modules/warranty_claims/lib/stateMachine.js.map +7 -0
  334. package/dist/modules/warranty_claims/lib/timelineFilters.js +16 -0
  335. package/dist/modules/warranty_claims/lib/timelineFilters.js.map +7 -0
  336. package/dist/modules/warranty_claims/lib/triage.js +241 -0
  337. package/dist/modules/warranty_claims/lib/triage.js.map +7 -0
  338. package/dist/modules/warranty_claims/lib/troubleshooting.js +111 -0
  339. package/dist/modules/warranty_claims/lib/troubleshooting.js.map +7 -0
  340. package/dist/modules/warranty_claims/lib/tryResolve.js +11 -0
  341. package/dist/modules/warranty_claims/lib/tryResolve.js.map +7 -0
  342. package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js +101 -0
  343. package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js.map +7 -0
  344. package/dist/modules/warranty_claims/lib/warrantyPreview.js +18 -0
  345. package/dist/modules/warranty_claims/lib/warrantyPreview.js.map +7 -0
  346. package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js +32 -0
  347. package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js.map +7 -0
  348. package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js +16 -0
  349. package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js.map +7 -0
  350. package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js +17 -0
  351. package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js.map +7 -0
  352. package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js +29 -0
  353. package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js.map +7 -0
  354. package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js +25 -0
  355. package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js.map +7 -0
  356. package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js +13 -0
  357. package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js.map +7 -0
  358. package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js +15 -0
  359. package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js.map +7 -0
  360. package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js +13 -0
  361. package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js.map +7 -0
  362. package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js +13 -0
  363. package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js.map +7 -0
  364. package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js +12 -0
  365. package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js.map +7 -0
  366. package/dist/modules/warranty_claims/notifications.client.js +67 -0
  367. package/dist/modules/warranty_claims/notifications.client.js.map +7 -0
  368. package/dist/modules/warranty_claims/notifications.js +103 -0
  369. package/dist/modules/warranty_claims/notifications.js.map +7 -0
  370. package/dist/modules/warranty_claims/search.js +174 -0
  371. package/dist/modules/warranty_claims/search.js.map +7 -0
  372. package/dist/modules/warranty_claims/services/adjudicationEvaluator.js +202 -0
  373. package/dist/modules/warranty_claims/services/adjudicationEvaluator.js.map +7 -0
  374. package/dist/modules/warranty_claims/services/claimNumberGenerator.js +58 -0
  375. package/dist/modules/warranty_claims/services/claimNumberGenerator.js.map +7 -0
  376. package/dist/modules/warranty_claims/services/entitlementResolver.js +69 -0
  377. package/dist/modules/warranty_claims/services/entitlementResolver.js.map +7 -0
  378. package/dist/modules/warranty_claims/services/returnLabelProvider.js +14 -0
  379. package/dist/modules/warranty_claims/services/returnLabelProvider.js.map +7 -0
  380. package/dist/modules/warranty_claims/setup.js +121 -0
  381. package/dist/modules/warranty_claims/setup.js.map +7 -0
  382. package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js +119 -0
  383. package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js.map +7 -0
  384. package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js +47 -0
  385. package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js.map +7 -0
  386. package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js +60 -0
  387. package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js.map +7 -0
  388. package/dist/modules/warranty_claims/subscribers/claim-status-notification.js +83 -0
  389. package/dist/modules/warranty_claims/subscribers/claim-status-notification.js.map +7 -0
  390. package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js +46 -0
  391. package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js.map +7 -0
  392. package/dist/modules/warranty_claims/subscribers/email-to-claim.js +135 -0
  393. package/dist/modules/warranty_claims/subscribers/email-to-claim.js.map +7 -0
  394. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +164 -0
  395. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +7 -0
  396. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js +24 -0
  397. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js.map +7 -0
  398. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js +26 -0
  399. package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js.map +7 -0
  400. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js +15 -0
  401. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js.map +7 -0
  402. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js +197 -0
  403. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js.map +7 -0
  404. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js +28 -0
  405. package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js.map +7 -0
  406. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js +418 -0
  407. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js.map +7 -0
  408. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js +18 -0
  409. package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js.map +7 -0
  410. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js +17 -0
  411. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js.map +7 -0
  412. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js +146 -0
  413. package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js.map +7 -0
  414. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js +17 -0
  415. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js.map +7 -0
  416. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js +147 -0
  417. package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js.map +7 -0
  418. package/dist/modules/warranty_claims/widgets/injection-table.js +38 -0
  419. package/dist/modules/warranty_claims/widgets/injection-table.js.map +7 -0
  420. package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js +223 -0
  421. package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js.map +7 -0
  422. package/dist/modules/warranty_claims/widgets/notifications/index.js +5 -0
  423. package/dist/modules/warranty_claims/widgets/notifications/index.js.map +7 -0
  424. package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js +334 -0
  425. package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js.map +7 -0
  426. package/generated/entities/warranty_claim/index.ts +51 -0
  427. package/generated/entities/warranty_claim_event/index.ts +11 -0
  428. package/generated/entities/warranty_claim_line/index.ts +37 -0
  429. package/generated/entities/warranty_claim_registration/index.ts +20 -0
  430. package/generated/entities/warranty_claim_sequence/index.ts +7 -0
  431. package/generated/entities/warranty_claim_settings/index.ts +20 -0
  432. package/generated/entities/warranty_claim_sla_signal/index.ts +11 -0
  433. package/generated/entities/warranty_troubleshooting_guide/index.ts +11 -0
  434. package/generated/entities/warranty_vendor_policy/index.ts +14 -0
  435. package/generated/entities.ids.generated.ts +12 -0
  436. package/generated/entity-fields-registry.ts +200 -0
  437. package/package.json +7 -7
  438. package/src/helpers/integration/api.ts +10 -3
  439. package/src/helpers/integration/salesFixtures.ts +17 -7
  440. package/src/modules/attachments/di.ts +30 -1
  441. package/src/modules/attachments/fields/attachment.tsx +215 -23
  442. package/src/modules/attachments/i18n/de.json +5 -0
  443. package/src/modules/attachments/i18n/en.json +5 -0
  444. package/src/modules/attachments/i18n/es.json +5 -0
  445. package/src/modules/attachments/i18n/ko.json +5 -0
  446. package/src/modules/attachments/i18n/pl.json +5 -0
  447. package/src/modules/attachments/lib/scoped-upload-service.ts +257 -0
  448. package/src/modules/attachments/lib/target-access-service.ts +57 -0
  449. package/src/modules/auth/api/logout.ts +46 -10
  450. package/src/modules/auth/api/reset/confirm.ts +7 -0
  451. package/src/modules/auth/api/reset.ts +7 -0
  452. package/src/modules/auth/commands/users.ts +22 -0
  453. package/src/modules/auth/events.ts +15 -0
  454. package/src/modules/customers/api/companies/route.ts +1 -0
  455. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +4 -2
  456. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +4 -2
  457. package/src/modules/customers/components/detail/assignableStaff.ts +14 -177
  458. package/src/modules/customers/lib/assignableStaff.ts +177 -0
  459. package/src/modules/data_sync/AGENTS.md +3 -0
  460. package/src/modules/data_sync/lib/batch-stream.ts +117 -0
  461. package/src/modules/data_sync/lib/sync-engine.ts +190 -115
  462. package/src/modules/inbox_ops/api/emails/[id]/reprocess/route.ts +4 -0
  463. package/src/modules/inbox_ops/api/extract/route.ts +4 -0
  464. package/src/modules/inbox_ops/api/webhook/inbound.ts +4 -0
  465. package/src/modules/sales/commands/documents.ts +16 -8
  466. package/src/modules/warranty_claims/AGENTS.md +49 -0
  467. package/src/modules/warranty_claims/acl.ts +71 -0
  468. package/src/modules/warranty_claims/ai-agents.ts +189 -0
  469. package/src/modules/warranty_claims/ai-tools.ts +711 -0
  470. package/src/modules/warranty_claims/api/ai/assess/route.ts +371 -0
  471. package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +149 -0
  472. package/src/modules/warranty_claims/api/ai/suggest/route.ts +190 -0
  473. package/src/modules/warranty_claims/api/assign/route.ts +142 -0
  474. package/src/modules/warranty_claims/api/assignees/route.ts +102 -0
  475. package/src/modules/warranty_claims/api/credit-memo/route.ts +191 -0
  476. package/src/modules/warranty_claims/api/entitlement/route.ts +224 -0
  477. package/src/modules/warranty_claims/api/events/route.ts +226 -0
  478. package/src/modules/warranty_claims/api/external/claims/route.ts +528 -0
  479. package/src/modules/warranty_claims/api/lines/route.ts +275 -0
  480. package/src/modules/warranty_claims/api/openapi.ts +28 -0
  481. package/src/modules/warranty_claims/api/portal/attachments/route.ts +547 -0
  482. package/src/modules/warranty_claims/api/portal/claims/[id]/route.ts +195 -0
  483. package/src/modules/warranty_claims/api/portal/claims/[id]/shared.ts +125 -0
  484. package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +82 -0
  485. package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +87 -0
  486. package/src/modules/warranty_claims/api/portal/claims/route.ts +579 -0
  487. package/src/modules/warranty_claims/api/portal/events/route.ts +260 -0
  488. package/src/modules/warranty_claims/api/portal/options/route.ts +113 -0
  489. package/src/modules/warranty_claims/api/portal/orders/lines/route.ts +258 -0
  490. package/src/modules/warranty_claims/api/portal/orders/route.ts +206 -0
  491. package/src/modules/warranty_claims/api/portal/troubleshooting/route.ts +151 -0
  492. package/src/modules/warranty_claims/api/receiving/route.ts +215 -0
  493. package/src/modules/warranty_claims/api/registrations/route.ts +417 -0
  494. package/src/modules/warranty_claims/api/replacement-order/route.ts +188 -0
  495. package/src/modules/warranty_claims/api/return-label/route.ts +291 -0
  496. package/src/modules/warranty_claims/api/risk/route.ts +112 -0
  497. package/src/modules/warranty_claims/api/route.ts +543 -0
  498. package/src/modules/warranty_claims/api/sales-return/route.ts +183 -0
  499. package/src/modules/warranty_claims/api/settings-general/route.ts +230 -0
  500. package/src/modules/warranty_claims/api/stats/route.ts +306 -0
  501. package/src/modules/warranty_claims/api/submit/route.ts +146 -0
  502. package/src/modules/warranty_claims/api/transition/route.ts +144 -0
  503. package/src/modules/warranty_claims/api/troubleshooting-guides/route.ts +295 -0
  504. package/src/modules/warranty_claims/api/vendor-policies/route.ts +351 -0
  505. package/src/modules/warranty_claims/api/vendor-recovery/route.ts +148 -0
  506. package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +167 -0
  507. package/src/modules/warranty_claims/backend/components/AiAssessButtons.tsx +389 -0
  508. package/src/modules/warranty_claims/backend/components/ClaimStageProgress.tsx +65 -0
  509. package/src/modules/warranty_claims/backend/components/ClaimStatusBadge.tsx +137 -0
  510. package/src/modules/warranty_claims/backend/components/ClaimsKpiStrip.tsx +169 -0
  511. package/src/modules/warranty_claims/backend/components/EntitlementLookupBadge.tsx +203 -0
  512. package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +300 -0
  513. package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +292 -0
  514. package/src/modules/warranty_claims/backend/components/TroubleshootingWalker.tsx +125 -0
  515. package/src/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.tsx +217 -0
  516. package/src/modules/warranty_claims/backend/components/WarrantyWorkspace.tsx +66 -0
  517. package/src/modules/warranty_claims/backend/components/claimReasonOptions.ts +57 -0
  518. package/src/modules/warranty_claims/backend/components/claimSla.tsx +127 -0
  519. package/src/modules/warranty_claims/backend/components/orderLookup.ts +109 -0
  520. package/src/modules/warranty_claims/backend/components/productLookup.tsx +433 -0
  521. package/src/modules/warranty_claims/backend/components/useUserDisplayNames.ts +66 -0
  522. package/src/modules/warranty_claims/backend/page.meta.ts +12 -0
  523. package/src/modules/warranty_claims/backend/page.tsx +1302 -0
  524. package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.ts +12 -0
  525. package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.tsx +531 -0
  526. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.ts +12 -0
  527. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +2599 -0
  528. package/src/modules/warranty_claims/backend/warranty_claims/create/page.meta.ts +12 -0
  529. package/src/modules/warranty_claims/backend/warranty_claims/create/page.tsx +1533 -0
  530. package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.ts +13 -0
  531. package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.tsx +136 -0
  532. package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.ts +13 -0
  533. package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.tsx +52 -0
  534. package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.ts +15 -0
  535. package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.tsx +555 -0
  536. package/src/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.tsx +295 -0
  537. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.meta.ts +15 -0
  538. package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +1903 -0
  539. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.tsx +550 -0
  540. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.ts +15 -0
  541. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.tsx +141 -0
  542. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.ts +15 -0
  543. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.tsx +57 -0
  544. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.ts +17 -0
  545. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.tsx +477 -0
  546. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.tsx +214 -0
  547. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.ts +15 -0
  548. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.tsx +137 -0
  549. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.ts +15 -0
  550. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.tsx +51 -0
  551. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.ts +17 -0
  552. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.tsx +506 -0
  553. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.tsx +220 -0
  554. package/src/modules/warranty_claims/ce.ts +44 -0
  555. package/src/modules/warranty_claims/commands/claim-lines.ts +1082 -0
  556. package/src/modules/warranty_claims/commands/claims.ts +3364 -0
  557. package/src/modules/warranty_claims/commands/index.ts +3 -0
  558. package/src/modules/warranty_claims/commands/settings.ts +198 -0
  559. package/src/modules/warranty_claims/commands/shared.ts +212 -0
  560. package/src/modules/warranty_claims/data/constants.ts +37 -0
  561. package/src/modules/warranty_claims/data/enrichers.ts +165 -0
  562. package/src/modules/warranty_claims/data/entities.ts +663 -0
  563. package/src/modules/warranty_claims/data/validators.ts +634 -0
  564. package/src/modules/warranty_claims/di.ts +34 -0
  565. package/src/modules/warranty_claims/encryption.ts +26 -0
  566. package/src/modules/warranty_claims/events.ts +81 -0
  567. package/src/modules/warranty_claims/extension-points.ts +16 -0
  568. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.ts +9 -0
  569. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +1037 -0
  570. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.ts +9 -0
  571. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +1409 -0
  572. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.ts +16 -0
  573. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +304 -0
  574. package/src/modules/warranty_claims/i18n/de.json +1270 -0
  575. package/src/modules/warranty_claims/i18n/en.json +1270 -0
  576. package/src/modules/warranty_claims/i18n/es.json +1270 -0
  577. package/src/modules/warranty_claims/i18n/ko.json +1270 -0
  578. package/src/modules/warranty_claims/i18n/pl.json +1270 -0
  579. package/src/modules/warranty_claims/index.ts +13 -0
  580. package/src/modules/warranty_claims/lib/aiAssist.ts +677 -0
  581. package/src/modules/warranty_claims/lib/apiIdsFilter.ts +12 -0
  582. package/src/modules/warranty_claims/lib/assigneeNames.ts +184 -0
  583. package/src/modules/warranty_claims/lib/attachmentVisibility.ts +5 -0
  584. package/src/modules/warranty_claims/lib/bulkFeedback.ts +6 -0
  585. package/src/modules/warranty_claims/lib/businessHours.ts +333 -0
  586. package/src/modules/warranty_claims/lib/businessHoursForm.ts +206 -0
  587. package/src/modules/warranty_claims/lib/claimTypeConfig.ts +49 -0
  588. package/src/modules/warranty_claims/lib/decryptionSafety.ts +19 -0
  589. package/src/modules/warranty_claims/lib/deskFilters.ts +131 -0
  590. package/src/modules/warranty_claims/lib/dictionaries.ts +212 -0
  591. package/src/modules/warranty_claims/lib/dictionaryLabels.ts +16 -0
  592. package/src/modules/warranty_claims/lib/emailIntake.ts +123 -0
  593. package/src/modules/warranty_claims/lib/escalation.ts +83 -0
  594. package/src/modules/warranty_claims/lib/externalIntake.ts +362 -0
  595. package/src/modules/warranty_claims/lib/grading.ts +23 -0
  596. package/src/modules/warranty_claims/lib/listSegments.ts +19 -0
  597. package/src/modules/warranty_claims/lib/orderOwnership.ts +58 -0
  598. package/src/modules/warranty_claims/lib/portalAttachmentUpload.ts +11 -0
  599. package/src/modules/warranty_claims/lib/portalAttachmentValidation.ts +115 -0
  600. package/src/modules/warranty_claims/lib/portalClaimAccess.ts +37 -0
  601. package/src/modules/warranty_claims/lib/quantity.ts +15 -0
  602. package/src/modules/warranty_claims/lib/risk.ts +371 -0
  603. package/src/modules/warranty_claims/lib/settings.ts +76 -0
  604. package/src/modules/warranty_claims/lib/stateMachine.ts +130 -0
  605. package/src/modules/warranty_claims/lib/timelineFilters.ts +22 -0
  606. package/src/modules/warranty_claims/lib/triage.ts +366 -0
  607. package/src/modules/warranty_claims/lib/troubleshooting.ts +157 -0
  608. package/src/modules/warranty_claims/lib/tryResolve.ts +7 -0
  609. package/src/modules/warranty_claims/lib/vendorPolicyRecovery.ts +182 -0
  610. package/src/modules/warranty_claims/lib/warrantyPreview.ts +20 -0
  611. package/src/modules/warranty_claims/migrations/.snapshot-open-mercato.json +3525 -0
  612. package/src/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.ts +38 -0
  613. package/src/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.ts +17 -0
  614. package/src/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.ts +17 -0
  615. package/src/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.ts +37 -0
  616. package/src/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.ts +25 -0
  617. package/src/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.ts +13 -0
  618. package/src/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.ts +15 -0
  619. package/src/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.ts +13 -0
  620. package/src/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.ts +13 -0
  621. package/src/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.ts +11 -0
  622. package/src/modules/warranty_claims/notifications.client.ts +78 -0
  623. package/src/modules/warranty_claims/notifications.ts +101 -0
  624. package/src/modules/warranty_claims/search.ts +199 -0
  625. package/src/modules/warranty_claims/services/adjudicationEvaluator.ts +344 -0
  626. package/src/modules/warranty_claims/services/claimNumberGenerator.ts +69 -0
  627. package/src/modules/warranty_claims/services/entitlementResolver.ts +102 -0
  628. package/src/modules/warranty_claims/services/returnLabelProvider.ts +30 -0
  629. package/src/modules/warranty_claims/setup.ts +154 -0
  630. package/src/modules/warranty_claims/subscribers/auto-vendor-recovery.ts +140 -0
  631. package/src/modules/warranty_claims/subscribers/claim-assigned-notification.ts +54 -0
  632. package/src/modules/warranty_claims/subscribers/claim-customer-reply-notification.ts +68 -0
  633. package/src/modules/warranty_claims/subscribers/claim-status-notification.ts +100 -0
  634. package/src/modules/warranty_claims/subscribers/claim-submitted-notification.ts +53 -0
  635. package/src/modules/warranty_claims/subscribers/email-to-claim.ts +175 -0
  636. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +226 -0
  637. package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.ts +28 -0
  638. package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.ts +31 -0
  639. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/config.ts +15 -0
  640. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.tsx +237 -0
  641. package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.ts +27 -0
  642. package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.tsx +527 -0
  643. package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.ts +37 -0
  644. package/src/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.ts +21 -0
  645. package/src/modules/warranty_claims/widgets/injection/customer-claims-tab.tsx +210 -0
  646. package/src/modules/warranty_claims/widgets/injection/order-claims-tab/widget.ts +21 -0
  647. package/src/modules/warranty_claims/widgets/injection/order-claims-tab.tsx +210 -0
  648. package/src/modules/warranty_claims/widgets/injection-table.ts +36 -0
  649. package/src/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.tsx +253 -0
  650. package/src/modules/warranty_claims/widgets/notifications/index.ts +1 -0
  651. package/src/modules/warranty_claims/workers/sla-escalation-sweep.ts +450 -0
@@ -0,0 +1,3364 @@
1
+ import { randomUUID } from 'crypto'
2
+ import { LockMode } from '@mikro-orm/core'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { z } from 'zod'
5
+ import { registerCommand, type CommandBus, type CommandHandler, type CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
7
+ import { emitCrudSideEffects, emitCrudUndoSideEffects } from '@open-mercato/shared/lib/commands/helpers'
8
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
11
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
+ import { readSafeDecryptedString } from '../lib/decryptionSafety'
13
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
14
+ import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
15
+ import { E } from '#generated/entities.ids.generated'
16
+ import {
17
+ WarrantyClaim,
18
+ WarrantyClaimLine,
19
+ } from '../data/entities'
20
+ import {
21
+ CLAIM_FULFILLMENT_UPDATE_FIELDS,
22
+ CLAIM_INTAKE_UPDATE_FIELDS,
23
+ claimCreateSchema,
24
+ claimUpdateSchema,
25
+ assignClaimInputSchema,
26
+ claimCreateCreditMemoSchema,
27
+ claimCreateReplacementOrderSchema,
28
+ claimCreateSalesReturnSchema,
29
+ claimSetReturnLabelSchema,
30
+ commentClaimInputSchema,
31
+ transitionClaimInputSchema,
32
+ vendorRecoveryInputSchema,
33
+ type ClaimCreateInput,
34
+ type ClaimInitialLineCreateInput,
35
+ type ClaimUpdateInput,
36
+ type AssignClaimInput,
37
+ type ClaimCreateCreditMemoInput,
38
+ type ClaimCreateReplacementOrderInput,
39
+ type ClaimCreateSalesReturnInput,
40
+ type ClaimSetReturnLabelInput,
41
+ type CommentClaimInput,
42
+ type TransitionClaimInput,
43
+ type VendorRecoveryInput,
44
+ type WarrantyClaimChannel,
45
+ type WarrantyClaimLineStatus,
46
+ type WarrantyClaimPriority,
47
+ type WarrantyClaimDisposition,
48
+ type WarrantyClaimStatus,
49
+ type WarrantyClaimType,
50
+ type WarrantyClaimWarrantyStatus,
51
+ } from '../data/validators'
52
+ import { createLogger } from '@open-mercato/shared/lib/logger'
53
+ import { isAssignableStaffUser } from '../lib/assigneeNames'
54
+ import { WarrantyClaimNumberGenerator } from '../services/claimNumberGenerator'
55
+ import { emitWarrantyClaimsEvent } from '../events'
56
+ import { assertTransition, canResolveWithLineStatuses, computeHeaderRollups } from '../lib/stateMachine'
57
+ import { assertOrderBelongsToCustomer } from '../lib/orderOwnership'
58
+ import { addBusinessMillis, businessMillisBetween } from '../lib/businessHours'
59
+ import { resolveEffectiveWarrantyClaimSettings, type WarrantyClaimEffectiveSettings } from '../lib/settings'
60
+ import { evaluateClaimRisk } from '../lib/risk'
61
+ import type { WarrantyAdjudicationEvaluator } from '../services/adjudicationEvaluator'
62
+ import type { WarrantyEntitlementInput, WarrantyEntitlementResolver } from '../services/entitlementResolver'
63
+ import {
64
+ WARRANTY_CLAIM_RESOURCE_KIND,
65
+ appendClaimEvent,
66
+ enforceWarrantyClaimOptimisticLock,
67
+ ensureOrganizationScope,
68
+ ensureTenantScope,
69
+ extractUndoPayload,
70
+ requireScopedClaim,
71
+ type WarrantyClaimScope,
72
+ } from './shared'
73
+
74
+ const claimCrudEvents: CrudEventsConfig = {
75
+ module: 'warranty_claims',
76
+ entity: 'claim',
77
+ persistent: true,
78
+ buildPayload: (ctx) => {
79
+ const claim = ctx.entity as WarrantyClaim | null
80
+ return {
81
+ id: ctx.identifiers.id,
82
+ organizationId: ctx.identifiers.organizationId,
83
+ tenantId: ctx.identifiers.tenantId,
84
+ claimType: claim?.claimType ?? null,
85
+ status: claim?.status ?? null,
86
+ }
87
+ },
88
+ }
89
+
90
+ const claimDeleteSchema = z
91
+ .object({
92
+ id: z.string().uuid(),
93
+ organizationId: z.string().uuid().optional(),
94
+ tenantId: z.string().uuid().optional(),
95
+ })
96
+ .strict()
97
+
98
+ const submitClaimSchema = claimDeleteSchema.extend({
99
+ actorCustomerId: z.string().uuid().optional(),
100
+ })
101
+ const escalateClaimInputSchema = z
102
+ .object({
103
+ id: z.string().uuid(),
104
+ organizationId: z.string().uuid(),
105
+ tenantId: z.string().uuid(),
106
+ toLevel: z.coerce.number().int().min(1).max(1000),
107
+ reassignToUserId: z.string().uuid().optional(),
108
+ })
109
+ .strict()
110
+
111
+ type ClaimDeleteInput = z.infer<typeof claimDeleteSchema>
112
+ type SubmitClaimInput = z.infer<typeof submitClaimSchema>
113
+ type EscalateClaimInput = z.infer<typeof escalateClaimInputSchema>
114
+ type EscalateClaimResult = { claimId: string; escalationLevel: number; escalated: boolean }
115
+ type ClaimSetReturnLabelResult = {
116
+ claimId: string
117
+ labelUrl: string | null
118
+ trackingNumber: string | null
119
+ carrier: string | null
120
+ }
121
+
122
+ type ScopeInput = {
123
+ organizationId?: string | null
124
+ tenantId?: string | null
125
+ }
126
+
127
+ type ClaimUpdateField =
128
+ | (typeof CLAIM_INTAKE_UPDATE_FIELDS)[number]
129
+ | (typeof CLAIM_FULFILLMENT_UPDATE_FIELDS)[number]
130
+
131
+ type ClaimLineSnapshot = {
132
+ id: string
133
+ lineNo: number
134
+ productId: string | null
135
+ variantId: string | null
136
+ sku: string | null
137
+ productName: string | null
138
+ orderLineId: string | null
139
+ serialNumber: string | null
140
+ lotNumber: string | null
141
+ purchaseDate: string | null
142
+ warrantyMonths: number | null
143
+ warrantyExpiresAt: string | null
144
+ warrantyStatus: WarrantyClaimWarrantyStatus
145
+ faultCode: string | null
146
+ faultDescription: string | null
147
+ qtyClaimed: string
148
+ qtyApproved: string | null
149
+ qtyReceived: string | null
150
+ conditionOnReceipt: string | null
151
+ conditionGrade: string | null
152
+ quarantineStatus: string
153
+ inspectionNotes: string | null
154
+ assessmentPayload: Record<string, unknown> | null
155
+ disposition: WarrantyClaimDisposition | null
156
+ lineStatus: WarrantyClaimLineStatus
157
+ creditAmount: string | null
158
+ restockingFee: string | null
159
+ coreChargeAmount: string | null
160
+ coreCreditAmount: string | null
161
+ vendorClaimLineId: string | null
162
+ vendorName: string | null
163
+ deletedAt: string | null
164
+ updatedAt: string | null
165
+ }
166
+
167
+ type ClaimSnapshot = {
168
+ id: string
169
+ organizationId: string
170
+ tenantId: string
171
+ claimNumber: string
172
+ claimType: WarrantyClaimType
173
+ status: WarrantyClaimStatus
174
+ channel: WarrantyClaimChannel
175
+ priority: WarrantyClaimPriority
176
+ customerId: string | null
177
+ customerName: string | null
178
+ externalRef: string | null
179
+ intakeMessageRef: string | null
180
+ entitlementSource: string | null
181
+ contactEmail: string | null
182
+ returnLabelUrl: string | null
183
+ returnTrackingNumber: string | null
184
+ returnCarrier: string | null
185
+ vendorName: string | null
186
+ vendorRef: string | null
187
+ orderId: string | null
188
+ orderNumber: string | null
189
+ awaitingStaffReply: boolean
190
+ salesReturnId: string | null
191
+ replacementOrderId: string | null
192
+ creditMemoId: string | null
193
+ sourceClaimId: string | null
194
+ advanceReplacement: boolean
195
+ advanceShippedAt: string | null
196
+ reasonCode: string | null
197
+ rejectionReasonCode: string | null
198
+ resolutionSummary: string | null
199
+ notes: string | null
200
+ currencyCode: string | null
201
+ totalClaimedAmount: string | null
202
+ totalApprovedAmount: string | null
203
+ totalRecoveredAmount: string | null
204
+ escalationLevel: number
205
+ escalatedAt: string | null
206
+ slaDueAt: string | null
207
+ slaPausedAt: string | null
208
+ slaAtRiskNotifiedAt: string | null
209
+ slaBreachedNotifiedAt: string | null
210
+ submittedAt: string | null
211
+ resolvedAt: string | null
212
+ closedAt: string | null
213
+ assigneeUserId: string | null
214
+ deletedAt: string | null
215
+ updatedAt: string | null
216
+ lines: ClaimLineSnapshot[]
217
+ }
218
+
219
+ type ClaimUndoPayload = {
220
+ before?: ClaimSnapshot | null
221
+ after?: ClaimSnapshot | null
222
+ }
223
+
224
+ type ClaimEventPayload = {
225
+ id: string
226
+ claimId: string
227
+ claimNumber: string
228
+ externalRef: string | null
229
+ claimType: WarrantyClaimType
230
+ status: WarrantyClaimStatus
231
+ customerId: string | null
232
+ organizationId: string
233
+ tenantId: string
234
+ }
235
+
236
+ type ReferenceLookupResult = 'missing' | 'unknown'
237
+
238
+ type ReferenceValidationInput = {
239
+ orderId?: string | null
240
+ salesReturnId?: string | null
241
+ replacementOrderId?: string | null
242
+ creditMemoId?: string | null
243
+ lineOrderRefs?: Array<{ orderLineId: string; orderId: string | null }>
244
+ }
245
+
246
+ const intakeStatuses = new Set<WarrantyClaimStatus>(['draft', 'submitted', 'in_review', 'info_requested'])
247
+ const fulfillmentStatuses = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return', 'received', 'inspecting'])
248
+ const deletableStatuses = new Set<WarrantyClaimStatus>(['draft', 'cancelled'])
249
+ const preReceivedStatuses = new Set<WarrantyClaimStatus>([
250
+ 'draft',
251
+ 'submitted',
252
+ 'in_review',
253
+ 'info_requested',
254
+ 'approved',
255
+ 'awaiting_return',
256
+ ])
257
+
258
+ function parseCommandInput<T>(schema: z.ZodType<T>, rawInput: unknown): T {
259
+ const result = schema.safeParse(rawInput ?? {})
260
+ if (!result.success) {
261
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
262
+ }
263
+ return result.data
264
+ }
265
+
266
+ function hasOwn(input: object, key: string): boolean {
267
+ return Object.prototype.hasOwnProperty.call(input, key)
268
+ }
269
+
270
+ function resolveScope(ctx: CommandRuntimeContext, input: ScopeInput): WarrantyClaimScope {
271
+ const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? null
272
+ const organizationId = input.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
273
+ if (!tenantId) throw new CrudHttpError(400, { error: '[internal] tenant scope required for warranty claim command' })
274
+ if (!organizationId) throw new CrudHttpError(400, { error: '[internal] organization scope required for warranty claim command' })
275
+ ensureTenantScope(ctx, tenantId)
276
+ ensureOrganizationScope(ctx, organizationId)
277
+ return { tenantId, organizationId }
278
+ }
279
+
280
+ function toIso(value: Date | string | null | undefined): string | null {
281
+ if (!value) return null
282
+ const date = value instanceof Date ? value : new Date(value)
283
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
284
+ }
285
+
286
+ function toDate(value: string | null): Date | null {
287
+ if (!value) return null
288
+ const date = new Date(value)
289
+ return Number.isNaN(date.getTime()) ? null : date
290
+ }
291
+
292
+ function toDateOnlyIso(value: Date | string | null | undefined): string | null {
293
+ const iso = toIso(value)
294
+ return iso ? iso.slice(0, 10) : null
295
+ }
296
+
297
+ function toDateOnly(value: string | null): Date | null {
298
+ if (!value) return null
299
+ const date = new Date(`${value}T00:00:00.000Z`)
300
+ return Number.isNaN(date.getTime()) ? null : date
301
+ }
302
+
303
+ function amountString(value: number | string | null | undefined, fallback = '0'): string | null {
304
+ if (value === null) return null
305
+ if (value === undefined) return fallback
306
+ const parsed = typeof value === 'number' ? value : Number(value)
307
+ if (!Number.isFinite(parsed)) return fallback
308
+ return String(parsed)
309
+ }
310
+
311
+ function nullableAmountString(value: number | string | null | undefined): string | null {
312
+ if (value === undefined || value === null) return null
313
+ return amountString(value, '0')
314
+ }
315
+
316
+ function addMonths(date: Date, months: number): Date {
317
+ const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1))
318
+ const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
319
+ const copy = new Date(date.getTime())
320
+ copy.setUTCDate(1)
321
+ copy.setUTCFullYear(target.getUTCFullYear(), target.getUTCMonth(), Math.min(date.getUTCDate(), lastDay))
322
+ return copy
323
+ }
324
+
325
+ function computeWarrantyDates(
326
+ purchaseDate: Date | null | undefined,
327
+ warrantyMonths: number | null | undefined,
328
+ ): { warrantyExpiresAt: Date | null; warrantyStatus: WarrantyClaimWarrantyStatus } {
329
+ if (!purchaseDate || warrantyMonths === null || warrantyMonths === undefined) {
330
+ return { warrantyExpiresAt: null, warrantyStatus: 'unknown' }
331
+ }
332
+ const warrantyExpiresAt = addMonths(purchaseDate, warrantyMonths)
333
+ const warrantyStatus = warrantyExpiresAt.getTime() >= Date.now() ? 'in_warranty' : 'out_of_warranty'
334
+ return { warrantyExpiresAt, warrantyStatus }
335
+ }
336
+
337
+ function buildCreateEntitlementInput(input: ClaimCreateInput): WarrantyEntitlementInput | null {
338
+ const lines = input.lines ?? []
339
+ const sourceLine = lines.find((line) => typeof line.serialNumber === 'string' && line.serialNumber.trim().length > 0)
340
+ ?? lines.find((line) => Boolean(line.productId))
341
+ ?? lines.find((line) => typeof line.sku === 'string' && line.sku.trim().length > 0)
342
+ ?? (input.orderId ? lines.find((line) => Boolean(line.purchaseDate)) : null)
343
+ ?? null
344
+
345
+ const serialNumber = sourceLine?.serialNumber?.trim() || null
346
+ const orderId = input.orderId ?? null
347
+ const productId = sourceLine?.productId ?? null
348
+ const sku = sourceLine?.sku?.trim() || null
349
+
350
+ if (!serialNumber && !orderId && !productId && !sku) return null
351
+
352
+ return {
353
+ serialNumber,
354
+ orderId,
355
+ productId,
356
+ sku,
357
+ purchaseDate: toDateOnlyIso(sourceLine?.purchaseDate),
358
+ }
359
+ }
360
+
361
+ async function stampEntitlementSourceIfResolvable(
362
+ ctx: CommandRuntimeContext,
363
+ em: EntityManager,
364
+ claim: WarrantyClaim,
365
+ input: ClaimCreateInput,
366
+ scope: WarrantyClaimScope,
367
+ ): Promise<void> {
368
+ const entitlementInput = buildCreateEntitlementInput(input)
369
+ if (!entitlementInput) return
370
+
371
+ try {
372
+ const resolver = ctx.container.resolve<WarrantyEntitlementResolver>('warrantyEntitlementResolver')
373
+ const result = await resolver.resolveEntitlement(entitlementInput, scope, em)
374
+ claim.entitlementSource = result.source ?? null
375
+ } catch {
376
+ claim.entitlementSource = null
377
+ }
378
+ }
379
+
380
+ function buildInitialLineData(
381
+ claim: WarrantyClaim,
382
+ input: ClaimInitialLineCreateInput,
383
+ index: number,
384
+ ): Partial<WarrantyClaimLine> & {
385
+ id: string
386
+ claim: WarrantyClaim
387
+ organizationId: string
388
+ tenantId: string
389
+ lineNo: number
390
+ qtyClaimed: string
391
+ lineStatus: WarrantyClaimLineStatus
392
+ warrantyStatus: WarrantyClaimWarrantyStatus
393
+ createdAt: Date
394
+ updatedAt: Date
395
+ } {
396
+ const purchaseDate = input.purchaseDate ?? null
397
+ const warrantyMonths = input.warrantyMonths ?? null
398
+ const computedWarranty = computeWarrantyDates(purchaseDate, warrantyMonths)
399
+ return {
400
+ id: randomUUID(),
401
+ claim,
402
+ organizationId: claim.organizationId,
403
+ tenantId: claim.tenantId,
404
+ lineNo: input.lineNo ?? index + 1,
405
+ productId: input.productId ?? null,
406
+ variantId: input.variantId ?? null,
407
+ sku: input.sku ?? null,
408
+ productName: input.productName ?? null,
409
+ orderLineId: input.orderLineId ?? null,
410
+ serialNumber: input.serialNumber ?? null,
411
+ lotNumber: input.lotNumber ?? null,
412
+ purchaseDate,
413
+ warrantyMonths,
414
+ warrantyExpiresAt: input.warrantyExpiresAt ?? computedWarranty.warrantyExpiresAt,
415
+ warrantyStatus: input.warrantyStatus ?? computedWarranty.warrantyStatus,
416
+ faultCode: input.faultCode ?? null,
417
+ faultDescription: input.faultDescription ?? null,
418
+ qtyClaimed: amountString(input.qtyClaimed, '1') ?? '1',
419
+ qtyApproved: nullableAmountString(input.qtyApproved),
420
+ qtyReceived: nullableAmountString(input.qtyReceived),
421
+ conditionOnReceipt: input.conditionOnReceipt ?? null,
422
+ inspectionNotes: input.inspectionNotes ?? null,
423
+ disposition: input.disposition ?? null,
424
+ vendorName: input.vendorName ?? null,
425
+ lineStatus: 'pending',
426
+ creditAmount: nullableAmountString(input.creditAmount),
427
+ restockingFee: nullableAmountString(input.restockingFee),
428
+ coreChargeAmount: nullableAmountString(input.coreChargeAmount),
429
+ coreCreditAmount: nullableAmountString(input.coreCreditAmount),
430
+ createdAt: new Date(),
431
+ updatedAt: new Date(),
432
+ }
433
+ }
434
+
435
+ function snapshotLine(line: WarrantyClaimLine): ClaimLineSnapshot {
436
+ return {
437
+ id: line.id,
438
+ lineNo: line.lineNo,
439
+ productId: line.productId ?? null,
440
+ variantId: line.variantId ?? null,
441
+ sku: line.sku ?? null,
442
+ productName: line.productName ?? null,
443
+ orderLineId: line.orderLineId ?? null,
444
+ serialNumber: line.serialNumber ?? null,
445
+ lotNumber: line.lotNumber ?? null,
446
+ purchaseDate: toDateOnlyIso(line.purchaseDate),
447
+ warrantyMonths: line.warrantyMonths ?? null,
448
+ warrantyExpiresAt: toDateOnlyIso(line.warrantyExpiresAt),
449
+ warrantyStatus: line.warrantyStatus,
450
+ faultCode: line.faultCode ?? null,
451
+ faultDescription: line.faultDescription ?? null,
452
+ qtyClaimed: line.qtyClaimed,
453
+ qtyApproved: line.qtyApproved ?? null,
454
+ qtyReceived: line.qtyReceived ?? null,
455
+ conditionOnReceipt: line.conditionOnReceipt ?? null,
456
+ conditionGrade: line.conditionGrade ?? null,
457
+ quarantineStatus: line.quarantineStatus,
458
+ inspectionNotes: line.inspectionNotes ?? null,
459
+ assessmentPayload: line.assessmentPayload ?? null,
460
+ disposition: line.disposition ?? null,
461
+ lineStatus: line.lineStatus,
462
+ creditAmount: line.creditAmount ?? null,
463
+ restockingFee: line.restockingFee ?? null,
464
+ coreChargeAmount: line.coreChargeAmount ?? null,
465
+ coreCreditAmount: line.coreCreditAmount ?? null,
466
+ vendorClaimLineId: line.vendorClaimLineId ?? null,
467
+ vendorName: line.vendorName ?? null,
468
+ deletedAt: toIso(line.deletedAt),
469
+ updatedAt: toIso(line.updatedAt),
470
+ }
471
+ }
472
+
473
+ function snapshotClaim(claim: WarrantyClaim, lines: readonly WarrantyClaimLine[]): ClaimSnapshot {
474
+ return {
475
+ id: claim.id,
476
+ organizationId: claim.organizationId,
477
+ tenantId: claim.tenantId,
478
+ claimNumber: claim.claimNumber,
479
+ claimType: claim.claimType,
480
+ status: claim.status,
481
+ channel: claim.channel,
482
+ priority: claim.priority,
483
+ customerId: claim.customerId ?? null,
484
+ customerName: claim.customerName ?? null,
485
+ externalRef: claim.externalRef ?? null,
486
+ intakeMessageRef: claim.intakeMessageRef ?? null,
487
+ entitlementSource: claim.entitlementSource ?? null,
488
+ contactEmail: claim.contactEmail ?? null,
489
+ returnLabelUrl: claim.returnLabelUrl ?? null,
490
+ returnTrackingNumber: claim.returnTrackingNumber ?? null,
491
+ returnCarrier: claim.returnCarrier ?? null,
492
+ vendorName: claim.vendorName ?? null,
493
+ vendorRef: claim.vendorRef ?? null,
494
+ orderId: claim.orderId ?? null,
495
+ orderNumber: claim.orderNumber ?? null,
496
+ awaitingStaffReply: claim.awaitingStaffReply === true,
497
+ salesReturnId: claim.salesReturnId ?? null,
498
+ replacementOrderId: claim.replacementOrderId ?? null,
499
+ creditMemoId: claim.creditMemoId ?? null,
500
+ sourceClaimId: claim.sourceClaimId ?? null,
501
+ advanceReplacement: claim.advanceReplacement,
502
+ advanceShippedAt: toIso(claim.advanceShippedAt),
503
+ reasonCode: claim.reasonCode ?? null,
504
+ rejectionReasonCode: claim.rejectionReasonCode ?? null,
505
+ resolutionSummary: claim.resolutionSummary ?? null,
506
+ notes: claim.notes ?? null,
507
+ currencyCode: claim.currencyCode ?? null,
508
+ totalClaimedAmount: claim.totalClaimedAmount ?? null,
509
+ totalApprovedAmount: claim.totalApprovedAmount ?? null,
510
+ totalRecoveredAmount: claim.totalRecoveredAmount ?? null,
511
+ escalationLevel: claim.escalationLevel ?? 0,
512
+ escalatedAt: toIso(claim.escalatedAt),
513
+ slaDueAt: toIso(claim.slaDueAt),
514
+ slaPausedAt: toIso(claim.slaPausedAt),
515
+ slaAtRiskNotifiedAt: toIso(claim.slaAtRiskNotifiedAt),
516
+ slaBreachedNotifiedAt: toIso(claim.slaBreachedNotifiedAt),
517
+ submittedAt: toIso(claim.submittedAt),
518
+ resolvedAt: toIso(claim.resolvedAt),
519
+ closedAt: toIso(claim.closedAt),
520
+ assigneeUserId: claim.assigneeUserId ?? null,
521
+ deletedAt: toIso(claim.deletedAt),
522
+ updatedAt: toIso(claim.updatedAt),
523
+ lines: lines.map(snapshotLine),
524
+ }
525
+ }
526
+
527
+ async function loadClaimSnapshot(
528
+ em: EntityManager,
529
+ claimId: string,
530
+ scope: WarrantyClaimScope,
531
+ ): Promise<ClaimSnapshot | null> {
532
+ const claim = await findOneWithDecryption(em, WarrantyClaim, { id: claimId, tenantId: scope.tenantId, organizationId: scope.organizationId }, {}, scope)
533
+ if (!claim) return null
534
+ const lines = await findWithDecryption(
535
+ em,
536
+ WarrantyClaimLine,
537
+ { claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
538
+ {},
539
+ scope,
540
+ )
541
+ return snapshotClaim(claim, lines)
542
+ }
543
+
544
+ function snapshotValueChanged(before: unknown, after: unknown): boolean {
545
+ return JSON.stringify(before) !== JSON.stringify(after)
546
+ }
547
+
548
+ function restoreClaimDelta(claim: WarrantyClaim, before: ClaimSnapshot, after: ClaimSnapshot): void {
549
+ if (before.claimNumber !== after.claimNumber) claim.claimNumber = before.claimNumber
550
+ if (before.claimType !== after.claimType) claim.claimType = before.claimType
551
+ if (before.status !== after.status) claim.status = before.status
552
+ if (before.channel !== after.channel) claim.channel = before.channel
553
+ if (before.priority !== after.priority) claim.priority = before.priority
554
+ if (before.customerId !== after.customerId) claim.customerId = before.customerId
555
+ if (before.customerName !== after.customerName) claim.customerName = before.customerName
556
+ if (before.externalRef !== after.externalRef) claim.externalRef = before.externalRef
557
+ if (before.intakeMessageRef !== after.intakeMessageRef) claim.intakeMessageRef = before.intakeMessageRef
558
+ if (before.entitlementSource !== after.entitlementSource) claim.entitlementSource = before.entitlementSource
559
+ if (before.contactEmail !== after.contactEmail) claim.contactEmail = before.contactEmail
560
+ if (before.returnLabelUrl !== after.returnLabelUrl) claim.returnLabelUrl = before.returnLabelUrl
561
+ if (before.returnTrackingNumber !== after.returnTrackingNumber) claim.returnTrackingNumber = before.returnTrackingNumber
562
+ if (before.returnCarrier !== after.returnCarrier) claim.returnCarrier = before.returnCarrier
563
+ if (before.vendorName !== after.vendorName) claim.vendorName = before.vendorName
564
+ if (before.vendorRef !== after.vendorRef) claim.vendorRef = before.vendorRef
565
+ if (before.orderId !== after.orderId) claim.orderId = before.orderId
566
+ if (before.orderNumber !== after.orderNumber) claim.orderNumber = before.orderNumber
567
+ if (before.awaitingStaffReply !== after.awaitingStaffReply) claim.awaitingStaffReply = before.awaitingStaffReply === true
568
+ if (before.salesReturnId !== after.salesReturnId) claim.salesReturnId = before.salesReturnId
569
+ if (before.replacementOrderId !== after.replacementOrderId) claim.replacementOrderId = before.replacementOrderId
570
+ if (before.creditMemoId !== after.creditMemoId) claim.creditMemoId = before.creditMemoId
571
+ if (before.sourceClaimId !== after.sourceClaimId) claim.sourceClaimId = before.sourceClaimId
572
+ if (before.advanceReplacement !== after.advanceReplacement) claim.advanceReplacement = before.advanceReplacement
573
+ if (before.advanceShippedAt !== after.advanceShippedAt) claim.advanceShippedAt = toDate(before.advanceShippedAt)
574
+ if (before.reasonCode !== after.reasonCode) claim.reasonCode = before.reasonCode
575
+ if (before.rejectionReasonCode !== after.rejectionReasonCode) claim.rejectionReasonCode = before.rejectionReasonCode
576
+ if (before.resolutionSummary !== after.resolutionSummary) claim.resolutionSummary = before.resolutionSummary
577
+ if (before.notes !== after.notes) claim.notes = before.notes
578
+ if (before.currencyCode !== after.currencyCode) claim.currencyCode = before.currencyCode
579
+ if (before.totalClaimedAmount !== after.totalClaimedAmount) claim.totalClaimedAmount = before.totalClaimedAmount
580
+ if (before.totalApprovedAmount !== after.totalApprovedAmount) claim.totalApprovedAmount = before.totalApprovedAmount
581
+ if (before.totalRecoveredAmount !== after.totalRecoveredAmount) claim.totalRecoveredAmount = before.totalRecoveredAmount
582
+ if (before.escalationLevel !== after.escalationLevel) claim.escalationLevel = before.escalationLevel
583
+ if (before.escalatedAt !== after.escalatedAt) claim.escalatedAt = toDate(before.escalatedAt)
584
+ if (before.slaDueAt !== after.slaDueAt) claim.slaDueAt = toDate(before.slaDueAt)
585
+ if (before.slaPausedAt !== after.slaPausedAt) claim.slaPausedAt = toDate(before.slaPausedAt)
586
+ if (before.slaAtRiskNotifiedAt !== after.slaAtRiskNotifiedAt) claim.slaAtRiskNotifiedAt = toDate(before.slaAtRiskNotifiedAt)
587
+ if (before.slaBreachedNotifiedAt !== after.slaBreachedNotifiedAt) claim.slaBreachedNotifiedAt = toDate(before.slaBreachedNotifiedAt)
588
+ if (before.submittedAt !== after.submittedAt) claim.submittedAt = toDate(before.submittedAt)
589
+ if (before.resolvedAt !== after.resolvedAt) claim.resolvedAt = toDate(before.resolvedAt)
590
+ if (before.closedAt !== after.closedAt) claim.closedAt = toDate(before.closedAt)
591
+ if (before.assigneeUserId !== after.assigneeUserId) claim.assigneeUserId = before.assigneeUserId
592
+ if (before.deletedAt !== after.deletedAt) claim.deletedAt = toDate(before.deletedAt)
593
+ claim.updatedAt = new Date()
594
+ }
595
+
596
+ function restoreLineDelta(line: WarrantyClaimLine, before: ClaimLineSnapshot, after: ClaimLineSnapshot): void {
597
+ if (before.lineNo !== after.lineNo) line.lineNo = before.lineNo
598
+ if (before.productId !== after.productId) line.productId = before.productId
599
+ if (before.variantId !== after.variantId) line.variantId = before.variantId
600
+ if (before.sku !== after.sku) line.sku = before.sku
601
+ if (before.productName !== after.productName) line.productName = before.productName
602
+ if (before.orderLineId !== after.orderLineId) line.orderLineId = before.orderLineId
603
+ if (before.serialNumber !== after.serialNumber) line.serialNumber = before.serialNumber
604
+ if (before.lotNumber !== after.lotNumber) line.lotNumber = before.lotNumber
605
+ if (before.purchaseDate !== after.purchaseDate) line.purchaseDate = toDateOnly(before.purchaseDate)
606
+ if (before.warrantyMonths !== after.warrantyMonths) line.warrantyMonths = before.warrantyMonths
607
+ if (before.warrantyExpiresAt !== after.warrantyExpiresAt) line.warrantyExpiresAt = toDateOnly(before.warrantyExpiresAt)
608
+ if (before.warrantyStatus !== after.warrantyStatus) line.warrantyStatus = before.warrantyStatus
609
+ if (before.faultCode !== after.faultCode) line.faultCode = before.faultCode
610
+ if (before.faultDescription !== after.faultDescription) line.faultDescription = before.faultDescription
611
+ if (before.qtyClaimed !== after.qtyClaimed) line.qtyClaimed = before.qtyClaimed
612
+ if (before.qtyApproved !== after.qtyApproved) line.qtyApproved = before.qtyApproved
613
+ if (before.qtyReceived !== after.qtyReceived) line.qtyReceived = before.qtyReceived
614
+ if (before.conditionOnReceipt !== after.conditionOnReceipt) line.conditionOnReceipt = before.conditionOnReceipt
615
+ if (before.conditionGrade !== after.conditionGrade) line.conditionGrade = before.conditionGrade
616
+ if (before.quarantineStatus !== after.quarantineStatus) line.quarantineStatus = before.quarantineStatus
617
+ if (before.inspectionNotes !== after.inspectionNotes) line.inspectionNotes = before.inspectionNotes
618
+ if (snapshotValueChanged(before.assessmentPayload, after.assessmentPayload)) line.assessmentPayload = before.assessmentPayload
619
+ if (before.disposition !== after.disposition) line.disposition = before.disposition
620
+ if (before.lineStatus !== after.lineStatus) line.lineStatus = before.lineStatus
621
+ if (before.creditAmount !== after.creditAmount) line.creditAmount = before.creditAmount
622
+ if (before.restockingFee !== after.restockingFee) line.restockingFee = before.restockingFee
623
+ if (before.coreChargeAmount !== after.coreChargeAmount) line.coreChargeAmount = before.coreChargeAmount
624
+ if (before.coreCreditAmount !== after.coreCreditAmount) line.coreCreditAmount = before.coreCreditAmount
625
+ if (before.vendorClaimLineId !== after.vendorClaimLineId) line.vendorClaimLineId = before.vendorClaimLineId
626
+ if (before.vendorName !== after.vendorName) line.vendorName = before.vendorName
627
+ if (before.deletedAt !== after.deletedAt) line.deletedAt = toDate(before.deletedAt)
628
+ line.updatedAt = new Date()
629
+ }
630
+
631
+ function throwClaimUndoConflict(): never {
632
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.conflict' })
633
+ }
634
+
635
+ async function requireCurrentUndoSnapshot(
636
+ em: EntityManager,
637
+ after: ClaimSnapshot,
638
+ ): Promise<{ claim: WarrantyClaim; lines: WarrantyClaimLine[] }> {
639
+ const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
640
+ const claim = await findOneWithDecryption(
641
+ em,
642
+ WarrantyClaim,
643
+ { id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
644
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
645
+ scope,
646
+ )
647
+ const expectedClaimUpdatedAt = toIso(after.updatedAt)
648
+ if (!claim || !expectedClaimUpdatedAt || toIso(claim.updatedAt) !== expectedClaimUpdatedAt) throwClaimUndoConflict()
649
+ const lines = await findWithDecryption(
650
+ em,
651
+ WarrantyClaimLine,
652
+ { claim: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
653
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
654
+ scope,
655
+ )
656
+ const afterLines = Array.isArray(after.lines) ? after.lines : []
657
+ if (lines.length !== afterLines.length) throwClaimUndoConflict()
658
+ const afterLinesById = new Map(afterLines.map((line) => [line.id, line]))
659
+ for (const line of lines) {
660
+ const expected = afterLinesById.get(line.id)
661
+ const expectedLineUpdatedAt = toIso(expected?.updatedAt)
662
+ if (!expectedLineUpdatedAt || toIso(line.updatedAt) !== expectedLineUpdatedAt) throwClaimUndoConflict()
663
+ }
664
+ return { claim, lines }
665
+ }
666
+
667
+ async function assertUndoSnapshotCurrent(ctx: CommandRuntimeContext, after: ClaimSnapshot): Promise<void> {
668
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
669
+ await em.transactional(async (tx) => {
670
+ await requireCurrentUndoSnapshot(tx, after)
671
+ })
672
+ }
673
+
674
+ async function restoreSnapshot(em: EntityManager, before: ClaimSnapshot, after: ClaimSnapshot): Promise<WarrantyClaim> {
675
+ const { claim, lines } = await requireCurrentUndoSnapshot(em, after)
676
+ restoreClaimDelta(claim, before, after)
677
+ const beforeLinesById = new Map((Array.isArray(before.lines) ? before.lines : []).map((line) => [line.id, line]))
678
+ const afterLinesById = new Map((Array.isArray(after.lines) ? after.lines : []).map((line) => [line.id, line]))
679
+ for (const line of lines) {
680
+ const beforeLine = beforeLinesById.get(line.id)
681
+ const afterLine = afterLinesById.get(line.id)
682
+ if (!beforeLine || !afterLine) throwClaimUndoConflict()
683
+ if (snapshotValueChanged(beforeLine, afterLine)) restoreLineDelta(line, beforeLine, afterLine)
684
+ }
685
+ return claim
686
+ }
687
+
688
+ function claimEventPayload(claim: WarrantyClaim): ClaimEventPayload {
689
+ return {
690
+ id: claim.id,
691
+ claimId: claim.id,
692
+ claimNumber: claim.claimNumber,
693
+ externalRef: claim.externalRef ?? null,
694
+ claimType: claim.claimType,
695
+ status: claim.status,
696
+ customerId: claim.customerId ?? null,
697
+ organizationId: claim.organizationId,
698
+ tenantId: claim.tenantId,
699
+ }
700
+ }
701
+
702
+ function claimPersistentEmitOptions(claim: WarrantyClaim) {
703
+ return {
704
+ persistent: true,
705
+ tenantId: claim.tenantId,
706
+ organizationId: claim.organizationId,
707
+ } as const
708
+ }
709
+
710
+ async function emitClaimCrud(
711
+ ctx: CommandRuntimeContext,
712
+ action: 'created' | 'updated' | 'deleted',
713
+ claim: WarrantyClaim,
714
+ ): Promise<void> {
715
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
716
+ await emitCrudSideEffects({
717
+ dataEngine,
718
+ action,
719
+ entity: claim,
720
+ identifiers: { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
721
+ indexer: { entityType: E.warranty_claims.warranty_claim },
722
+ events: claimCrudEvents,
723
+ })
724
+ await invalidateCrudCache(
725
+ ctx.container,
726
+ 'warranty_claims.claim',
727
+ { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
728
+ ctx.auth?.tenantId ?? null,
729
+ `warranty_claims.claim.${action}`,
730
+ )
731
+ }
732
+
733
+ async function emitClaimUndoCrud(
734
+ ctx: CommandRuntimeContext,
735
+ action: 'created' | 'updated' | 'deleted',
736
+ claim: WarrantyClaim,
737
+ ): Promise<void> {
738
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
739
+ await emitCrudUndoSideEffects({
740
+ dataEngine,
741
+ action,
742
+ entity: claim,
743
+ identifiers: { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
744
+ indexer: { entityType: E.warranty_claims.warranty_claim },
745
+ events: claimCrudEvents,
746
+ })
747
+ await invalidateCrudCache(
748
+ ctx.container,
749
+ 'warranty_claims.claim',
750
+ { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
751
+ ctx.auth?.tenantId ?? null,
752
+ `warranty_claims.claim.undo.${action}`,
753
+ )
754
+ }
755
+
756
+ async function emitLineCrud(
757
+ ctx: CommandRuntimeContext,
758
+ action: 'created' | 'updated' | 'deleted',
759
+ line: WarrantyClaimLine,
760
+ ): Promise<void> {
761
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
762
+ await emitCrudSideEffects({
763
+ dataEngine,
764
+ action,
765
+ entity: line,
766
+ identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
767
+ indexer: { entityType: E.warranty_claims.warranty_claim_line },
768
+ })
769
+ await invalidateCrudCache(
770
+ ctx.container,
771
+ 'warranty_claims.claim_line',
772
+ { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
773
+ ctx.auth?.tenantId ?? null,
774
+ `warranty_claims.claim_line.${action}`,
775
+ )
776
+ }
777
+
778
+ async function emitLineUndoCrud(
779
+ ctx: CommandRuntimeContext,
780
+ action: 'created' | 'updated' | 'deleted',
781
+ line: WarrantyClaimLine,
782
+ ): Promise<void> {
783
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
784
+ await emitCrudUndoSideEffects({
785
+ dataEngine,
786
+ action,
787
+ entity: line,
788
+ identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
789
+ indexer: { entityType: E.warranty_claims.warranty_claim_line },
790
+ })
791
+ await invalidateCrudCache(
792
+ ctx.container,
793
+ 'warranty_claims.claim_line',
794
+ { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
795
+ ctx.auth?.tenantId ?? null,
796
+ `warranty_claims.claim_line.undo.${action}`,
797
+ )
798
+ }
799
+
800
+ function resolveClaimNumberGenerator(ctx: CommandRuntimeContext, em: EntityManager): WarrantyClaimNumberGenerator {
801
+ try {
802
+ return ctx.container.resolve('warrantyClaimNumberGenerator') as WarrantyClaimNumberGenerator
803
+ } catch {
804
+ return new WarrantyClaimNumberGenerator(em)
805
+ }
806
+ }
807
+
808
+ function readCustomerName(row: Record<string, unknown>): string | null {
809
+ const candidates = [row.display_name, row.displayName, row.name, row.label]
810
+ for (const candidate of candidates) {
811
+ if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
812
+ }
813
+ return null
814
+ }
815
+
816
+ function readString(row: Record<string, unknown>, key: string): string | null {
817
+ const value = row[key]
818
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
819
+ }
820
+
821
+ type SalesReferenceTable = 'sales_orders' | 'sales_returns' | 'sales_credit_memos' | 'sales_order_lines'
822
+
823
+ type SalesReferenceDb = {
824
+ sales_orders: {
825
+ id: string
826
+ currency_code: string
827
+ customer_entity_id: string | null
828
+ customer_contact_id: string | null
829
+ billing_address_id: string | null
830
+ shipping_address_id: string | null
831
+ channel_id: string | null
832
+ updated_at: Date | string | null
833
+ tenant_id: string | null
834
+ organization_id: string | null
835
+ deleted_at: Date | null
836
+ }
837
+ sales_returns: {
838
+ id: string
839
+ updated_at: Date | string | null
840
+ tenant_id: string | null
841
+ organization_id: string | null
842
+ deleted_at: Date | null
843
+ }
844
+ sales_credit_memos: {
845
+ id: string
846
+ updated_at: Date | string | null
847
+ tenant_id: string | null
848
+ organization_id: string | null
849
+ deleted_at: Date | null
850
+ }
851
+ sales_order_lines: {
852
+ id: string
853
+ order_id: string
854
+ product_id: string | null
855
+ product_variant_id: string | null
856
+ name: string | null
857
+ kind: 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
858
+ currency_code: string
859
+ quantity: string | null
860
+ unit_price_net: string
861
+ unit_price_gross: string
862
+ tax_rate: string
863
+ total_net_amount: string
864
+ total_gross_amount: string
865
+ tenant_id: string | null
866
+ organization_id: string | null
867
+ deleted_at: Date | null
868
+ }
869
+ }
870
+
871
+ type ClaimedQuantityDb = SalesReferenceDb & {
872
+ warranty_claims: {
873
+ id: string
874
+ status: string
875
+ tenant_id: string
876
+ organization_id: string
877
+ deleted_at: Date | null
878
+ }
879
+ warranty_claim_lines: {
880
+ id: string
881
+ claim_id: string
882
+ order_line_id: string | null
883
+ qty_claimed: string | null
884
+ line_status: string
885
+ tenant_id: string
886
+ organization_id: string
887
+ deleted_at: Date | null
888
+ }
889
+ }
890
+
891
+ export type ClaimedQuantityLine = {
892
+ id?: string | null
893
+ orderLineId?: string | null
894
+ qtyClaimed: string | number
895
+ deletedAt?: Date | string | null
896
+ }
897
+
898
+ function isMissingReferenceTableError(err: unknown): boolean {
899
+ if (typeof err !== 'object' || err === null) return false
900
+ const candidate = err as { code?: unknown; message?: unknown }
901
+ return candidate.code === '42P01'
902
+ || (typeof candidate.message === 'string' && candidate.message.includes('does not exist'))
903
+ }
904
+
905
+ async function querySalesReferenceRow(
906
+ ctx: CommandRuntimeContext,
907
+ table: SalesReferenceTable,
908
+ scope: WarrantyClaimScope,
909
+ id: string,
910
+ select: string[],
911
+ ): Promise<Record<string, unknown> | null | undefined> {
912
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
913
+ const db = em.getKysely<SalesReferenceDb>()
914
+ try {
915
+ const row = await db
916
+ .selectFrom(table)
917
+ .select(select as never[])
918
+ .where('id' as never, '=', id as never)
919
+ .where('tenant_id' as never, '=', scope.tenantId as never)
920
+ .where('organization_id' as never, '=', scope.organizationId as never)
921
+ .where('deleted_at' as never, 'is', null)
922
+ .executeTakeFirst()
923
+ return (row as Record<string, unknown> | undefined) ?? null
924
+ } catch (err) {
925
+ if (isMissingReferenceTableError(err)) return undefined
926
+ throw err
927
+ }
928
+ }
929
+
930
+ async function querySalesOrderLineReferences(
931
+ ctx: CommandRuntimeContext,
932
+ scope: WarrantyClaimScope,
933
+ ids: string[],
934
+ select: string[] = ['id', 'order_id'],
935
+ ): Promise<Record<string, unknown>[] | undefined> {
936
+ if (ids.length === 0) return []
937
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
938
+ const db = em.getKysely<SalesReferenceDb>()
939
+ try {
940
+ const rows = await db
941
+ .selectFrom('sales_order_lines')
942
+ .select(select as never[])
943
+ .where('id', 'in', ids)
944
+ .where('tenant_id', '=', scope.tenantId)
945
+ .where('organization_id', '=', scope.organizationId)
946
+ .where('deleted_at', 'is', null)
947
+ .execute()
948
+ return rows as Record<string, unknown>[]
949
+ } catch (err) {
950
+ if (isMissingReferenceTableError(err)) return undefined
951
+ throw err
952
+ }
953
+ }
954
+
955
+ const CLAIMED_QUANTITY_SCALE = 10_000n
956
+ const TAX_RATE_FORMULA_SCALE = 1_000_000n
957
+
958
+ function claimedQuantityUnits(value: string | number | null | undefined): bigint | null {
959
+ if (value === null || value === undefined) return null
960
+ let normalized = String(value).trim()
961
+ if (/e/i.test(normalized)) {
962
+ const numeric = Number(normalized)
963
+ if (!Number.isFinite(numeric)) return null
964
+ normalized = numeric.toFixed(4)
965
+ }
966
+ const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(normalized)
967
+ if (!match) return null
968
+ const [, sign, whole, fraction = ''] = match
969
+ const scaledFraction = fraction.slice(0, 4).padEnd(4, '0')
970
+ let units = BigInt(whole) * CLAIMED_QUANTITY_SCALE + BigInt(scaledFraction)
971
+ if (fraction.length > 4 && fraction[4] >= '5') units += 1n
972
+ return sign === '-' ? -units : units
973
+ }
974
+
975
+ function roundHalfUpDivision(numerator: bigint, denominator: bigint): bigint {
976
+ if (denominator <= 0n) throw new Error('[internal] Positive denominator required')
977
+ if (numerator < 0n) return -roundHalfUpDivision(-numerator, denominator)
978
+ return (numerator + denominator / 2n) / denominator
979
+ }
980
+
981
+ function formatScaledUnits(units: bigint): string {
982
+ const sign = units < 0n ? '-' : ''
983
+ const absolute = units < 0n ? -units : units
984
+ const whole = absolute / CLAIMED_QUANTITY_SCALE
985
+ const fraction = String(absolute % CLAIMED_QUANTITY_SCALE).padStart(4, '0')
986
+ return `${sign}${whole}.${fraction}`
987
+ }
988
+
989
+ function netUnitsFromGross(grossUnits: bigint, taxRateUnits: bigint): bigint {
990
+ const denominator = TAX_RATE_FORMULA_SCALE + taxRateUnits
991
+ return roundHalfUpDivision(grossUnits * TAX_RATE_FORMULA_SCALE, denominator)
992
+ }
993
+
994
+ export async function assertClaimedQtyWithinSold(
995
+ ctx: CommandRuntimeContext,
996
+ scope: WarrantyClaimScope,
997
+ claimIdOrPendingLines: string | readonly ClaimedQuantityLine[],
998
+ candidateLine: ClaimedQuantityLine,
999
+ ): Promise<void> {
1000
+ const orderLineId = candidateLine.orderLineId ?? null
1001
+ if (!orderLineId || !resolveOptionalEntityId('sales', 'sales_order_line')) return
1002
+
1003
+ const salesLine = await querySalesReferenceRow(ctx, 'sales_order_lines', scope, orderLineId, ['id', 'quantity'])
1004
+ const soldQuantity = claimedQuantityUnits(salesLine?.quantity as string | number | null | undefined)
1005
+ if (salesLine === undefined || salesLine === null || soldQuantity === null) return
1006
+
1007
+ let lines: readonly ClaimedQuantityLine[]
1008
+ if (typeof claimIdOrPendingLines === 'string') {
1009
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1010
+ lines = await findWithDecryption(
1011
+ em,
1012
+ WarrantyClaimLine,
1013
+ {
1014
+ claim: claimIdOrPendingLines,
1015
+ orderLineId,
1016
+ tenantId: scope.tenantId,
1017
+ organizationId: scope.organizationId,
1018
+ deletedAt: null,
1019
+ },
1020
+ {},
1021
+ scope,
1022
+ )
1023
+ } else {
1024
+ lines = claimIdOrPendingLines
1025
+ }
1026
+
1027
+ let claimedQuantity = claimedQuantityUnits(candidateLine.qtyClaimed) ?? 0n
1028
+ for (const line of lines) {
1029
+ if (line.deletedAt || (line.orderLineId ?? null) !== orderLineId) continue
1030
+ if (candidateLine.id != null && line.id === candidateLine.id) continue
1031
+ claimedQuantity += claimedQuantityUnits(line.qtyClaimed) ?? 0n
1032
+ }
1033
+ if (claimedQuantity > soldQuantity) {
1034
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.qtyExceedsOrdered' })
1035
+ }
1036
+ }
1037
+
1038
+ export async function assertPendingClaimQuantitiesWithinSold(
1039
+ em: EntityManager,
1040
+ scope: WarrantyClaimScope,
1041
+ pendingLinesByOrderLine: ReadonlyMap<string, readonly ClaimedQuantityLine[]>,
1042
+ options: { currentClaimId: string; excludedLineIds?: ReadonlySet<string> },
1043
+ ): Promise<void> {
1044
+ const db = em.getKysely<ClaimedQuantityDb>()
1045
+ const orderLineIds = Array.from(pendingLinesByOrderLine.keys()).sort((left, right) => left.localeCompare(right))
1046
+ for (const orderLineId of orderLineIds) {
1047
+ let soldRow: { id: string; quantity: string | null } | undefined
1048
+ try {
1049
+ soldRow = await db
1050
+ .selectFrom('sales_order_lines')
1051
+ .select(['id', 'quantity'])
1052
+ .where('id', '=', orderLineId)
1053
+ .where('tenant_id', '=', scope.tenantId)
1054
+ .where('organization_id', '=', scope.organizationId)
1055
+ .where('deleted_at', 'is', null)
1056
+ .forUpdate()
1057
+ .executeTakeFirst()
1058
+ } catch (err) {
1059
+ if (isMissingReferenceTableError(err)) return
1060
+ throw err
1061
+ }
1062
+ const soldQuantity = claimedQuantityUnits(soldRow?.quantity)
1063
+ if (!soldRow || soldQuantity === null) continue
1064
+
1065
+ const persistedRows = await db
1066
+ .selectFrom('warranty_claim_lines')
1067
+ .innerJoin('warranty_claims', 'warranty_claims.id', 'warranty_claim_lines.claim_id')
1068
+ .select(['warranty_claim_lines.id', 'warranty_claim_lines.qty_claimed'])
1069
+ .where('warranty_claim_lines.order_line_id', '=', orderLineId)
1070
+ .where('warranty_claim_lines.tenant_id', '=', scope.tenantId)
1071
+ .where('warranty_claim_lines.organization_id', '=', scope.organizationId)
1072
+ .where('warranty_claim_lines.deleted_at', 'is', null)
1073
+ .where('warranty_claim_lines.line_status', '!=', 'rejected')
1074
+ .where('warranty_claims.tenant_id', '=', scope.tenantId)
1075
+ .where('warranty_claims.organization_id', '=', scope.organizationId)
1076
+ .where('warranty_claims.id', '=', options.currentClaimId)
1077
+ .where('warranty_claims.deleted_at', 'is', null)
1078
+ .where('warranty_claims.status', '!=', 'cancelled')
1079
+ .execute()
1080
+
1081
+ let claimedQuantity = 0n
1082
+ for (const row of persistedRows) {
1083
+ if (options.excludedLineIds?.has(row.id)) continue
1084
+ claimedQuantity += claimedQuantityUnits(row.qty_claimed) ?? 0n
1085
+ }
1086
+ for (const line of pendingLinesByOrderLine.get(orderLineId) ?? []) {
1087
+ claimedQuantity += claimedQuantityUnits(line.qtyClaimed) ?? 0n
1088
+ }
1089
+ if (claimedQuantity > soldQuantity) {
1090
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.qtyExceedsOrdered' })
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ type CustomerEntitiesDb = {
1096
+ customer_entities: {
1097
+ id: string
1098
+ display_name: string | null
1099
+ tenant_id: string | null
1100
+ organization_id: string | null
1101
+ deleted_at: Date | null
1102
+ }
1103
+ }
1104
+
1105
+ function isMissingCustomerTableError(err: unknown): boolean {
1106
+ if (typeof err !== 'object' || err === null) return false
1107
+ const candidate = err as { code?: unknown; message?: unknown }
1108
+ return candidate.code === '42P01'
1109
+ || (typeof candidate.message === 'string'
1110
+ && candidate.message.includes('customer_entities')
1111
+ && candidate.message.includes('does not exist'))
1112
+ }
1113
+
1114
+ async function assertCustomerExists(
1115
+ ctx: CommandRuntimeContext,
1116
+ customerId: string,
1117
+ scope: WarrantyClaimScope,
1118
+ ): Promise<void> {
1119
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1120
+ const db = em.getKysely<CustomerEntitiesDb>()
1121
+ let row: { id: string } | undefined
1122
+ try {
1123
+ row = await db
1124
+ .selectFrom('customer_entities')
1125
+ .select('id')
1126
+ .where('id', '=', customerId)
1127
+ .where('tenant_id', '=', scope.tenantId)
1128
+ .where('organization_id', '=', scope.organizationId)
1129
+ .where('deleted_at', 'is', null)
1130
+ .executeTakeFirst()
1131
+ } catch (err) {
1132
+ if (isMissingCustomerTableError(err)) return
1133
+ throw err
1134
+ }
1135
+ if (!row) throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
1136
+ }
1137
+
1138
+ async function resolveCustomerName(
1139
+ ctx: CommandRuntimeContext,
1140
+ customerId: string | null | undefined,
1141
+ scope: WarrantyClaimScope,
1142
+ fallback: string | null | undefined,
1143
+ options: { strict: boolean } = { strict: false },
1144
+ ): Promise<string | null> {
1145
+ if (!customerId) return fallback ?? null
1146
+ if (options.strict) await assertCustomerExists(ctx, customerId, scope)
1147
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1148
+ const db = em.getKysely<CustomerEntitiesDb>()
1149
+ let customer: Record<string, unknown> | undefined
1150
+ try {
1151
+ customer = await db
1152
+ .selectFrom('customer_entities')
1153
+ .select(['id', 'display_name'] as never[])
1154
+ .where('id' as never, '=', customerId as never)
1155
+ .where('tenant_id' as never, '=', scope.tenantId as never)
1156
+ .where('organization_id' as never, '=', scope.organizationId as never)
1157
+ .where('deleted_at' as never, 'is', null)
1158
+ .executeTakeFirst() as Record<string, unknown> | undefined
1159
+ } catch (err) {
1160
+ if (!isMissingCustomerTableError(err)) throw err
1161
+ return fallback ?? null
1162
+ }
1163
+ if (!customer) return fallback ?? null
1164
+ let encryption: {
1165
+ decryptEntityPayload: (entityId: string, payload: Record<string, unknown>, tenantId: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
1166
+ } | null = null
1167
+ try {
1168
+ encryption = ctx.container.resolve('tenantEncryptionService')
1169
+ } catch {
1170
+ encryption = null
1171
+ }
1172
+ if (encryption) {
1173
+ try {
1174
+ customer = await encryption.decryptEntityPayload('customers:customer_entity', customer, scope.tenantId, scope.organizationId)
1175
+ } catch {
1176
+ return fallback ?? null
1177
+ }
1178
+ }
1179
+ return readSafeDecryptedString(readCustomerName(customer)) ?? fallback ?? null
1180
+ }
1181
+
1182
+ async function resolveOrderNumber(
1183
+ ctx: CommandRuntimeContext,
1184
+ orderId: string | null | undefined,
1185
+ scope: WarrantyClaimScope,
1186
+ fallback: string | null | undefined,
1187
+ ): Promise<string | null> {
1188
+ if (!orderId) return null
1189
+ const row = await querySalesReferenceRow(ctx, 'sales_orders', scope, orderId, ['id', 'order_number'])
1190
+ if (!row) return fallback ?? null
1191
+ return readString(row, 'order_number') ?? fallback ?? null
1192
+ }
1193
+
1194
+ type CustomerUsersDb = {
1195
+ customer_users: {
1196
+ id: string
1197
+ tenant_id: string | null
1198
+ organization_id: string | null
1199
+ customer_entity_id: string | null
1200
+ is_active: boolean
1201
+ deleted_at: Date | null
1202
+ }
1203
+ }
1204
+
1205
+ async function resolvePortalRecipientUserIds(
1206
+ ctx: CommandRuntimeContext,
1207
+ claim: WarrantyClaim,
1208
+ ): Promise<string[]> {
1209
+ if (!claim.customerId) return []
1210
+ try {
1211
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1212
+ const db = em.getKysely<CustomerUsersDb>()
1213
+ const rows = await db
1214
+ .selectFrom('customer_users')
1215
+ .select('id')
1216
+ .where('tenant_id', '=', claim.tenantId)
1217
+ .where('organization_id', '=', claim.organizationId)
1218
+ .where('customer_entity_id', '=', claim.customerId)
1219
+ .where('is_active', '=', true)
1220
+ .where('deleted_at', 'is', null)
1221
+ .limit(100)
1222
+ .execute()
1223
+ return rows.map((row) => row.id)
1224
+ } catch {
1225
+ return []
1226
+ }
1227
+ }
1228
+
1229
+ async function emitClaimStatusChanged(
1230
+ ctx: CommandRuntimeContext,
1231
+ claim: WarrantyClaim,
1232
+ fromStatus: WarrantyClaimStatus,
1233
+ toStatus: WarrantyClaimStatus,
1234
+ ): Promise<void> {
1235
+ const payload = {
1236
+ ...claimEventPayload(claim),
1237
+ fromStatus,
1238
+ toStatus,
1239
+ }
1240
+ await emitWarrantyClaimsEvent('warranty_claims.claim.status_changed', payload, claimPersistentEmitOptions(claim))
1241
+ const recipientUserIds = await resolvePortalRecipientUserIds(ctx, claim)
1242
+ if (recipientUserIds.length === 0) return
1243
+ await emitWarrantyClaimsEvent('warranty_claims.claim.portal_status_changed', {
1244
+ ...payload,
1245
+ recipientUserIds,
1246
+ }, claimPersistentEmitOptions(claim))
1247
+ }
1248
+
1249
+ async function emitCustomerVisibleCommentAdded(
1250
+ ctx: CommandRuntimeContext,
1251
+ claim: WarrantyClaim,
1252
+ input: CommentClaimInput,
1253
+ ): Promise<void> {
1254
+ const recipientUserIds = await resolvePortalRecipientUserIds(ctx, claim)
1255
+ if (recipientUserIds.length === 0) return
1256
+ await emitWarrantyClaimsEvent('warranty_claims.claim.comment_added', {
1257
+ ...claimEventPayload(claim),
1258
+ visibility: input.visibility,
1259
+ actorCustomerId: input.actorCustomerId ?? null,
1260
+ recipientUserIds,
1261
+ }, claimPersistentEmitOptions(claim))
1262
+ }
1263
+
1264
+ function applySlaPause(
1265
+ em: EntityManager,
1266
+ claim: WarrantyClaim,
1267
+ fromStatus: WarrantyClaimStatus,
1268
+ toStatus: WarrantyClaimStatus,
1269
+ now: Date,
1270
+ effectiveSettings: WarrantyClaimEffectiveSettings,
1271
+ ): void {
1272
+ if (fromStatus === toStatus || toStatus !== 'info_requested' || !effectiveSettings.slaPauseOnInfoRequested) return
1273
+ claim.slaPausedAt = now
1274
+ appendClaimEvent(em, claim, 'system', {
1275
+ visibility: 'customer',
1276
+ payload: { action: 'sla_paused' },
1277
+ actorUserId: null,
1278
+ })
1279
+ }
1280
+
1281
+ function applySlaResume(
1282
+ em: EntityManager,
1283
+ claim: WarrantyClaim,
1284
+ fromStatus: WarrantyClaimStatus,
1285
+ toStatus: WarrantyClaimStatus,
1286
+ now: Date,
1287
+ effectiveSettings: WarrantyClaimEffectiveSettings,
1288
+ ): void {
1289
+ if (fromStatus !== 'info_requested' || toStatus === 'info_requested' || !claim.slaPausedAt) return
1290
+ // Preserve the business time that was still remaining when the clock stopped;
1291
+ // a claim already past due when paused stays past due.
1292
+ if (claim.slaDueAt && claim.slaDueAt.getTime() > claim.slaPausedAt.getTime()) {
1293
+ const remainingBusinessMillis = businessMillisBetween(claim.slaPausedAt, claim.slaDueAt, effectiveSettings.businessHours)
1294
+ claim.slaDueAt = addBusinessMillis(now, remainingBusinessMillis, effectiveSettings.businessHours)
1295
+ }
1296
+ claim.slaPausedAt = null
1297
+ claim.slaAtRiskNotifiedAt = null
1298
+ claim.slaBreachedNotifiedAt = null
1299
+ appendClaimEvent(em, claim, 'system', {
1300
+ visibility: 'customer',
1301
+ payload: { action: 'sla_resumed' },
1302
+ actorUserId: null,
1303
+ })
1304
+ }
1305
+
1306
+ function resolveOptionalEntityId(moduleId: string, entity: string): EntityId | null {
1307
+ const registry = E as unknown as Record<string, Record<string, string> | undefined>
1308
+ const value = registry[moduleId]?.[entity]
1309
+ return typeof value === 'string' ? (value as EntityId) : null
1310
+ }
1311
+
1312
+ async function lookupSalesReference(
1313
+ ctx: CommandRuntimeContext,
1314
+ entityId: EntityId | null,
1315
+ table: SalesReferenceTable,
1316
+ scope: WarrantyClaimScope,
1317
+ id: string,
1318
+ select: string[],
1319
+ ): Promise<Record<string, unknown> | ReferenceLookupResult> {
1320
+ if (!entityId) return 'unknown'
1321
+ const row = await querySalesReferenceRow(ctx, table, scope, id, select)
1322
+ if (row === undefined) return 'unknown'
1323
+ return row ?? 'missing'
1324
+ }
1325
+
1326
+ function assertValidSalesReference(result: Record<string, unknown> | ReferenceLookupResult): asserts result is Record<string, unknown> | 'unknown' {
1327
+ if (result === 'missing') {
1328
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
1329
+ }
1330
+ }
1331
+
1332
+ export async function validateClaimReferences(
1333
+ ctx: CommandRuntimeContext,
1334
+ scope: WarrantyClaimScope,
1335
+ input: ReferenceValidationInput,
1336
+ ): Promise<void> {
1337
+ if (input.orderId) {
1338
+ const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_order'), 'sales_orders', scope, input.orderId, ['id'])
1339
+ assertValidSalesReference(result)
1340
+ }
1341
+ if (input.replacementOrderId) {
1342
+ const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_order'), 'sales_orders', scope, input.replacementOrderId, ['id'])
1343
+ assertValidSalesReference(result)
1344
+ }
1345
+ if (input.salesReturnId) {
1346
+ const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_return'), 'sales_returns', scope, input.salesReturnId, ['id'])
1347
+ assertValidSalesReference(result)
1348
+ }
1349
+ if (input.creditMemoId) {
1350
+ const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_credit_memo'), 'sales_credit_memos', scope, input.creditMemoId, ['id'])
1351
+ assertValidSalesReference(result)
1352
+ }
1353
+ const lineRefs = input.lineOrderRefs ?? []
1354
+ if (lineRefs.length === 0 || !resolveOptionalEntityId('sales', 'sales_order_line')) return
1355
+ const lineRows = await querySalesOrderLineReferences(
1356
+ ctx,
1357
+ scope,
1358
+ Array.from(new Set(lineRefs.map((lineRef) => lineRef.orderLineId))),
1359
+ )
1360
+ if (lineRows === undefined) return
1361
+ const lineRowsById = new Map(lineRows.map((row) => [readString(row, 'id'), row]))
1362
+ for (const lineRef of lineRefs) {
1363
+ const result = lineRowsById.get(lineRef.orderLineId)
1364
+ if (!result) {
1365
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
1366
+ }
1367
+ if (lineRef.orderId && readString(result, 'order_id') !== lineRef.orderId) {
1368
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ async function validateAssigneeUser(
1374
+ ctx: CommandRuntimeContext,
1375
+ scope: WarrantyClaimScope,
1376
+ assigneeUserId: string | null | undefined,
1377
+ ): Promise<void> {
1378
+ if (!assigneeUserId) return
1379
+ const assignable = await isAssignableStaffUser({
1380
+ container: ctx.container,
1381
+ tenantId: scope.tenantId,
1382
+ organizationId: scope.organizationId,
1383
+ }, assigneeUserId)
1384
+ if (!assignable) throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidAssignee' })
1385
+ }
1386
+
1387
+ function applyClaimAssignment(
1388
+ em: EntityManager,
1389
+ claim: WarrantyClaim,
1390
+ assigneeUserId: string | null,
1391
+ actorUserId: string | null,
1392
+ now: Date,
1393
+ ): void {
1394
+ claim.assigneeUserId = assigneeUserId
1395
+ claim.updatedAt = now
1396
+ appendClaimEvent(em, claim, 'assignment', {
1397
+ visibility: 'internal',
1398
+ payload: { assigneeUserId: claim.assigneeUserId },
1399
+ actorUserId,
1400
+ })
1401
+ }
1402
+
1403
+ function allowedUpdateFields(status: WarrantyClaimStatus): Set<ClaimUpdateField> {
1404
+ if (intakeStatuses.has(status)) return new Set(CLAIM_INTAKE_UPDATE_FIELDS)
1405
+ if (fulfillmentStatuses.has(status)) return new Set(CLAIM_FULFILLMENT_UPDATE_FIELDS)
1406
+ return new Set()
1407
+ }
1408
+
1409
+ function assertClaimUpdateFields(claim: WarrantyClaim, input: ClaimUpdateInput): void {
1410
+ const keys = [...CLAIM_INTAKE_UPDATE_FIELDS, ...CLAIM_FULFILLMENT_UPDATE_FIELDS]
1411
+ const requested = keys.filter((key) => hasOwn(input, key))
1412
+ if (!requested.length) return
1413
+ const allowed = allowedUpdateFields(claim.status)
1414
+ const disallowed = requested.filter((key) => !allowed.has(key))
1415
+ if (disallowed.length > 0) {
1416
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.fieldLocked' })
1417
+ }
1418
+ }
1419
+
1420
+ function applyClaimUpdate(claim: WarrantyClaim, input: ClaimUpdateInput, customerName: string | null, orderNumber: string | null): void {
1421
+ if (hasOwn(input, 'customerId')) claim.customerId = input.customerId ?? null
1422
+ if (hasOwn(input, 'customerName')) claim.customerName = customerName
1423
+ if (hasOwn(input, 'orderId')) {
1424
+ claim.orderId = input.orderId ?? null
1425
+ claim.orderNumber = orderNumber
1426
+ }
1427
+ if (hasOwn(input, 'reasonCode')) claim.reasonCode = input.reasonCode ?? null
1428
+ if (hasOwn(input, 'priority') && input.priority) claim.priority = input.priority
1429
+ if (hasOwn(input, 'notes')) claim.notes = input.notes ?? null
1430
+ if (hasOwn(input, 'advanceReplacement')) claim.advanceReplacement = input.advanceReplacement ?? false
1431
+ if (hasOwn(input, 'replacementOrderId')) claim.replacementOrderId = input.replacementOrderId ?? null
1432
+ if (hasOwn(input, 'advanceShippedAt')) claim.advanceShippedAt = input.advanceShippedAt ?? null
1433
+ if (hasOwn(input, 'salesReturnId')) claim.salesReturnId = input.salesReturnId ?? null
1434
+ if (hasOwn(input, 'creditMemoId')) claim.creditMemoId = input.creditMemoId ?? null
1435
+ if (hasOwn(input, 'vendorName')) claim.vendorName = input.vendorName ?? null
1436
+ if (hasOwn(input, 'vendorRef')) claim.vendorRef = input.vendorRef ?? null
1437
+ if (hasOwn(input, 'resolutionSummary')) claim.resolutionSummary = input.resolutionSummary ?? null
1438
+ claim.updatedAt = new Date()
1439
+ }
1440
+
1441
+ async function recomputeClaimRollups(em: EntityManager, claim: WarrantyClaim): Promise<void> {
1442
+ const lines = await findWithDecryption(
1443
+ em,
1444
+ WarrantyClaimLine,
1445
+ {
1446
+ claim: claim.id,
1447
+ tenantId: claim.tenantId,
1448
+ organizationId: claim.organizationId,
1449
+ deletedAt: null,
1450
+ },
1451
+ {},
1452
+ { tenantId: claim.tenantId, organizationId: claim.organizationId },
1453
+ )
1454
+ const totals = computeHeaderRollups(lines, { claimType: claim.claimType })
1455
+ claim.totalClaimedAmount = String(totals.totalClaimedAmount)
1456
+ claim.totalApprovedAmount = String(totals.totalApprovedAmount)
1457
+ }
1458
+
1459
+ function assertCanResolve(lines: readonly WarrantyClaimLine[]): void {
1460
+ if (!canResolveWithLineStatuses(lines)) {
1461
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
1462
+ }
1463
+ }
1464
+
1465
+ function assertVendorRecoveryLines(lines: readonly WarrantyClaimLine[], requestedLineIds: readonly string[]): void {
1466
+ if (lines.length !== requestedLineIds.length) {
1467
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.vendorRecoveryNeedsResolvedLines' })
1468
+ }
1469
+ for (const line of lines) {
1470
+ if (line.lineStatus !== 'resolved' || line.vendorClaimLineId) {
1471
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.vendorRecoveryNeedsResolvedLines' })
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ function buildClaimLog(
1477
+ actionLabel: string,
1478
+ resourceId: string,
1479
+ snapshots: { before?: unknown; after?: unknown },
1480
+ ): {
1481
+ actionLabel: string
1482
+ resourceKind: string
1483
+ resourceId: string
1484
+ tenantId: string | null
1485
+ organizationId: string | null
1486
+ snapshotBefore: unknown
1487
+ snapshotAfter: unknown
1488
+ payload: { undo: ClaimUndoPayload }
1489
+ } {
1490
+ const after = snapshots.after as ClaimSnapshot | null | undefined
1491
+ const before = snapshots.before as ClaimSnapshot | null | undefined
1492
+ return {
1493
+ actionLabel,
1494
+ resourceKind: WARRANTY_CLAIM_RESOURCE_KIND,
1495
+ resourceId,
1496
+ tenantId: after?.tenantId ?? before?.tenantId ?? null,
1497
+ organizationId: after?.organizationId ?? before?.organizationId ?? null,
1498
+ snapshotBefore: before ?? null,
1499
+ snapshotAfter: after ?? null,
1500
+ payload: { undo: { before: before ?? null, after: after ?? null } },
1501
+ }
1502
+ }
1503
+
1504
+ const createClaimCommand: CommandHandler<ClaimCreateInput, { claimId: string }> = {
1505
+ id: 'warranty_claims.claim.create',
1506
+ isUndoable: true,
1507
+ async execute(rawInput, ctx) {
1508
+ const input = parseCommandInput(claimCreateSchema, rawInput)
1509
+ const scope = resolveScope(ctx, input)
1510
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1511
+ await validateClaimReferences(ctx, scope, {
1512
+ orderId: input.orderId ?? null,
1513
+ salesReturnId: input.salesReturnId ?? null,
1514
+ replacementOrderId: input.replacementOrderId ?? null,
1515
+ creditMemoId: null,
1516
+ lineOrderRefs: (input.lines ?? [])
1517
+ .filter((line): line is ClaimInitialLineCreateInput & { orderLineId: string } => typeof line.orderLineId === 'string')
1518
+ .map((line) => ({ orderLineId: line.orderLineId, orderId: input.orderId ?? null })),
1519
+ })
1520
+ await assertOrderBelongsToCustomer(em, scope, input.orderId ?? null, input.customerId ?? null)
1521
+ const pendingLines = (input.lines ?? []).map((line) => ({
1522
+ orderLineId: line.orderLineId ?? null,
1523
+ qtyClaimed: amountString(line.qtyClaimed, '1') ?? '1',
1524
+ }))
1525
+ const pendingLinesByOrderLine = new Map<string, ClaimedQuantityLine[]>()
1526
+ for (const line of pendingLines) {
1527
+ if (!line.orderLineId) continue
1528
+ const grouped = pendingLinesByOrderLine.get(line.orderLineId) ?? []
1529
+ grouped.push(line)
1530
+ pendingLinesByOrderLine.set(line.orderLineId, grouped)
1531
+ }
1532
+ const numberGenerator = resolveClaimNumberGenerator(ctx, em)
1533
+ const generated = await numberGenerator.generate({
1534
+ claimType: input.claimType,
1535
+ tenantId: scope.tenantId,
1536
+ organizationId: scope.organizationId,
1537
+ })
1538
+ const customerName = await resolveCustomerName(ctx, input.customerId, scope, input.customerName, { strict: true })
1539
+ // A linked order derives its number from the order itself (falling back to any supplied
1540
+ // reference); without a linked order, persist the free-text reference as the display number
1541
+ // so the portal "my order isn't listed" path is captured (WQA-002).
1542
+ const orderNumber = input.orderId
1543
+ ? await resolveOrderNumber(ctx, input.orderId, scope, input.orderNumber ?? null)
1544
+ : (input.orderNumber ?? null)
1545
+ const claimId = randomUUID()
1546
+ let claim!: WarrantyClaim
1547
+ let createdLines: WarrantyClaimLine[] = []
1548
+
1549
+ await withAtomicFlush(em, [
1550
+ async () => {
1551
+ await assertPendingClaimQuantitiesWithinSold(em, scope, pendingLinesByOrderLine, { currentClaimId: claimId })
1552
+ claim = em.create(WarrantyClaim, {
1553
+ id: claimId,
1554
+ organizationId: scope.organizationId,
1555
+ tenantId: scope.tenantId,
1556
+ claimNumber: generated.number,
1557
+ claimType: input.claimType,
1558
+ status: 'draft',
1559
+ channel: input.channel ?? 'staff',
1560
+ priority: input.priority ?? 'normal',
1561
+ customerId: input.customerId ?? null,
1562
+ customerName,
1563
+ externalRef: input.externalRef ?? null,
1564
+ intakeMessageRef: input.intakeMessageRef ?? null,
1565
+ contactEmail: input.contactEmail ?? null,
1566
+ vendorName: input.vendorName ?? null,
1567
+ vendorRef: input.vendorRef ?? null,
1568
+ orderId: input.orderId ?? null,
1569
+ orderNumber,
1570
+ salesReturnId: input.salesReturnId ?? null,
1571
+ replacementOrderId: input.replacementOrderId ?? null,
1572
+ creditMemoId: null,
1573
+ sourceClaimId: null,
1574
+ advanceReplacement: input.advanceReplacement ?? false,
1575
+ advanceShippedAt: input.advanceShippedAt ?? null,
1576
+ reasonCode: input.reasonCode ?? null,
1577
+ rejectionReasonCode: input.rejectionReasonCode ?? null,
1578
+ resolutionSummary: input.resolutionSummary ?? null,
1579
+ notes: input.notes ?? null,
1580
+ currencyCode: input.currencyCode ?? null,
1581
+ totalClaimedAmount: '0',
1582
+ totalApprovedAmount: '0',
1583
+ totalRecoveredAmount: '0',
1584
+ createdAt: new Date(),
1585
+ updatedAt: new Date(),
1586
+ })
1587
+ em.persist(claim)
1588
+ createdLines = (input.lines ?? []).map((lineInput, index) => {
1589
+ const line = em.create(WarrantyClaimLine, buildInitialLineData(claim, lineInput, index))
1590
+ em.persist(line)
1591
+ return line
1592
+ })
1593
+ const totals = computeHeaderRollups(createdLines, { claimType: claim.claimType })
1594
+ claim.totalClaimedAmount = String(totals.totalClaimedAmount)
1595
+ claim.totalApprovedAmount = String(totals.totalApprovedAmount)
1596
+ appendClaimEvent(em, claim, 'system', {
1597
+ visibility: 'internal',
1598
+ payload: { action: 'created' },
1599
+ actorUserId: ctx.auth?.sub ?? null,
1600
+ })
1601
+ },
1602
+ () => stampEntitlementSourceIfResolvable(ctx, em, claim, input, scope),
1603
+ ], { transaction: true, label: 'warranty_claims.claim.create' })
1604
+
1605
+ await emitClaimCrud(ctx, 'created', claim)
1606
+ await Promise.all(createdLines.map((line) => emitLineCrud(ctx, 'created', line)))
1607
+ return { claimId: claim.id }
1608
+ },
1609
+ captureAfter: async (rawInput, result, ctx) => {
1610
+ const input = parseCommandInput(claimCreateSchema, rawInput)
1611
+ const scope = resolveScope(ctx, input)
1612
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1613
+ return loadClaimSnapshot(em, result.claimId, scope)
1614
+ },
1615
+ buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.create', result.claimId, snapshots),
1616
+ undo: async ({ logEntry, ctx }) => {
1617
+ const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
1618
+ const after = payload?.after
1619
+ if (!after) return
1620
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1621
+ let claim!: WarrantyClaim
1622
+ let lines: WarrantyClaimLine[] = []
1623
+ await withAtomicFlush(em, [
1624
+ async () => {
1625
+ const current = await requireCurrentUndoSnapshot(em, after)
1626
+ claim = current.claim
1627
+ lines = current.lines.filter((line) => !line.deletedAt)
1628
+ const now = new Date()
1629
+ claim.deletedAt = now
1630
+ claim.updatedAt = now
1631
+ for (const line of lines) {
1632
+ line.deletedAt = now
1633
+ line.updatedAt = now
1634
+ }
1635
+ },
1636
+ ], { transaction: true, label: 'warranty_claims.claim.create.undo' })
1637
+ await emitClaimUndoCrud(ctx, 'deleted', claim)
1638
+ await Promise.all(lines.map((line) => emitLineUndoCrud(ctx, 'deleted', line)))
1639
+ },
1640
+ }
1641
+
1642
+ const updateClaimCommand: CommandHandler<ClaimUpdateInput, { claimId: string }> = {
1643
+ id: 'warranty_claims.claim.update',
1644
+ isUndoable: true,
1645
+ prepare: async (rawInput, ctx) => {
1646
+ const input = parseCommandInput(claimUpdateSchema, rawInput)
1647
+ const scope = resolveScope(ctx, input)
1648
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1649
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
1650
+ },
1651
+ async execute(rawInput, ctx) {
1652
+ const input = parseCommandInput(claimUpdateSchema, rawInput)
1653
+ const scope = resolveScope(ctx, input)
1654
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1655
+ const claim = await requireScopedClaim(em, input.id, scope)
1656
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
1657
+ assertClaimUpdateFields(claim, input)
1658
+ await validateClaimReferences(ctx, scope, {
1659
+ orderId: hasOwn(input, 'orderId') && (input.orderId ?? null) !== (claim.orderId ?? null) ? input.orderId ?? null : null,
1660
+ salesReturnId: hasOwn(input, 'salesReturnId') && (input.salesReturnId ?? null) !== (claim.salesReturnId ?? null) ? input.salesReturnId ?? null : null,
1661
+ replacementOrderId: hasOwn(input, 'replacementOrderId') && (input.replacementOrderId ?? null) !== (claim.replacementOrderId ?? null) ? input.replacementOrderId ?? null : null,
1662
+ creditMemoId: hasOwn(input, 'creditMemoId') && (input.creditMemoId ?? null) !== (claim.creditMemoId ?? null) ? input.creditMemoId ?? null : null,
1663
+ })
1664
+ const effectiveOrderId = hasOwn(input, 'orderId') ? (input.orderId ?? null) : (claim.orderId ?? null)
1665
+ const effectiveCustomerId = hasOwn(input, 'customerId') ? (input.customerId ?? null) : (claim.customerId ?? null)
1666
+ await assertOrderBelongsToCustomer(em, scope, effectiveOrderId, effectiveCustomerId)
1667
+ const customerChanged = hasOwn(input, 'customerId') && (input.customerId ?? null) !== (claim.customerId ?? null)
1668
+ const shouldRefreshCustomerName = hasOwn(input, 'customerId') || hasOwn(input, 'customerName')
1669
+ const customerName = shouldRefreshCustomerName
1670
+ ? await resolveCustomerName(ctx, input.customerId ?? claim.customerId, scope, input.customerName ?? claim.customerName, { strict: customerChanged })
1671
+ : claim.customerName ?? null
1672
+ const orderNumber = hasOwn(input, 'orderId')
1673
+ ? await resolveOrderNumber(ctx, input.orderId, scope, (input.orderId ?? null) === (claim.orderId ?? null) ? claim.orderNumber : null)
1674
+ : claim.orderNumber ?? null
1675
+ await withAtomicFlush(em, [
1676
+ () => applyClaimUpdate(claim, input, customerName, orderNumber),
1677
+ ], { transaction: true, label: 'warranty_claims.claim.update' })
1678
+ await emitClaimCrud(ctx, 'updated', claim)
1679
+ return { claimId: claim.id }
1680
+ },
1681
+ captureAfter: async (rawInput, result, ctx) => {
1682
+ const input = parseCommandInput(claimUpdateSchema, rawInput)
1683
+ const scope = resolveScope(ctx, input)
1684
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1685
+ return loadClaimSnapshot(em, result.claimId, scope)
1686
+ },
1687
+ buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.update', result.claimId, snapshots),
1688
+ undo: async ({ logEntry, ctx }) => {
1689
+ const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
1690
+ const before = payload?.before
1691
+ if (!before) return
1692
+ const after = payload?.after
1693
+ if (!after) throwClaimUndoConflict()
1694
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1695
+ let claim!: WarrantyClaim
1696
+ await withAtomicFlush(em, [
1697
+ async () => {
1698
+ claim = await restoreSnapshot(em, before, after)
1699
+ },
1700
+ ], { transaction: true, label: 'warranty_claims.claim.update.undo' })
1701
+ await emitClaimUndoCrud(ctx, 'updated', claim)
1702
+ },
1703
+ }
1704
+
1705
+ const deleteClaimCommand: CommandHandler<ClaimDeleteInput, { claimId: string }> = {
1706
+ id: 'warranty_claims.claim.delete',
1707
+ isUndoable: true,
1708
+ prepare: async (rawInput, ctx) => {
1709
+ const input = parseCommandInput(claimDeleteSchema, rawInput)
1710
+ const scope = resolveScope(ctx, input)
1711
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1712
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
1713
+ },
1714
+ async execute(rawInput, ctx) {
1715
+ const input = parseCommandInput(claimDeleteSchema, rawInput)
1716
+ const scope = resolveScope(ctx, input)
1717
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1718
+ const claim = await requireScopedClaim(em, input.id, scope)
1719
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
1720
+ if (!deletableStatuses.has(claim.status)) {
1721
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.deleteNotAllowed' })
1722
+ }
1723
+ const lines = await findWithDecryption(
1724
+ em,
1725
+ WarrantyClaimLine,
1726
+ { claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
1727
+ {},
1728
+ scope,
1729
+ )
1730
+ await withAtomicFlush(em, [
1731
+ () => {
1732
+ const now = new Date()
1733
+ claim.deletedAt = now
1734
+ claim.updatedAt = now
1735
+ for (const line of lines) {
1736
+ line.deletedAt = now
1737
+ line.updatedAt = now
1738
+ }
1739
+ },
1740
+ ], { transaction: true, label: 'warranty_claims.claim.delete' })
1741
+ await emitClaimCrud(ctx, 'deleted', claim)
1742
+ await Promise.all(lines.map((line) => emitLineCrud(ctx, 'deleted', line)))
1743
+ return { claimId: claim.id }
1744
+ },
1745
+ captureAfter: async (rawInput, result, ctx) => {
1746
+ const input = parseCommandInput(claimDeleteSchema, rawInput)
1747
+ const scope = resolveScope(ctx, input)
1748
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1749
+ return loadClaimSnapshot(em, result.claimId, scope)
1750
+ },
1751
+ buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.delete', result.claimId, snapshots),
1752
+ undo: async ({ logEntry, ctx }) => {
1753
+ const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
1754
+ const before = payload?.before
1755
+ if (!before) return
1756
+ const after = payload?.after
1757
+ if (!after) throwClaimUndoConflict()
1758
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1759
+ let claim!: WarrantyClaim
1760
+ await withAtomicFlush(em, [
1761
+ async () => {
1762
+ claim = await restoreSnapshot(em, before, after)
1763
+ },
1764
+ ], { transaction: true, label: 'warranty_claims.claim.delete.undo' })
1765
+ await emitClaimUndoCrud(ctx, 'created', claim)
1766
+ const restoredLines = await findWithDecryption(
1767
+ em,
1768
+ WarrantyClaimLine,
1769
+ { claim: claim.id, tenantId: before.tenantId, organizationId: before.organizationId, deletedAt: null },
1770
+ {},
1771
+ { tenantId: before.tenantId, organizationId: before.organizationId },
1772
+ )
1773
+ await Promise.all(restoredLines.map((line) => emitLineCrud(ctx, 'created', line)))
1774
+ },
1775
+ }
1776
+
1777
+ const submitClaimCommand: CommandHandler<SubmitClaimInput, { claimId: string }> = {
1778
+ id: 'warranty_claims.claim.submit',
1779
+ async execute(rawInput, ctx) {
1780
+ const input = parseCommandInput(submitClaimSchema, rawInput)
1781
+ const scope = resolveScope(ctx, input)
1782
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1783
+ const claim = await requireScopedClaim(em, input.id, scope)
1784
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
1785
+ if (claim.status !== 'draft') {
1786
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
1787
+ }
1788
+ const fromStatus = claim.status
1789
+ let autoApproved = false
1790
+ let effectiveSettings: WarrantyClaimEffectiveSettings | null = null
1791
+ await withAtomicFlush(em, [
1792
+ async () => {
1793
+ effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
1794
+ const submittedAt = new Date()
1795
+ claim.status = 'submitted'
1796
+ claim.submittedAt = submittedAt
1797
+ claim.slaDueAt = addBusinessMillis(submittedAt, effectiveSettings.slaHours * 60 * 60 * 1000, effectiveSettings.businessHours)
1798
+ claim.slaAtRiskNotifiedAt = null
1799
+ claim.slaBreachedNotifiedAt = null
1800
+ claim.updatedAt = submittedAt
1801
+ appendClaimEvent(em, claim, 'status_changed', {
1802
+ visibility: 'customer',
1803
+ payload: { from: fromStatus, to: 'submitted' },
1804
+ actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
1805
+ actorCustomerId: input.actorCustomerId ?? null,
1806
+ })
1807
+ },
1808
+ async () => {
1809
+ const lines = await findWithDecryption(
1810
+ em,
1811
+ WarrantyClaimLine,
1812
+ {
1813
+ claim: claim.id,
1814
+ tenantId: claim.tenantId,
1815
+ organizationId: claim.organizationId,
1816
+ deletedAt: null,
1817
+ },
1818
+ {},
1819
+ scope,
1820
+ )
1821
+ const risk = await evaluateClaimRisk(em, claim, lines)
1822
+ const settings = effectiveSettings ?? await resolveEffectiveWarrantyClaimSettings(em, scope)
1823
+ const evaluator = ctx.container.resolve<WarrantyAdjudicationEvaluator>('warrantyAdjudicationEvaluator')
1824
+ const { decision } = await evaluator.evaluate({
1825
+ claim,
1826
+ lines,
1827
+ settings,
1828
+ risk,
1829
+ container: ctx.container,
1830
+ em,
1831
+ scope,
1832
+ })
1833
+ if (decision === 'auto_approve') {
1834
+ claim.status = 'approved'
1835
+ claim.updatedAt = new Date()
1836
+ autoApproved = true
1837
+ appendClaimEvent(em, claim, 'status_changed', {
1838
+ visibility: 'customer',
1839
+ payload: { from: 'submitted', to: 'approved' },
1840
+ actorUserId: null,
1841
+ })
1842
+ appendClaimEvent(em, claim, 'system', {
1843
+ visibility: 'internal',
1844
+ payload: {
1845
+ action: 'auto_approved',
1846
+ maxAmount: settings.autoApproveMaxAmount,
1847
+ currencyCode: settings.autoApproveCurrencyCode,
1848
+ },
1849
+ actorUserId: null,
1850
+ })
1851
+ }
1852
+ },
1853
+ ], { transaction: true, label: 'warranty_claims.claim.submit' })
1854
+ await emitClaimCrud(ctx, 'updated', claim)
1855
+ const payload = claimEventPayload(claim)
1856
+ await emitWarrantyClaimsEvent('warranty_claims.claim.submitted', payload, claimPersistentEmitOptions(claim))
1857
+ await emitClaimStatusChanged(ctx, claim, autoApproved ? 'submitted' : fromStatus, autoApproved ? 'approved' : 'submitted')
1858
+ return { claimId: claim.id }
1859
+ },
1860
+ }
1861
+
1862
+ const transitionClaimCommand: CommandHandler<TransitionClaimInput, { claimId: string }> = {
1863
+ id: 'warranty_claims.claim.transition',
1864
+ isUndoable: true,
1865
+ prepare: async (rawInput, ctx) => {
1866
+ const input = parseCommandInput(transitionClaimInputSchema, rawInput)
1867
+ const scope = resolveScope(ctx, {})
1868
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1869
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
1870
+ },
1871
+ async execute(rawInput, ctx) {
1872
+ const input = parseCommandInput(transitionClaimInputSchema, rawInput)
1873
+ const scope = resolveScope(ctx, {})
1874
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1875
+ const claim = await requireScopedClaim(em, input.id, scope)
1876
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
1877
+ const fromStatus = claim.status
1878
+ assertTransition(fromStatus, input.toStatus)
1879
+ if (input.toStatus === 'cancelled' && !preReceivedStatuses.has(fromStatus)) {
1880
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
1881
+ }
1882
+ if (input.toStatus === 'rejected' && !input.rejectionReasonCode) {
1883
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
1884
+ }
1885
+ const lines = input.toStatus === 'resolved'
1886
+ ? await findWithDecryption(
1887
+ em,
1888
+ WarrantyClaimLine,
1889
+ {
1890
+ claim: claim.id,
1891
+ tenantId: claim.tenantId,
1892
+ organizationId: claim.organizationId,
1893
+ deletedAt: null,
1894
+ },
1895
+ {},
1896
+ { tenantId: claim.tenantId, organizationId: claim.organizationId },
1897
+ )
1898
+ : []
1899
+ if (input.toStatus === 'resolved') assertCanResolve(lines)
1900
+ await withAtomicFlush(em, [
1901
+ async () => {
1902
+ const effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
1903
+ const now = new Date()
1904
+ claim.status = input.toStatus
1905
+ if (input.toStatus === 'rejected') claim.rejectionReasonCode = input.rejectionReasonCode ?? null
1906
+ if (input.toStatus === 'resolved') {
1907
+ claim.resolvedAt = now
1908
+ if (hasOwn(input, 'resolutionSummary')) claim.resolutionSummary = input.resolutionSummary ?? null
1909
+ }
1910
+ if (input.toStatus === 'closed') claim.closedAt = now
1911
+ applySlaResume(em, claim, fromStatus, input.toStatus, now, effectiveSettings)
1912
+ applySlaPause(em, claim, fromStatus, input.toStatus, now, effectiveSettings)
1913
+ claim.awaitingStaffReply = false
1914
+ claim.updatedAt = now
1915
+ appendClaimEvent(em, claim, 'status_changed', {
1916
+ visibility: 'customer',
1917
+ payload: {
1918
+ from: fromStatus,
1919
+ to: input.toStatus,
1920
+ ...(input.systemNote ? { systemNote: input.systemNote } : {}),
1921
+ },
1922
+ actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
1923
+ actorCustomerId: input.actorCustomerId ?? null,
1924
+ })
1925
+ },
1926
+ ], { transaction: true, label: 'warranty_claims.claim.transition' })
1927
+ await emitClaimCrud(ctx, 'updated', claim)
1928
+ await emitClaimStatusChanged(ctx, claim, fromStatus, input.toStatus)
1929
+ return { claimId: claim.id }
1930
+ },
1931
+ captureAfter: async (_rawInput, result, ctx) => {
1932
+ const scope = resolveScope(ctx, {})
1933
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1934
+ return loadClaimSnapshot(em, result.claimId, scope)
1935
+ },
1936
+ buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.transition', result.claimId, snapshots),
1937
+ undo: async ({ logEntry, ctx }) => {
1938
+ const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
1939
+ const before = payload?.before
1940
+ if (!before) return
1941
+ const after = payload?.after
1942
+ if (!after) throwClaimUndoConflict()
1943
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1944
+ let claim!: WarrantyClaim
1945
+ await withAtomicFlush(em, [
1946
+ async () => {
1947
+ claim = await restoreSnapshot(em, before, after)
1948
+ appendClaimEvent(em, claim, 'system', {
1949
+ visibility: 'internal',
1950
+ payload: { action: 'undo_status_transition' },
1951
+ actorUserId: ctx.auth?.sub ?? null,
1952
+ })
1953
+ },
1954
+ ], { transaction: true, label: 'warranty_claims.claim.transition.undo' })
1955
+ await emitClaimUndoCrud(ctx, 'updated', claim)
1956
+ },
1957
+ }
1958
+
1959
+ const assignClaimCommand: CommandHandler<AssignClaimInput, { claimId: string }> = {
1960
+ id: 'warranty_claims.claim.assign',
1961
+ async execute(rawInput, ctx) {
1962
+ const input = parseCommandInput(assignClaimInputSchema, rawInput)
1963
+ const scope = resolveScope(ctx, {})
1964
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1965
+ const claim = await requireScopedClaim(em, input.id, scope)
1966
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
1967
+ await validateAssigneeUser(ctx, scope, input.assigneeUserId ?? null)
1968
+ await withAtomicFlush(em, [
1969
+ () => {
1970
+ applyClaimAssignment(em, claim, input.assigneeUserId ?? null, ctx.auth?.sub ?? null, new Date())
1971
+ },
1972
+ ], { transaction: true, label: 'warranty_claims.claim.assign' })
1973
+ await emitClaimCrud(ctx, 'updated', claim)
1974
+ await emitWarrantyClaimsEvent('warranty_claims.claim.assigned', {
1975
+ ...claimEventPayload(claim),
1976
+ assigneeUserId: claim.assigneeUserId,
1977
+ }, claimPersistentEmitOptions(claim))
1978
+ return { claimId: claim.id }
1979
+ },
1980
+ }
1981
+
1982
+ const setReturnLabelCommand: CommandHandler<ClaimSetReturnLabelInput, ClaimSetReturnLabelResult> = {
1983
+ id: 'warranty_claims.claim.set_return_label',
1984
+ isUndoable: true,
1985
+ prepare: async (rawInput, ctx) => {
1986
+ const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
1987
+ const scope = resolveScope(ctx, input)
1988
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1989
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
1990
+ },
1991
+ async execute(rawInput, ctx) {
1992
+ const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
1993
+ const scope = resolveScope(ctx, input)
1994
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1995
+ const claim = await requireScopedClaim(em, input.id, scope)
1996
+ await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
1997
+
1998
+ await withAtomicFlush(em, [
1999
+ () => {
2000
+ if (input.labelUrl !== undefined) claim.returnLabelUrl = input.labelUrl
2001
+ if (input.trackingNumber !== undefined) claim.returnTrackingNumber = input.trackingNumber
2002
+ if (input.carrier !== undefined) claim.returnCarrier = input.carrier
2003
+ claim.updatedAt = new Date()
2004
+ appendClaimEvent(em, claim, 'system', {
2005
+ visibility: 'internal',
2006
+ payload: {
2007
+ action: 'return_label_created',
2008
+ carrier: claim.returnCarrier ?? null,
2009
+ trackingNumber: claim.returnTrackingNumber ?? null,
2010
+ },
2011
+ actorUserId: ctx.auth?.sub ?? null,
2012
+ })
2013
+ },
2014
+ ], { transaction: true, label: 'warranty_claims.claim.set_return_label' })
2015
+
2016
+ await emitClaimCrud(ctx, 'updated', claim)
2017
+ await emitWarrantyClaimsEvent('warranty_claims.claim.return_label_created', {
2018
+ id: claim.id,
2019
+ claimId: claim.id,
2020
+ carrier: claim.returnCarrier ?? null,
2021
+ trackingNumber: claim.returnTrackingNumber ?? null,
2022
+ scope,
2023
+ organizationId: scope.organizationId,
2024
+ tenantId: scope.tenantId,
2025
+ }, claimPersistentEmitOptions(claim))
2026
+
2027
+ return {
2028
+ claimId: claim.id,
2029
+ labelUrl: claim.returnLabelUrl ?? null,
2030
+ trackingNumber: claim.returnTrackingNumber ?? null,
2031
+ carrier: claim.returnCarrier ?? null,
2032
+ }
2033
+ },
2034
+ captureAfter: async (rawInput, result, ctx) => {
2035
+ const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
2036
+ const scope = resolveScope(ctx, input)
2037
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2038
+ return loadClaimSnapshot(em, result.claimId, scope)
2039
+ },
2040
+ buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.set_return_label', result.claimId, snapshots),
2041
+ undo: async ({ logEntry, ctx }) => {
2042
+ const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
2043
+ const before = payload?.before
2044
+ if (!before) return
2045
+ const after = payload?.after
2046
+ if (!after) throwClaimUndoConflict()
2047
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2048
+ let claim!: WarrantyClaim
2049
+ await withAtomicFlush(em, [
2050
+ async () => {
2051
+ claim = await restoreSnapshot(em, before, after)
2052
+ appendClaimEvent(em, claim, 'system', {
2053
+ visibility: 'internal',
2054
+ payload: { action: 'undo_return_label' },
2055
+ actorUserId: ctx.auth?.sub ?? null,
2056
+ })
2057
+ },
2058
+ ], { transaction: true, label: 'warranty_claims.claim.set_return_label.undo' })
2059
+ await emitClaimUndoCrud(ctx, 'updated', claim)
2060
+ },
2061
+ }
2062
+
2063
+ const escalateClaimCommand: CommandHandler<EscalateClaimInput, EscalateClaimResult> = {
2064
+ id: 'warranty_claims.claim.escalate',
2065
+ async execute(rawInput, ctx) {
2066
+ const input = parseCommandInput(escalateClaimInputSchema, rawInput)
2067
+ const scope = resolveScope(ctx, input)
2068
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2069
+ if (input.reassignToUserId) {
2070
+ await validateAssigneeUser(ctx, scope, input.reassignToUserId)
2071
+ }
2072
+
2073
+ let claim: WarrantyClaim | null = null
2074
+ let escalated = false
2075
+ await withAtomicFlush(em, [
2076
+ async () => {
2077
+ claim = await requireScopedClaim(em, input.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
2078
+ if ((claim.escalationLevel ?? 0) >= input.toLevel) return
2079
+
2080
+ const now = new Date()
2081
+ claim.escalationLevel = input.toLevel
2082
+ claim.escalatedAt = now
2083
+ claim.updatedAt = now
2084
+ appendClaimEvent(em, claim, 'system', {
2085
+ visibility: 'internal',
2086
+ payload: {
2087
+ action: 'sla_escalated',
2088
+ level: input.toLevel,
2089
+ reassignToUserId: input.reassignToUserId ?? null,
2090
+ },
2091
+ actorUserId: ctx.auth?.sub ?? null,
2092
+ })
2093
+ if (input.reassignToUserId) {
2094
+ applyClaimAssignment(em, claim, input.reassignToUserId, ctx.auth?.sub ?? null, now)
2095
+ }
2096
+ escalated = true
2097
+ },
2098
+ ], { transaction: true, label: 'warranty_claims.claim.escalate' })
2099
+
2100
+ const escalatedClaim = claim as WarrantyClaim | null
2101
+ if (!escalatedClaim) {
2102
+ throw new CrudHttpError(404, { error: 'warranty_claims.errors.notFound' })
2103
+ }
2104
+ if (!escalated) {
2105
+ return { claimId: escalatedClaim.id, escalationLevel: escalatedClaim.escalationLevel ?? 0, escalated: false }
2106
+ }
2107
+
2108
+ await emitClaimCrud(ctx, 'updated', escalatedClaim)
2109
+ if (input.reassignToUserId) {
2110
+ await emitWarrantyClaimsEvent('warranty_claims.claim.assigned', {
2111
+ ...claimEventPayload(escalatedClaim),
2112
+ assigneeUserId: escalatedClaim.assigneeUserId ?? null,
2113
+ }, claimPersistentEmitOptions(escalatedClaim))
2114
+ }
2115
+ await emitWarrantyClaimsEvent('warranty_claims.claim.escalated', {
2116
+ ...claimEventPayload(escalatedClaim),
2117
+ level: escalatedClaim.escalationLevel,
2118
+ escalationLevel: escalatedClaim.escalationLevel,
2119
+ reassignToUserId: input.reassignToUserId ?? null,
2120
+ }, claimPersistentEmitOptions(escalatedClaim))
2121
+
2122
+ return { claimId: escalatedClaim.id, escalationLevel: escalatedClaim.escalationLevel, escalated: true }
2123
+ },
2124
+ }
2125
+
2126
+ const commentClaimCommand: CommandHandler<CommentClaimInput, { claimId: string }> = {
2127
+ id: 'warranty_claims.claim.comment',
2128
+ async execute(rawInput, ctx) {
2129
+ const input = parseCommandInput(commentClaimInputSchema, rawInput)
2130
+ const scope = resolveScope(ctx, {})
2131
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2132
+ const claim = await requireScopedClaim(em, input.claimId, scope)
2133
+ await enforceWarrantyClaimOptimisticLock(ctx, claim)
2134
+ let autoResumed = false
2135
+ await withAtomicFlush(em, [
2136
+ async () => {
2137
+ const fromStatus = claim.status
2138
+ appendClaimEvent(em, claim, 'comment', {
2139
+ visibility: input.visibility,
2140
+ body: input.body,
2141
+ actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
2142
+ actorCustomerId: input.actorCustomerId ?? null,
2143
+ })
2144
+ claim.awaitingStaffReply = Boolean(input.actorCustomerId)
2145
+ claim.updatedAt = new Date()
2146
+ if (input.actorCustomerId && fromStatus === 'info_requested') {
2147
+ const now = new Date()
2148
+ const effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
2149
+ claim.status = 'in_review'
2150
+ applySlaResume(em, claim, fromStatus, 'in_review', now, effectiveSettings)
2151
+ claim.updatedAt = now
2152
+ autoResumed = true
2153
+ appendClaimEvent(em, claim, 'status_changed', {
2154
+ visibility: 'customer',
2155
+ payload: { from: fromStatus, to: 'in_review' },
2156
+ actorUserId: null,
2157
+ })
2158
+ }
2159
+ },
2160
+ ], { transaction: true, label: 'warranty_claims.claim.comment' })
2161
+ if (input.visibility === 'customer') {
2162
+ await emitCustomerVisibleCommentAdded(ctx, claim, input)
2163
+ }
2164
+ await emitClaimCrud(ctx, 'updated', claim)
2165
+ if (autoResumed) {
2166
+ await emitClaimStatusChanged(ctx, claim, 'info_requested', 'in_review')
2167
+ }
2168
+ return { claimId: claim.id }
2169
+ },
2170
+ }
2171
+
2172
+ const createVendorRecoveryCommand: CommandHandler<VendorRecoveryInput, { claimId: string }> = {
2173
+ id: 'warranty_claims.claim.create_vendor_recovery',
2174
+ async execute(rawInput, ctx) {
2175
+ const input = parseCommandInput(vendorRecoveryInputSchema, rawInput)
2176
+ const scope = resolveScope(ctx, input)
2177
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2178
+ const sourceClaim = await requireScopedClaim(em, input.claimId, scope)
2179
+ await enforceWarrantyClaimOptimisticLock(ctx, sourceClaim)
2180
+ const preflightLines = await findWithDecryption(
2181
+ em,
2182
+ WarrantyClaimLine,
2183
+ {
2184
+ id: { $in: input.lineIds },
2185
+ claim: sourceClaim.id,
2186
+ tenantId: scope.tenantId,
2187
+ organizationId: scope.organizationId,
2188
+ deletedAt: null,
2189
+ },
2190
+ {},
2191
+ scope,
2192
+ )
2193
+ assertVendorRecoveryLines(preflightLines, input.lineIds)
2194
+ const numberGenerator = resolveClaimNumberGenerator(ctx, em)
2195
+ const generated = await numberGenerator.generate({
2196
+ claimType: 'vendor_recovery',
2197
+ tenantId: scope.tenantId,
2198
+ organizationId: scope.organizationId,
2199
+ })
2200
+ let recoveryClaim!: WarrantyClaim
2201
+ let copiedLines: WarrantyClaimLine[] = []
2202
+ let updatedSourceLines: WarrantyClaimLine[] = []
2203
+
2204
+ await em.transactional(async (tx) => {
2205
+ const lockedSource = await requireScopedClaim(tx, sourceClaim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
2206
+ const lockedLines = await findWithDecryption(
2207
+ tx,
2208
+ WarrantyClaimLine,
2209
+ {
2210
+ id: { $in: input.lineIds },
2211
+ claim: lockedSource.id,
2212
+ tenantId: scope.tenantId,
2213
+ organizationId: scope.organizationId,
2214
+ deletedAt: null,
2215
+ },
2216
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
2217
+ scope,
2218
+ )
2219
+ assertVendorRecoveryLines(lockedLines, input.lineIds)
2220
+ updatedSourceLines = lockedLines
2221
+ recoveryClaim = tx.create(WarrantyClaim, {
2222
+ id: randomUUID(),
2223
+ organizationId: scope.organizationId,
2224
+ tenantId: scope.tenantId,
2225
+ claimNumber: generated.number,
2226
+ claimType: 'vendor_recovery',
2227
+ status: 'draft',
2228
+ channel: 'staff',
2229
+ priority: lockedSource.priority,
2230
+ customerId: lockedSource.customerId ?? null,
2231
+ customerName: lockedSource.customerName ?? null,
2232
+ vendorName: input.vendorName,
2233
+ vendorRef: input.vendorRef ?? null,
2234
+ sourceClaimId: lockedSource.id,
2235
+ advanceReplacement: false,
2236
+ currencyCode: lockedSource.currencyCode ?? null,
2237
+ totalClaimedAmount: '0',
2238
+ totalApprovedAmount: '0',
2239
+ totalRecoveredAmount: '0',
2240
+ createdAt: new Date(),
2241
+ updatedAt: new Date(),
2242
+ })
2243
+ tx.persist(recoveryClaim)
2244
+ const lineOrder = new Map(input.lineIds.map((id, index) => [id, index]))
2245
+ copiedLines = [...lockedLines]
2246
+ .sort((left, right) => (lineOrder.get(left.id) ?? 0) - (lineOrder.get(right.id) ?? 0))
2247
+ .map((sourceLine, index) => {
2248
+ const copiedLine = tx.create(WarrantyClaimLine, {
2249
+ id: randomUUID(),
2250
+ claim: recoveryClaim,
2251
+ organizationId: scope.organizationId,
2252
+ tenantId: scope.tenantId,
2253
+ lineNo: index + 1,
2254
+ productId: sourceLine.productId ?? null,
2255
+ variantId: sourceLine.variantId ?? null,
2256
+ sku: sourceLine.sku ?? null,
2257
+ productName: sourceLine.productName ?? null,
2258
+ orderLineId: sourceLine.orderLineId ?? null,
2259
+ serialNumber: sourceLine.serialNumber ?? null,
2260
+ lotNumber: sourceLine.lotNumber ?? null,
2261
+ purchaseDate: sourceLine.purchaseDate ?? null,
2262
+ warrantyMonths: sourceLine.warrantyMonths ?? null,
2263
+ warrantyExpiresAt: sourceLine.warrantyExpiresAt ?? null,
2264
+ warrantyStatus: sourceLine.warrantyStatus,
2265
+ faultCode: sourceLine.faultCode ?? null,
2266
+ faultDescription: sourceLine.faultDescription ?? null,
2267
+ qtyClaimed: sourceLine.qtyApproved ?? sourceLine.qtyClaimed,
2268
+ qtyApproved: sourceLine.qtyApproved ?? null,
2269
+ conditionOnReceipt: sourceLine.conditionOnReceipt ?? null,
2270
+ inspectionNotes: sourceLine.inspectionNotes ?? null,
2271
+ disposition: sourceLine.disposition ?? null,
2272
+ lineStatus: 'pending',
2273
+ creditAmount: sourceLine.creditAmount ?? null,
2274
+ restockingFee: sourceLine.restockingFee ?? null,
2275
+ coreChargeAmount: sourceLine.coreChargeAmount ?? null,
2276
+ coreCreditAmount: sourceLine.coreCreditAmount ?? null,
2277
+ createdAt: new Date(),
2278
+ updatedAt: new Date(),
2279
+ })
2280
+ sourceLine.vendorClaimLineId = copiedLine.id
2281
+ sourceLine.updatedAt = new Date()
2282
+ tx.persist(copiedLine)
2283
+ return copiedLine
2284
+ })
2285
+ const totals = computeHeaderRollups(copiedLines)
2286
+ recoveryClaim.totalClaimedAmount = String(totals.totalClaimedAmount)
2287
+ recoveryClaim.totalApprovedAmount = String(totals.totalApprovedAmount)
2288
+ appendClaimEvent(tx, lockedSource, 'system', {
2289
+ visibility: 'internal',
2290
+ payload: { action: 'vendor_recovery_created', recoveryClaimId: recoveryClaim.id },
2291
+ actorUserId: ctx.auth?.sub ?? null,
2292
+ })
2293
+ appendClaimEvent(tx, recoveryClaim, 'system', {
2294
+ visibility: 'internal',
2295
+ payload: { action: 'created_from_source_claim', sourceClaimId: lockedSource.id },
2296
+ actorUserId: ctx.auth?.sub ?? null,
2297
+ })
2298
+ await tx.flush()
2299
+ })
2300
+
2301
+ await emitClaimCrud(ctx, 'created', recoveryClaim)
2302
+ await Promise.all(copiedLines.map((line) => emitLineCrud(ctx, 'created', line)))
2303
+ await Promise.all(updatedSourceLines.map((line) => emitLineCrud(ctx, 'updated', line)))
2304
+ await Promise.all(input.lineIds.map((sourceLineId) => invalidateCrudCache(
2305
+ ctx.container,
2306
+ 'warranty_claims.claim_line',
2307
+ { id: sourceLineId, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },
2308
+ ctx.auth?.tenantId ?? null,
2309
+ 'warranty_claims.vendor_recovery.source_lines',
2310
+ )))
2311
+ return { claimId: recoveryClaim.id }
2312
+ },
2313
+ }
2314
+
2315
+ const salesReturnBridgeLogger = createLogger('warranty_claims')
2316
+
2317
+ const SALES_RETURN_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return', 'received', 'inspecting'])
2318
+ const SALES_RETURN_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['approved', 'received', 'inspected', 'resolved'])
2319
+
2320
+ type ClaimSalesReturnUndoPayload = ClaimUndoPayload & {
2321
+ salesReturn?: { id: string; orderId: string; updatedAt: string | null } | null
2322
+ }
2323
+
2324
+ type ClaimCreateSalesReturnResult = {
2325
+ claimId: string
2326
+ salesReturnId: string
2327
+ salesReturnUpdatedAt: string | null
2328
+ skippedLineIds: string[]
2329
+ }
2330
+
2331
+ function scrubbedDispatchContext(ctx: CommandRuntimeContext): CommandRuntimeContext {
2332
+ return { ...ctx, request: undefined }
2333
+ }
2334
+
2335
+ function isSalesQuantityRejection(err: unknown): boolean {
2336
+ if (!(err instanceof CrudHttpError) || err.status !== 400) return false
2337
+ const body = err.body as { error?: unknown } | null
2338
+ const message = typeof body?.error === 'string' ? body.error : ''
2339
+ return message.includes('quantityExceedsShipped') || message.includes('Cannot return more than the shipped quantity')
2340
+ }
2341
+
2342
+ async function dispatchSalesReturnDelete(
2343
+ ctx: CommandRuntimeContext,
2344
+ scope: WarrantyClaimScope,
2345
+ salesReturnId: string,
2346
+ orderId: string,
2347
+ ): Promise<void> {
2348
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
2349
+ await commandBus.execute(
2350
+ 'sales.returns.delete',
2351
+ {
2352
+ input: { id: salesReturnId, orderId, tenantId: scope.tenantId, organizationId: scope.organizationId },
2353
+ ctx: scrubbedDispatchContext(ctx),
2354
+ },
2355
+ )
2356
+ }
2357
+
2358
+ const createSalesReturnCommand: CommandHandler<ClaimCreateSalesReturnInput, ClaimCreateSalesReturnResult> = {
2359
+ id: 'warranty_claims.claim.create_sales_return',
2360
+ isUndoable: true,
2361
+ prepare: async (rawInput, ctx) => {
2362
+ const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
2363
+ const scope = resolveScope(ctx, input)
2364
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2365
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
2366
+ },
2367
+ async execute(rawInput, ctx) {
2368
+ const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
2369
+ const scope = resolveScope(ctx, input)
2370
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2371
+ const claim = await requireScopedClaim(em, input.id, scope)
2372
+ await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
2373
+ if (!claim.orderId) {
2374
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnRequiresOrder' })
2375
+ }
2376
+ if (!SALES_RETURN_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
2377
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnStatusNotEligible' })
2378
+ }
2379
+ if (claim.salesReturnId) {
2380
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnAlreadyLinked' })
2381
+ }
2382
+ if (!resolveOptionalEntityId('sales', 'sales_return')) {
2383
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesUnavailable' })
2384
+ }
2385
+ const lines = await findWithDecryption(
2386
+ em,
2387
+ WarrantyClaimLine,
2388
+ { claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
2389
+ {},
2390
+ scope,
2391
+ )
2392
+ const eligibleLines: Array<{ orderLineId: string; quantity: string }> = []
2393
+ const skippedLineIds: string[] = []
2394
+ for (const line of lines) {
2395
+ const effectiveQty = line.qtyApproved ?? line.qtyClaimed
2396
+ const quantityUnits = claimedQuantityUnits(effectiveQty) ?? 0n
2397
+ const eligible = Boolean(line.orderLineId)
2398
+ && SALES_RETURN_ELIGIBLE_LINE_STATUSES.has(line.lineStatus as WarrantyClaimLineStatus)
2399
+ && quantityUnits > 0n
2400
+ && quantityUnits % CLAIMED_QUANTITY_SCALE === 0n
2401
+ if (eligible && line.orderLineId) {
2402
+ eligibleLines.push({ orderLineId: line.orderLineId, quantity: String(effectiveQty) })
2403
+ } else {
2404
+ skippedLineIds.push(line.id)
2405
+ }
2406
+ }
2407
+ if (eligibleLines.length === 0) {
2408
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnNoEligibleLines' })
2409
+ }
2410
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
2411
+ let salesReturnId: string
2412
+ try {
2413
+ const { result: dispatchedResult } = await commandBus.execute<
2414
+ { orderId: string; reason?: string; tenantId: string; organizationId: string; lines: Array<{ orderLineId: string; quantity: string }> },
2415
+ { returnId: string }
2416
+ >(
2417
+ 'sales.returns.create',
2418
+ {
2419
+ input: {
2420
+ orderId: claim.orderId,
2421
+ reason: claim.claimNumber,
2422
+ tenantId: scope.tenantId,
2423
+ organizationId: scope.organizationId,
2424
+ lines: eligibleLines,
2425
+ },
2426
+ ctx: scrubbedDispatchContext(ctx),
2427
+ },
2428
+ )
2429
+ if (!dispatchedResult?.returnId) {
2430
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
2431
+ }
2432
+ salesReturnId = dispatchedResult.returnId
2433
+ } catch (err) {
2434
+ if (isSalesQuantityRejection(err)) {
2435
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnQuantityRejected' })
2436
+ }
2437
+ throw err
2438
+ }
2439
+ let salesReturnUpdatedAt: string | null = null
2440
+ try {
2441
+ const createdReturnRow = await querySalesReferenceRow(ctx, 'sales_returns', scope, salesReturnId, ['id', 'updated_at'])
2442
+ salesReturnUpdatedAt = createdReturnRow?.updated_at
2443
+ ? new Date(createdReturnRow.updated_at as string | Date).toISOString()
2444
+ : null
2445
+ await em.transactional(async (tx) => {
2446
+ const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
2447
+ if (lockedClaim.salesReturnId) {
2448
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnAlreadyLinked' })
2449
+ }
2450
+ lockedClaim.salesReturnId = salesReturnId
2451
+ lockedClaim.updatedAt = new Date()
2452
+ appendClaimEvent(tx, lockedClaim, 'system', {
2453
+ visibility: 'internal',
2454
+ payload: { action: 'sales_return_created', salesReturnId },
2455
+ actorUserId: ctx.auth?.sub ?? null,
2456
+ })
2457
+ claim.salesReturnId = salesReturnId
2458
+ claim.updatedAt = lockedClaim.updatedAt
2459
+ await tx.flush()
2460
+ })
2461
+ } catch (err) {
2462
+ try {
2463
+ await dispatchSalesReturnDelete(ctx, scope, salesReturnId, claim.orderId)
2464
+ } catch (compensationErr) {
2465
+ salesReturnBridgeLogger.error('warranty_claims.claim.create_sales_return compensation failed — orphaned sales return', {
2466
+ err: compensationErr,
2467
+ claimId: claim.id,
2468
+ salesReturnId,
2469
+ })
2470
+ const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
2471
+ const orphanClaim = await findOneWithDecryption(
2472
+ orphanEm,
2473
+ WarrantyClaim,
2474
+ { id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
2475
+ {},
2476
+ scope,
2477
+ )
2478
+ if (orphanClaim) {
2479
+ try {
2480
+ appendClaimEvent(orphanEm, orphanClaim, 'system', {
2481
+ visibility: 'internal',
2482
+ payload: { action: 'sales_return_orphaned', salesReturnId },
2483
+ actorUserId: ctx.auth?.sub ?? null,
2484
+ })
2485
+ await orphanEm.flush()
2486
+ } catch (orphanEventErr) {
2487
+ salesReturnBridgeLogger.error('warranty_claims.claim.create_sales_return orphan timeline write failed', {
2488
+ err: orphanEventErr,
2489
+ claimId: claim.id,
2490
+ salesReturnId,
2491
+ })
2492
+ }
2493
+ }
2494
+ throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
2495
+ }
2496
+ throw err
2497
+ }
2498
+ await emitClaimCrud(ctx, 'updated', claim)
2499
+ return { claimId: claim.id, salesReturnId, salesReturnUpdatedAt, skippedLineIds }
2500
+ },
2501
+ captureAfter: async (rawInput, result, ctx) => {
2502
+ const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
2503
+ const scope = resolveScope(ctx, input)
2504
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2505
+ return loadClaimSnapshot(em, result.claimId, scope)
2506
+ },
2507
+ buildLog: async ({ result, snapshots }) => {
2508
+ const base = buildClaimLog('warranty_claims.audit.claim.create_sales_return', result.claimId, snapshots)
2509
+ const after = snapshots.after as { orderId?: string | null } | null | undefined
2510
+ const undoPayload: ClaimSalesReturnUndoPayload = {
2511
+ ...(base.payload.undo as ClaimUndoPayload),
2512
+ salesReturn: {
2513
+ id: result.salesReturnId,
2514
+ orderId: after?.orderId ?? '',
2515
+ updatedAt: result.salesReturnUpdatedAt,
2516
+ },
2517
+ }
2518
+ return { ...base, payload: { undo: undoPayload } }
2519
+ },
2520
+ undo: async ({ logEntry, ctx }) => {
2521
+ const payload = extractUndoPayload<ClaimSalesReturnUndoPayload>(logEntry)
2522
+ const before = payload?.before
2523
+ if (!before) return
2524
+ const after = payload?.after
2525
+ if (!after) throwClaimUndoConflict()
2526
+ await assertUndoSnapshotCurrent(ctx, after)
2527
+ const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
2528
+ const salesReturn = payload?.salesReturn ?? null
2529
+ if (salesReturn?.id && salesReturn.orderId) {
2530
+ const currentRow = await querySalesReferenceRow(ctx, 'sales_returns', scope, salesReturn.id, ['id', 'updated_at'])
2531
+ if (currentRow) {
2532
+ const currentUpdatedAt = currentRow.updated_at
2533
+ ? new Date(currentRow.updated_at as string | Date).toISOString()
2534
+ : null
2535
+ if (!salesReturn.updatedAt || (currentUpdatedAt && currentUpdatedAt !== salesReturn.updatedAt)) {
2536
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.salesReturnChangedUndoAborted' })
2537
+ }
2538
+ await dispatchSalesReturnDelete(ctx, scope, salesReturn.id, salesReturn.orderId)
2539
+ }
2540
+ }
2541
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2542
+ let claim!: WarrantyClaim
2543
+ await withAtomicFlush(em, [
2544
+ async () => {
2545
+ claim = await restoreSnapshot(em, before, after)
2546
+ appendClaimEvent(em, claim, 'system', {
2547
+ visibility: 'internal',
2548
+ payload: { action: 'undo_sales_return_created' },
2549
+ actorUserId: ctx.auth?.sub ?? null,
2550
+ })
2551
+ },
2552
+ ], { transaction: true, label: 'warranty_claims.claim.create_sales_return.undo' })
2553
+ await emitClaimUndoCrud(ctx, 'updated', claim)
2554
+ },
2555
+ }
2556
+
2557
+ const creditMemoBridgeLogger = createLogger('warranty_claims')
2558
+
2559
+ const CREDIT_MEMO_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['received', 'inspecting', 'resolved'])
2560
+ const CREDIT_MEMO_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['received', 'inspected', 'resolved'])
2561
+ const CREDIT_MEMO_ELIGIBLE_DISPOSITIONS = new Set<WarrantyClaimDisposition>(['credit', 'refund'])
2562
+
2563
+ type CreditMemoLineInput = {
2564
+ orderLineId: string
2565
+ quantity: string
2566
+ currencyCode: string
2567
+ unitPriceNet: string
2568
+ unitPriceGross: string
2569
+ taxRate: string
2570
+ taxAmount: string
2571
+ totalNetAmount: string
2572
+ totalGrossAmount: string
2573
+ metadata: { warrantyClaimLineId: string }
2574
+ name?: string
2575
+ }
2576
+
2577
+ type CreditMemoCreateInput = {
2578
+ organizationId: string
2579
+ tenantId: string
2580
+ orderId: string
2581
+ currencyCode: string
2582
+ reason: string
2583
+ metadata: { warrantyClaimId: string; warrantyClaimNumber: string }
2584
+ lines: CreditMemoLineInput[]
2585
+ subtotalNetAmount: string
2586
+ subtotalGrossAmount: string
2587
+ taxTotalAmount: string
2588
+ grandTotalNetAmount: string
2589
+ grandTotalGrossAmount: string
2590
+ }
2591
+
2592
+ type PreparedCreditMemoLine = {
2593
+ input: CreditMemoLineInput
2594
+ netUnits: bigint
2595
+ grossUnits: bigint
2596
+ taxUnits: bigint
2597
+ }
2598
+
2599
+ type ClaimCreditMemoUndoPayload = ClaimUndoPayload & {
2600
+ creditMemo?: { id: string; updatedAt: string | null } | null
2601
+ }
2602
+
2603
+ type ClaimCreateCreditMemoResult = {
2604
+ claimId: string
2605
+ creditMemoId: string
2606
+ creditMemoUpdatedAt: string | null
2607
+ skippedLineIds: string[]
2608
+ grandTotalGrossAmount: string
2609
+ currencyCode: string
2610
+ }
2611
+
2612
+ function minimumUnits(left: bigint, right: bigint): bigint {
2613
+ return left < right ? left : right
2614
+ }
2615
+
2616
+ function prepareCreditMemoLine(
2617
+ claimLine: WarrantyClaimLine,
2618
+ sourceLine: Record<string, unknown>,
2619
+ creditedQuantityUnits: bigint,
2620
+ currencyCode: string,
2621
+ ): PreparedCreditMemoLine | null {
2622
+ const orderQuantityUnits = claimedQuantityUnits(sourceLine.quantity as string | number | null | undefined)
2623
+ const sourceGrossUnits = claimedQuantityUnits(sourceLine.total_gross_amount as string | number | null | undefined)
2624
+ const sourceNetUnits = claimedQuantityUnits(sourceLine.total_net_amount as string | number | null | undefined)
2625
+ const taxRateUnits = claimedQuantityUnits(sourceLine.tax_rate as string | number | null | undefined)
2626
+ if (
2627
+ orderQuantityUnits === null
2628
+ || orderQuantityUnits <= 0n
2629
+ || creditedQuantityUnits > orderQuantityUnits
2630
+ || sourceGrossUnits === null
2631
+ || sourceNetUnits === null
2632
+ || taxRateUnits === null
2633
+ ) {
2634
+ return null
2635
+ }
2636
+
2637
+ let netBasisUnits = sourceNetUnits
2638
+ if (sourceGrossUnits > 0n && netBasisUnits <= 0n) {
2639
+ netBasisUnits = netUnitsFromGross(sourceGrossUnits, taxRateUnits)
2640
+ }
2641
+ const proratedGrossUnits = roundHalfUpDivision(sourceGrossUnits * creditedQuantityUnits, orderQuantityUnits)
2642
+ const proratedNetUnits = roundHalfUpDivision(netBasisUnits * creditedQuantityUnits, orderQuantityUnits)
2643
+ const creditAmountUnits = claimedQuantityUnits(claimLine.creditAmount)
2644
+ const restockingFeeUnits = claimedQuantityUnits(claimLine.restockingFee) ?? 0n
2645
+ const coreCreditUnits = claimedQuantityUnits(claimLine.coreCreditAmount) ?? 0n
2646
+ const baseGrossUnits = creditAmountUnits ?? proratedGrossUnits
2647
+ const adjustedGrossUnits = baseGrossUnits - restockingFeeUnits + coreCreditUnits
2648
+ const grossUnits = adjustedGrossUnits > 0n ? adjustedGrossUnits : 0n
2649
+ let netUnits = grossUnits === proratedGrossUnits
2650
+ ? proratedNetUnits
2651
+ : netUnitsFromGross(grossUnits, taxRateUnits)
2652
+ if (grossUnits > 0n && netUnits <= 0n) {
2653
+ netUnits = netUnitsFromGross(grossUnits, taxRateUnits)
2654
+ }
2655
+ const taxUnits = grossUnits - netUnits
2656
+ const input: CreditMemoLineInput = {
2657
+ orderLineId: claimLine.orderLineId as string,
2658
+ quantity: formatScaledUnits(creditedQuantityUnits),
2659
+ currencyCode,
2660
+ unitPriceNet: formatScaledUnits(roundHalfUpDivision(netUnits * CLAIMED_QUANTITY_SCALE, creditedQuantityUnits)),
2661
+ unitPriceGross: formatScaledUnits(roundHalfUpDivision(grossUnits * CLAIMED_QUANTITY_SCALE, creditedQuantityUnits)),
2662
+ taxRate: formatScaledUnits(taxRateUnits),
2663
+ taxAmount: formatScaledUnits(taxUnits),
2664
+ totalNetAmount: formatScaledUnits(netUnits),
2665
+ totalGrossAmount: formatScaledUnits(grossUnits),
2666
+ metadata: { warrantyClaimLineId: claimLine.id },
2667
+ }
2668
+ const name = readString(sourceLine, 'name')
2669
+ if (name) input.name = name
2670
+ return { input, netUnits, grossUnits, taxUnits }
2671
+ }
2672
+
2673
+ async function dispatchCreditMemoDelete(
2674
+ ctx: CommandRuntimeContext,
2675
+ scope: WarrantyClaimScope,
2676
+ creditMemoId: string,
2677
+ ): Promise<void> {
2678
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
2679
+ await commandBus.execute(
2680
+ 'sales.credit_memos.delete',
2681
+ {
2682
+ input: { id: creditMemoId, organizationId: scope.organizationId, tenantId: scope.tenantId },
2683
+ ctx: scrubbedDispatchContext(ctx),
2684
+ },
2685
+ )
2686
+ }
2687
+
2688
+ const createCreditMemoCommand: CommandHandler<ClaimCreateCreditMemoInput, ClaimCreateCreditMemoResult> = {
2689
+ id: 'warranty_claims.claim.create_credit_memo',
2690
+ isUndoable: true,
2691
+ prepare: async (rawInput, ctx) => {
2692
+ const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
2693
+ const scope = resolveScope(ctx, input)
2694
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2695
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
2696
+ },
2697
+ async execute(rawInput, ctx) {
2698
+ const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
2699
+ const scope = resolveScope(ctx, input)
2700
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2701
+ const claim = await requireScopedClaim(em, input.id, scope)
2702
+ await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
2703
+ if (!claim.orderId) {
2704
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoRequiresOrder' })
2705
+ }
2706
+ if (!CREDIT_MEMO_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
2707
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoInvalidStatus' })
2708
+ }
2709
+ if (claim.creditMemoId) {
2710
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoAlreadyLinked' })
2711
+ }
2712
+ if (!resolveOptionalEntityId('sales', 'sales_credit_memo')) {
2713
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoSalesUnavailable' })
2714
+ }
2715
+
2716
+ const sourceOrder = await querySalesReferenceRow(
2717
+ ctx,
2718
+ 'sales_orders',
2719
+ scope,
2720
+ claim.orderId,
2721
+ ['id', 'currency_code'],
2722
+ )
2723
+ const orderCurrencyCode = sourceOrder ? readString(sourceOrder, 'currency_code') : null
2724
+ if (!sourceOrder || !orderCurrencyCode) {
2725
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoRequiresOrder' })
2726
+ }
2727
+
2728
+ const claimLines = await findWithDecryption(
2729
+ em,
2730
+ WarrantyClaimLine,
2731
+ { claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
2732
+ {},
2733
+ scope,
2734
+ )
2735
+ const sourceLineRows = await querySalesOrderLineReferences(
2736
+ ctx,
2737
+ scope,
2738
+ Array.from(new Set(claimLines.flatMap((line) => line.orderLineId ? [line.orderLineId] : []))),
2739
+ ['id', 'order_id', 'name', 'currency_code', 'quantity', 'total_net_amount', 'total_gross_amount', 'tax_rate'],
2740
+ )
2741
+ const sourceLinesById = new Map((sourceLineRows ?? []).map((row) => [readString(row, 'id'), row]))
2742
+ const preparedLines: PreparedCreditMemoLine[] = []
2743
+ const skippedLineIds: string[] = []
2744
+ for (const claimLine of claimLines) {
2745
+ const approvedQuantityUnits = claimedQuantityUnits(claimLine.qtyApproved ?? claimLine.qtyClaimed) ?? 0n
2746
+ const receivedQuantityUnits = claimedQuantityUnits(claimLine.qtyReceived) ?? 0n
2747
+ const creditedQuantityUnits = minimumUnits(approvedQuantityUnits, receivedQuantityUnits)
2748
+ const initiallyEligible = Boolean(claimLine.orderLineId)
2749
+ && CREDIT_MEMO_ELIGIBLE_DISPOSITIONS.has(claimLine.disposition as WarrantyClaimDisposition)
2750
+ && CREDIT_MEMO_ELIGIBLE_LINE_STATUSES.has(claimLine.lineStatus as WarrantyClaimLineStatus)
2751
+ && creditedQuantityUnits > 0n
2752
+ if (!initiallyEligible || !claimLine.orderLineId) {
2753
+ skippedLineIds.push(claimLine.id)
2754
+ continue
2755
+ }
2756
+
2757
+ const sourceLine = sourceLinesById.get(claimLine.orderLineId) ?? null
2758
+ const sourceCurrencyCode = sourceLine ? readString(sourceLine, 'currency_code') : null
2759
+ if (
2760
+ !sourceLine
2761
+ || sourceLine.order_id !== claim.orderId
2762
+ || sourceCurrencyCode !== orderCurrencyCode
2763
+ ) {
2764
+ skippedLineIds.push(claimLine.id)
2765
+ continue
2766
+ }
2767
+ const preparedLine = prepareCreditMemoLine(
2768
+ claimLine,
2769
+ sourceLine,
2770
+ creditedQuantityUnits,
2771
+ orderCurrencyCode,
2772
+ )
2773
+ if (!preparedLine) {
2774
+ skippedLineIds.push(claimLine.id)
2775
+ continue
2776
+ }
2777
+ preparedLines.push(preparedLine)
2778
+ }
2779
+ if (preparedLines.length === 0) {
2780
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoNoEligibleLines' })
2781
+ }
2782
+
2783
+ const totals = preparedLines.reduce(
2784
+ (result, line) => ({
2785
+ netUnits: result.netUnits + line.netUnits,
2786
+ grossUnits: result.grossUnits + line.grossUnits,
2787
+ taxUnits: result.taxUnits + line.taxUnits,
2788
+ }),
2789
+ { netUnits: 0n, grossUnits: 0n, taxUnits: 0n },
2790
+ )
2791
+ const grandTotalNetAmount = formatScaledUnits(totals.netUnits)
2792
+ const grandTotalGrossAmount = formatScaledUnits(totals.grossUnits)
2793
+ const creditMemoInput: CreditMemoCreateInput = {
2794
+ organizationId: scope.organizationId,
2795
+ tenantId: scope.tenantId,
2796
+ orderId: claim.orderId,
2797
+ currencyCode: orderCurrencyCode,
2798
+ reason: claim.claimNumber,
2799
+ metadata: { warrantyClaimId: claim.id, warrantyClaimNumber: claim.claimNumber },
2800
+ lines: preparedLines.map((line) => line.input),
2801
+ subtotalNetAmount: grandTotalNetAmount,
2802
+ subtotalGrossAmount: grandTotalGrossAmount,
2803
+ taxTotalAmount: formatScaledUnits(totals.taxUnits),
2804
+ grandTotalNetAmount,
2805
+ grandTotalGrossAmount,
2806
+ }
2807
+
2808
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
2809
+ let creditMemoId: string | null = null
2810
+ let creditMemoUpdatedAt: string | null = null
2811
+ try {
2812
+ const { result: dispatchedResult } = await commandBus.execute<CreditMemoCreateInput, { creditMemoId: string }>(
2813
+ 'sales.credit_memos.create',
2814
+ { input: creditMemoInput, ctx: scrubbedDispatchContext(ctx) },
2815
+ )
2816
+ if (!dispatchedResult?.creditMemoId) {
2817
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
2818
+ }
2819
+ creditMemoId = dispatchedResult.creditMemoId
2820
+ const createdCreditMemoRow = await querySalesReferenceRow(
2821
+ ctx,
2822
+ 'sales_credit_memos',
2823
+ scope,
2824
+ creditMemoId,
2825
+ ['id', 'updated_at'],
2826
+ )
2827
+ creditMemoUpdatedAt = createdCreditMemoRow?.updated_at
2828
+ ? new Date(createdCreditMemoRow.updated_at as string | Date).toISOString()
2829
+ : null
2830
+ await em.transactional(async (tx) => {
2831
+ const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
2832
+ if (lockedClaim.creditMemoId) {
2833
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoAlreadyLinked' })
2834
+ }
2835
+ lockedClaim.creditMemoId = creditMemoId
2836
+ lockedClaim.updatedAt = new Date()
2837
+ appendClaimEvent(tx, lockedClaim, 'system', {
2838
+ visibility: 'internal',
2839
+ payload: { action: 'credit_memo_created', creditMemoId, grandTotalGrossAmount, currencyCode: orderCurrencyCode },
2840
+ actorUserId: ctx.auth?.sub ?? null,
2841
+ })
2842
+ claim.creditMemoId = creditMemoId
2843
+ claim.updatedAt = lockedClaim.updatedAt
2844
+ await tx.flush()
2845
+ })
2846
+ } catch (err) {
2847
+ if (!creditMemoId) throw err
2848
+ try {
2849
+ await dispatchCreditMemoDelete(ctx, scope, creditMemoId)
2850
+ } catch (compensationErr) {
2851
+ creditMemoBridgeLogger.error('warranty_claims.claim.create_credit_memo compensation failed — orphaned credit memo', {
2852
+ err: compensationErr,
2853
+ claimId: claim.id,
2854
+ creditMemoId,
2855
+ })
2856
+ try {
2857
+ const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
2858
+ const orphanClaim = await findOneWithDecryption(
2859
+ orphanEm,
2860
+ WarrantyClaim,
2861
+ { id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
2862
+ {},
2863
+ scope,
2864
+ )
2865
+ if (orphanClaim) {
2866
+ appendClaimEvent(orphanEm, orphanClaim, 'system', {
2867
+ visibility: 'internal',
2868
+ payload: { action: 'credit_memo_orphaned', creditMemoId },
2869
+ actorUserId: ctx.auth?.sub ?? null,
2870
+ })
2871
+ await orphanEm.flush()
2872
+ }
2873
+ } catch (orphanEventErr) {
2874
+ creditMemoBridgeLogger.error('warranty_claims.claim.create_credit_memo orphan timeline write failed', {
2875
+ err: orphanEventErr,
2876
+ claimId: claim.id,
2877
+ creditMemoId,
2878
+ })
2879
+ }
2880
+ throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
2881
+ }
2882
+ throw err
2883
+ }
2884
+
2885
+ await emitClaimCrud(ctx, 'updated', claim)
2886
+ return {
2887
+ claimId: claim.id,
2888
+ creditMemoId,
2889
+ creditMemoUpdatedAt,
2890
+ skippedLineIds,
2891
+ grandTotalGrossAmount,
2892
+ currencyCode: orderCurrencyCode,
2893
+ }
2894
+ },
2895
+ captureAfter: async (rawInput, result, ctx) => {
2896
+ const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
2897
+ const scope = resolveScope(ctx, input)
2898
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2899
+ return loadClaimSnapshot(em, result.claimId, scope)
2900
+ },
2901
+ buildLog: async ({ result, snapshots }) => {
2902
+ const base = buildClaimLog('warranty_claims.audit.claim.create_credit_memo', result.claimId, snapshots)
2903
+ const undoPayload: ClaimCreditMemoUndoPayload = {
2904
+ ...(base.payload.undo as ClaimUndoPayload),
2905
+ creditMemo: { id: result.creditMemoId, updatedAt: result.creditMemoUpdatedAt },
2906
+ }
2907
+ return { ...base, payload: { undo: undoPayload } }
2908
+ },
2909
+ undo: async ({ logEntry, ctx }) => {
2910
+ const payload = extractUndoPayload<ClaimCreditMemoUndoPayload>(logEntry)
2911
+ const before = payload?.before
2912
+ if (!before) return
2913
+ const after = payload?.after
2914
+ if (!after) throwClaimUndoConflict()
2915
+ await assertUndoSnapshotCurrent(ctx, after)
2916
+ const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
2917
+ const creditMemo = payload?.creditMemo ?? null
2918
+ if (creditMemo?.id) {
2919
+ const currentRow = await querySalesReferenceRow(ctx, 'sales_credit_memos', scope, creditMemo.id, ['id', 'updated_at'])
2920
+ if (currentRow === undefined) {
2921
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.creditMemoChangedUndoAborted' })
2922
+ }
2923
+ if (currentRow) {
2924
+ const currentUpdatedAt = currentRow.updated_at
2925
+ ? new Date(currentRow.updated_at as string | Date).toISOString()
2926
+ : null
2927
+ if (!creditMemo.updatedAt || !currentUpdatedAt || currentUpdatedAt !== creditMemo.updatedAt) {
2928
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.creditMemoChangedUndoAborted' })
2929
+ }
2930
+ await dispatchCreditMemoDelete(ctx, scope, creditMemo.id)
2931
+ } else {
2932
+ creditMemoBridgeLogger.info('warranty_claims.claim.create_credit_memo undo found credit memo already absent', {
2933
+ claimId: before.id,
2934
+ creditMemoId: creditMemo.id,
2935
+ })
2936
+ }
2937
+ }
2938
+
2939
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
2940
+ let claim!: WarrantyClaim
2941
+ await withAtomicFlush(em, [
2942
+ async () => {
2943
+ claim = await restoreSnapshot(em, before, after)
2944
+ appendClaimEvent(em, claim, 'system', {
2945
+ visibility: 'internal',
2946
+ payload: { action: 'undo_credit_memo_created' },
2947
+ actorUserId: ctx.auth?.sub ?? null,
2948
+ })
2949
+ },
2950
+ ], { transaction: true, label: 'warranty_claims.claim.create_credit_memo.undo' })
2951
+ await emitClaimUndoCrud(ctx, 'updated', claim)
2952
+ },
2953
+ }
2954
+
2955
+ const replacementOrderBridgeLogger = createLogger('warranty_claims')
2956
+
2957
+ const REPLACEMENT_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>([
2958
+ 'approved',
2959
+ 'awaiting_return',
2960
+ 'received',
2961
+ 'inspecting',
2962
+ 'resolved',
2963
+ ])
2964
+ const REPLACEMENT_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['approved', 'received', 'inspected', 'resolved'])
2965
+ const ADVANCE_REPLACEMENT_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return'])
2966
+ const SALES_ORDER_LINE_KINDS = new Set(['product', 'service', 'shipping', 'discount', 'adjustment'] as const)
2967
+
2968
+ type SalesOrderLineKind = 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
2969
+
2970
+ type ReplacementOrderLineInput = {
2971
+ kind: SalesOrderLineKind
2972
+ currencyCode: string
2973
+ quantity: string
2974
+ unitPriceNet: string
2975
+ unitPriceGross: string
2976
+ productId?: string
2977
+ productVariantId?: string
2978
+ name?: string
2979
+ taxRate?: string
2980
+ }
2981
+
2982
+ type ReplacementOrderCreateInput = {
2983
+ organizationId: string
2984
+ tenantId: string
2985
+ currencyCode: string
2986
+ metadata: { warrantyClaimId: string; warrantyClaimNumber: string }
2987
+ lines: ReplacementOrderLineInput[]
2988
+ customerEntityId?: string
2989
+ customerContactId?: string
2990
+ billingAddressId?: string
2991
+ shippingAddressId?: string
2992
+ channelId?: string
2993
+ }
2994
+
2995
+ type ClaimReplacementOrderUndoPayload = ClaimUndoPayload & {
2996
+ replacementOrder?: { id: string; updatedAt: string | null } | null
2997
+ }
2998
+
2999
+ type ClaimCreateReplacementOrderResult = {
3000
+ claimId: string
3001
+ replacementOrderId: string
3002
+ replacementOrderUpdatedAt: string | null
3003
+ skippedLineIds: string[]
3004
+ pricing: 'zero' | 'original'
3005
+ }
3006
+
3007
+ function readSalesOrderLineKind(value: unknown): SalesOrderLineKind | null {
3008
+ return typeof value === 'string' && SALES_ORDER_LINE_KINDS.has(value as SalesOrderLineKind)
3009
+ ? value as SalesOrderLineKind
3010
+ : null
3011
+ }
3012
+
3013
+ async function dispatchSalesOrderDelete(
3014
+ ctx: CommandRuntimeContext,
3015
+ scope: WarrantyClaimScope,
3016
+ replacementOrderId: string,
3017
+ ): Promise<void> {
3018
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
3019
+ await commandBus.execute(
3020
+ 'sales.orders.delete',
3021
+ {
3022
+ input: { id: replacementOrderId, tenantId: scope.tenantId, organizationId: scope.organizationId },
3023
+ ctx: scrubbedDispatchContext(ctx),
3024
+ },
3025
+ )
3026
+ }
3027
+
3028
+ const createReplacementOrderCommand: CommandHandler<ClaimCreateReplacementOrderInput, ClaimCreateReplacementOrderResult> = {
3029
+ id: 'warranty_claims.claim.create_replacement_order',
3030
+ isUndoable: true,
3031
+ prepare: async (rawInput, ctx) => {
3032
+ const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
3033
+ const scope = resolveScope(ctx, input)
3034
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
3035
+ return { before: await loadClaimSnapshot(em, input.id, scope) }
3036
+ },
3037
+ async execute(rawInput, ctx) {
3038
+ const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
3039
+ const scope = resolveScope(ctx, input)
3040
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
3041
+ const claim = await requireScopedClaim(em, input.id, scope)
3042
+ await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
3043
+ if (!claim.orderId) {
3044
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementOrderRequiresOrder' })
3045
+ }
3046
+ if (!REPLACEMENT_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
3047
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementInvalidStatus' })
3048
+ }
3049
+ if (claim.replacementOrderId) {
3050
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementAlreadyLinked' })
3051
+ }
3052
+ if (!resolveOptionalEntityId('sales', 'sales_order')) {
3053
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementSalesUnavailable' })
3054
+ }
3055
+
3056
+ const sourceOrder = await querySalesReferenceRow(
3057
+ ctx,
3058
+ 'sales_orders',
3059
+ scope,
3060
+ claim.orderId,
3061
+ [
3062
+ 'id',
3063
+ 'currency_code',
3064
+ 'customer_entity_id',
3065
+ 'customer_contact_id',
3066
+ 'billing_address_id',
3067
+ 'shipping_address_id',
3068
+ 'channel_id',
3069
+ ],
3070
+ )
3071
+ const orderCurrencyCode = sourceOrder ? readString(sourceOrder, 'currency_code') : null
3072
+ if (!sourceOrder || !orderCurrencyCode) {
3073
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementOrderRequiresOrder' })
3074
+ }
3075
+
3076
+ const lines = await findWithDecryption(
3077
+ em,
3078
+ WarrantyClaimLine,
3079
+ { claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
3080
+ {},
3081
+ scope,
3082
+ )
3083
+ const sourceLineRows = await querySalesOrderLineReferences(
3084
+ ctx,
3085
+ scope,
3086
+ Array.from(new Set(lines.flatMap((line) => line.orderLineId ? [line.orderLineId] : []))),
3087
+ [
3088
+ 'id',
3089
+ 'order_id',
3090
+ 'product_id',
3091
+ 'product_variant_id',
3092
+ 'name',
3093
+ 'kind',
3094
+ 'currency_code',
3095
+ 'unit_price_net',
3096
+ 'unit_price_gross',
3097
+ 'tax_rate',
3098
+ ],
3099
+ )
3100
+ const sourceLinesById = new Map((sourceLineRows ?? []).map((row) => [readString(row, 'id'), row]))
3101
+ const replacementLines: ReplacementOrderLineInput[] = []
3102
+ const skippedLineIds: string[] = []
3103
+ for (const line of lines) {
3104
+ const effectiveQty = line.qtyApproved ?? line.qtyClaimed
3105
+ const quantityUnits = claimedQuantityUnits(effectiveQty) ?? 0n
3106
+ const initiallyEligible = line.disposition === 'replace'
3107
+ && Boolean(line.orderLineId)
3108
+ && REPLACEMENT_ELIGIBLE_LINE_STATUSES.has(line.lineStatus as WarrantyClaimLineStatus)
3109
+ && quantityUnits > 0n
3110
+ && quantityUnits % CLAIMED_QUANTITY_SCALE === 0n
3111
+ if (!initiallyEligible || !line.orderLineId) {
3112
+ skippedLineIds.push(line.id)
3113
+ continue
3114
+ }
3115
+
3116
+ const sourceLine = sourceLinesById.get(line.orderLineId) ?? null
3117
+ const productId = sourceLine ? readString(sourceLine, 'product_id') : null
3118
+ const productVariantId = sourceLine ? readString(sourceLine, 'product_variant_id') : null
3119
+ const name = sourceLine ? readString(sourceLine, 'name') : null
3120
+ const kind = sourceLine ? readSalesOrderLineKind(sourceLine.kind) : null
3121
+ const currencyCode = sourceLine ? readString(sourceLine, 'currency_code') : null
3122
+ const unitPriceNet = sourceLine ? readString(sourceLine, 'unit_price_net') : null
3123
+ const unitPriceGross = sourceLine ? readString(sourceLine, 'unit_price_gross') : null
3124
+ const taxRate = sourceLine ? readString(sourceLine, 'tax_rate') : null
3125
+ const originalPricingIncomplete = input.pricing === 'original'
3126
+ && (unitPriceNet === null || unitPriceGross === null || taxRate === null)
3127
+ if (
3128
+ !sourceLine
3129
+ || sourceLine.order_id !== claim.orderId
3130
+ || (!productId && !productVariantId && !name)
3131
+ || !kind
3132
+ || !currencyCode
3133
+ || originalPricingIncomplete
3134
+ ) {
3135
+ skippedLineIds.push(line.id)
3136
+ continue
3137
+ }
3138
+
3139
+ const replacementLine: ReplacementOrderLineInput = {
3140
+ kind,
3141
+ currencyCode,
3142
+ quantity: String(effectiveQty),
3143
+ unitPriceNet: input.pricing === 'zero' ? '0' : unitPriceNet as string,
3144
+ unitPriceGross: input.pricing === 'zero' ? '0' : unitPriceGross as string,
3145
+ }
3146
+ if (productId) replacementLine.productId = productId
3147
+ if (productVariantId) replacementLine.productVariantId = productVariantId
3148
+ if (name) replacementLine.name = name
3149
+ if (input.pricing === 'original') replacementLine.taxRate = taxRate as string
3150
+ replacementLines.push(replacementLine)
3151
+ }
3152
+ if (replacementLines.length === 0) {
3153
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementNoEligibleLines' })
3154
+ }
3155
+
3156
+ const orderInput: ReplacementOrderCreateInput = {
3157
+ organizationId: scope.organizationId,
3158
+ tenantId: scope.tenantId,
3159
+ currencyCode: orderCurrencyCode,
3160
+ metadata: { warrantyClaimId: claim.id, warrantyClaimNumber: claim.claimNumber },
3161
+ lines: replacementLines,
3162
+ }
3163
+ const customerEntityId = readString(sourceOrder, 'customer_entity_id')
3164
+ const customerContactId = readString(sourceOrder, 'customer_contact_id')
3165
+ const billingAddressId = readString(sourceOrder, 'billing_address_id')
3166
+ const shippingAddressId = readString(sourceOrder, 'shipping_address_id')
3167
+ const channelId = readString(sourceOrder, 'channel_id')
3168
+ if (customerEntityId) orderInput.customerEntityId = customerEntityId
3169
+ if (customerContactId) orderInput.customerContactId = customerContactId
3170
+ if (billingAddressId) orderInput.billingAddressId = billingAddressId
3171
+ if (shippingAddressId) orderInput.shippingAddressId = shippingAddressId
3172
+ if (channelId) orderInput.channelId = channelId
3173
+
3174
+ const commandBus = ctx.container.resolve('commandBus') as CommandBus
3175
+ const { result: dispatchedResult } = await commandBus.execute<ReplacementOrderCreateInput, { orderId: string }>(
3176
+ 'sales.orders.create',
3177
+ { input: orderInput, ctx: scrubbedDispatchContext(ctx) },
3178
+ )
3179
+ if (!dispatchedResult?.orderId) {
3180
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
3181
+ }
3182
+ const replacementOrderId = dispatchedResult.orderId
3183
+ let replacementOrderUpdatedAt: string | null = null
3184
+ try {
3185
+ const createdOrderRow = await querySalesReferenceRow(ctx, 'sales_orders', scope, replacementOrderId, ['id', 'updated_at'])
3186
+ replacementOrderUpdatedAt = createdOrderRow?.updated_at
3187
+ ? new Date(createdOrderRow.updated_at as string | Date).toISOString()
3188
+ : null
3189
+ await em.transactional(async (tx) => {
3190
+ const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
3191
+ if (lockedClaim.replacementOrderId) {
3192
+ throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementAlreadyLinked' })
3193
+ }
3194
+ lockedClaim.replacementOrderId = replacementOrderId
3195
+ if (ADVANCE_REPLACEMENT_CLAIM_STATUSES.has(lockedClaim.status as WarrantyClaimStatus)) {
3196
+ lockedClaim.advanceReplacement = true
3197
+ }
3198
+ lockedClaim.updatedAt = new Date()
3199
+ appendClaimEvent(tx, lockedClaim, 'system', {
3200
+ visibility: 'internal',
3201
+ payload: { action: 'replacement_order_created', replacementOrderId, pricing: input.pricing },
3202
+ actorUserId: ctx.auth?.sub ?? null,
3203
+ })
3204
+ claim.replacementOrderId = replacementOrderId
3205
+ claim.advanceReplacement = lockedClaim.advanceReplacement
3206
+ claim.updatedAt = lockedClaim.updatedAt
3207
+ await tx.flush()
3208
+ })
3209
+ } catch (err) {
3210
+ try {
3211
+ await dispatchSalesOrderDelete(ctx, scope, replacementOrderId)
3212
+ } catch (compensationErr) {
3213
+ replacementOrderBridgeLogger.error('warranty_claims.claim.create_replacement_order compensation failed — orphaned sales order', {
3214
+ err: compensationErr,
3215
+ claimId: claim.id,
3216
+ replacementOrderId,
3217
+ })
3218
+ try {
3219
+ const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
3220
+ const orphanClaim = await findOneWithDecryption(
3221
+ orphanEm,
3222
+ WarrantyClaim,
3223
+ { id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
3224
+ {},
3225
+ scope,
3226
+ )
3227
+ if (orphanClaim) {
3228
+ appendClaimEvent(orphanEm, orphanClaim, 'system', {
3229
+ visibility: 'internal',
3230
+ payload: { action: 'replacement_order_orphaned', replacementOrderId },
3231
+ actorUserId: ctx.auth?.sub ?? null,
3232
+ })
3233
+ await orphanEm.flush()
3234
+ }
3235
+ } catch (orphanEventErr) {
3236
+ replacementOrderBridgeLogger.error('warranty_claims.claim.create_replacement_order orphan timeline write failed', {
3237
+ err: orphanEventErr,
3238
+ claimId: claim.id,
3239
+ replacementOrderId,
3240
+ })
3241
+ }
3242
+ throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
3243
+ }
3244
+ throw err
3245
+ }
3246
+
3247
+ await emitClaimCrud(ctx, 'updated', claim)
3248
+ return {
3249
+ claimId: claim.id,
3250
+ replacementOrderId,
3251
+ replacementOrderUpdatedAt,
3252
+ skippedLineIds,
3253
+ pricing: input.pricing,
3254
+ }
3255
+ },
3256
+ captureAfter: async (rawInput, result, ctx) => {
3257
+ const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
3258
+ const scope = resolveScope(ctx, input)
3259
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
3260
+ return loadClaimSnapshot(em, result.claimId, scope)
3261
+ },
3262
+ buildLog: async ({ result, snapshots }) => {
3263
+ const base = buildClaimLog('warranty_claims.audit.claim.create_replacement_order', result.claimId, snapshots)
3264
+ const undoPayload: ClaimReplacementOrderUndoPayload = {
3265
+ ...(base.payload.undo as ClaimUndoPayload),
3266
+ replacementOrder: {
3267
+ id: result.replacementOrderId,
3268
+ updatedAt: result.replacementOrderUpdatedAt,
3269
+ },
3270
+ }
3271
+ return { ...base, payload: { undo: undoPayload } }
3272
+ },
3273
+ undo: async ({ logEntry, ctx }) => {
3274
+ const payload = extractUndoPayload<ClaimReplacementOrderUndoPayload>(logEntry)
3275
+ const before = payload?.before
3276
+ if (!before) return
3277
+ const after = payload?.after
3278
+ if (!after) throwClaimUndoConflict()
3279
+ await assertUndoSnapshotCurrent(ctx, after)
3280
+ const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
3281
+ const replacementOrder = payload?.replacementOrder ?? null
3282
+ if (replacementOrder?.id) {
3283
+ const currentRow = await querySalesReferenceRow(ctx, 'sales_orders', scope, replacementOrder.id, ['id', 'updated_at'])
3284
+ if (currentRow === undefined) {
3285
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.replacementOrderChangedUndoAborted' })
3286
+ }
3287
+ if (currentRow) {
3288
+ const currentUpdatedAt = currentRow.updated_at
3289
+ ? new Date(currentRow.updated_at as string | Date).toISOString()
3290
+ : null
3291
+ if (!replacementOrder.updatedAt || !currentUpdatedAt || currentUpdatedAt !== replacementOrder.updatedAt) {
3292
+ throw new CrudHttpError(409, { error: 'warranty_claims.errors.replacementOrderChangedUndoAborted' })
3293
+ }
3294
+ await dispatchSalesOrderDelete(ctx, scope, replacementOrder.id)
3295
+ } else {
3296
+ replacementOrderBridgeLogger.info('warranty_claims.claim.create_replacement_order undo found replacement order already absent', {
3297
+ claimId: before.id,
3298
+ replacementOrderId: replacementOrder.id,
3299
+ })
3300
+ }
3301
+ }
3302
+
3303
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
3304
+ let claim!: WarrantyClaim
3305
+ await withAtomicFlush(em, [
3306
+ async () => {
3307
+ claim = await restoreSnapshot(em, before, after)
3308
+ appendClaimEvent(em, claim, 'system', {
3309
+ visibility: 'internal',
3310
+ payload: { action: 'undo_replacement_order_created' },
3311
+ actorUserId: ctx.auth?.sub ?? null,
3312
+ })
3313
+ },
3314
+ ], { transaction: true, label: 'warranty_claims.claim.create_replacement_order.undo' })
3315
+ await emitClaimUndoCrud(ctx, 'updated', claim)
3316
+ },
3317
+ }
3318
+
3319
+ registerCommand(createClaimCommand)
3320
+ registerCommand(updateClaimCommand)
3321
+ registerCommand(deleteClaimCommand)
3322
+ registerCommand(submitClaimCommand)
3323
+ registerCommand(transitionClaimCommand)
3324
+ registerCommand(assignClaimCommand)
3325
+ registerCommand(setReturnLabelCommand)
3326
+ registerCommand(escalateClaimCommand)
3327
+ registerCommand(commentClaimCommand)
3328
+ registerCommand(createVendorRecoveryCommand)
3329
+ registerCommand(createSalesReturnCommand)
3330
+ registerCommand(createCreditMemoCommand)
3331
+ registerCommand(createReplacementOrderCommand)
3332
+
3333
+ export const claimCommands = [
3334
+ createClaimCommand,
3335
+ updateClaimCommand,
3336
+ deleteClaimCommand,
3337
+ submitClaimCommand,
3338
+ transitionClaimCommand,
3339
+ assignClaimCommand,
3340
+ setReturnLabelCommand,
3341
+ escalateClaimCommand,
3342
+ commentClaimCommand,
3343
+ createVendorRecoveryCommand,
3344
+ createSalesReturnCommand,
3345
+ createCreditMemoCommand,
3346
+ createReplacementOrderCommand,
3347
+ ]
3348
+
3349
+ export {
3350
+ claimCrudEvents,
3351
+ createClaimCommand,
3352
+ updateClaimCommand,
3353
+ deleteClaimCommand,
3354
+ submitClaimCommand,
3355
+ transitionClaimCommand,
3356
+ assignClaimCommand,
3357
+ setReturnLabelCommand,
3358
+ escalateClaimCommand,
3359
+ commentClaimCommand,
3360
+ createVendorRecoveryCommand,
3361
+ createSalesReturnCommand,
3362
+ createCreditMemoCommand,
3363
+ createReplacementOrderCommand,
3364
+ }