@dainamite/billing 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/CHANGELOG.md +658 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/dist/modules/billing/acl.js +20 -0
  5. package/dist/modules/billing/acl.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/route.js +182 -0
  7. package/dist/modules/billing/api/accounts/route.js.map +7 -0
  8. package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
  9. package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
  10. package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
  11. package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
  12. package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
  13. package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
  14. package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
  15. package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
  16. package/dist/modules/billing/api/invoices/post/route.js +74 -0
  17. package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
  18. package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
  19. package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
  20. package/dist/modules/billing/api/invoices/route.js +109 -0
  21. package/dist/modules/billing/api/invoices/route.js.map +7 -0
  22. package/dist/modules/billing/api/items/bulk/route.js +74 -0
  23. package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
  24. package/dist/modules/billing/api/items/route.js +224 -0
  25. package/dist/modules/billing/api/items/route.js.map +7 -0
  26. package/dist/modules/billing/api/openapi.js +25 -0
  27. package/dist/modules/billing/api/openapi.js.map +7 -0
  28. package/dist/modules/billing/api/run-outcomes/route.js +122 -0
  29. package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
  30. package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
  31. package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
  32. package/dist/modules/billing/api/runs/route.js +143 -0
  33. package/dist/modules/billing/api/runs/route.js.map +7 -0
  34. package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
  35. package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
  36. package/dist/modules/billing/api/usage/route.js +138 -0
  37. package/dist/modules/billing/api/usage/route.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
  39. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
  41. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
  43. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
  45. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
  47. package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
  49. package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
  51. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
  53. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
  55. package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
  56. package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
  57. package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
  58. package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
  59. package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
  60. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
  61. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
  62. package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
  63. package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
  64. package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
  65. package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
  66. package/dist/modules/billing/backend/billing/items/page.js +208 -0
  67. package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
  68. package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
  69. package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
  70. package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
  71. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
  72. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
  73. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
  74. package/dist/modules/billing/backend/billing/runs/page.js +341 -0
  75. package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
  76. package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
  77. package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
  78. package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
  79. package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
  80. package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
  81. package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
  82. package/dist/modules/billing/ce.js +56 -0
  83. package/dist/modules/billing/ce.js.map +7 -0
  84. package/dist/modules/billing/cli.js +67 -0
  85. package/dist/modules/billing/cli.js.map +7 -0
  86. package/dist/modules/billing/commands/accounts.js +157 -0
  87. package/dist/modules/billing/commands/accounts.js.map +7 -0
  88. package/dist/modules/billing/commands/index.js +6 -0
  89. package/dist/modules/billing/commands/index.js.map +7 -0
  90. package/dist/modules/billing/commands/invoices.js +401 -0
  91. package/dist/modules/billing/commands/invoices.js.map +7 -0
  92. package/dist/modules/billing/commands/items.js +317 -0
  93. package/dist/modules/billing/commands/items.js.map +7 -0
  94. package/dist/modules/billing/commands/runs.js +123 -0
  95. package/dist/modules/billing/commands/runs.js.map +7 -0
  96. package/dist/modules/billing/commands/usage.js +88 -0
  97. package/dist/modules/billing/commands/usage.js.map +7 -0
  98. package/dist/modules/billing/components/AccountForm.js +420 -0
  99. package/dist/modules/billing/components/AccountForm.js.map +7 -0
  100. package/dist/modules/billing/components/DetailFields.js +37 -0
  101. package/dist/modules/billing/components/DetailFields.js.map +7 -0
  102. package/dist/modules/billing/components/ItemForm.js +440 -0
  103. package/dist/modules/billing/components/ItemForm.js.map +7 -0
  104. package/dist/modules/billing/components/LineFormDialog.js +195 -0
  105. package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
  106. package/dist/modules/billing/components/UsageForm.js +262 -0
  107. package/dist/modules/billing/components/UsageForm.js.map +7 -0
  108. package/dist/modules/billing/data/entities.js +433 -0
  109. package/dist/modules/billing/data/entities.js.map +7 -0
  110. package/dist/modules/billing/data/entityIds.js +12 -0
  111. package/dist/modules/billing/data/entityIds.js.map +7 -0
  112. package/dist/modules/billing/data/validators.js +396 -0
  113. package/dist/modules/billing/data/validators.js.map +7 -0
  114. package/dist/modules/billing/di.js +6 -0
  115. package/dist/modules/billing/di.js.map +7 -0
  116. package/dist/modules/billing/events.js +35 -0
  117. package/dist/modules/billing/events.js.map +7 -0
  118. package/dist/modules/billing/i18n/en.json +336 -0
  119. package/dist/modules/billing/i18n/pl.json +336 -0
  120. package/dist/modules/billing/index.js +17 -0
  121. package/dist/modules/billing/index.js.map +7 -0
  122. package/dist/modules/billing/lib/billPeriod.js +101 -0
  123. package/dist/modules/billing/lib/billPeriod.js.map +7 -0
  124. package/dist/modules/billing/lib/billRunEngine.js +256 -0
  125. package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
  126. package/dist/modules/billing/lib/billRunLabel.js +8 -0
  127. package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
  128. package/dist/modules/billing/lib/idempotency.js +13 -0
  129. package/dist/modules/billing/lib/idempotency.js.map +7 -0
  130. package/dist/modules/billing/lib/invoiceStatus.js +38 -0
  131. package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
  132. package/dist/modules/billing/lib/invoiceWriter.js +153 -0
  133. package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
  134. package/dist/modules/billing/lib/itemSelector.js +77 -0
  135. package/dist/modules/billing/lib/itemSelector.js.map +7 -0
  136. package/dist/modules/billing/lib/seeds/demo.js +130 -0
  137. package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
  138. package/dist/modules/billing/lib/tenantLock.js +32 -0
  139. package/dist/modules/billing/lib/tenantLock.js.map +7 -0
  140. package/dist/modules/billing/lib/usageRater.js +150 -0
  141. package/dist/modules/billing/lib/usageRater.js.map +7 -0
  142. package/dist/modules/billing/lib/usageRunner.js +82 -0
  143. package/dist/modules/billing/lib/usageRunner.js.map +7 -0
  144. package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
  145. package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
  146. package/dist/modules/billing/setup.js +190 -0
  147. package/dist/modules/billing/setup.js.map +7 -0
  148. package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
  149. package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
  150. package/dist/modules/billing/translations.js +5 -0
  151. package/dist/modules/billing/translations.js.map +7 -0
  152. package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
  153. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
  154. package/package.json +148 -0
  155. package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
  156. package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
  157. package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
  158. package/src/modules/billing/__integration__/meta.ts +4 -0
  159. package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
  160. package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
  161. package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
  162. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
  163. package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
  164. package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
  165. package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
  166. package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
  167. package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
  168. package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
  169. package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
  170. package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
  171. package/src/modules/billing/__tests__/setup.test.ts +107 -0
  172. package/src/modules/billing/__tests__/validators.test.ts +594 -0
  173. package/src/modules/billing/acl.ts +16 -0
  174. package/src/modules/billing/api/accounts/route.ts +198 -0
  175. package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
  176. package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
  177. package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
  178. package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
  179. package/src/modules/billing/api/invoices/post/route.ts +97 -0
  180. package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
  181. package/src/modules/billing/api/invoices/route.ts +167 -0
  182. package/src/modules/billing/api/items/bulk/route.ts +92 -0
  183. package/src/modules/billing/api/items/route.ts +255 -0
  184. package/src/modules/billing/api/openapi.ts +33 -0
  185. package/src/modules/billing/api/run-outcomes/route.ts +149 -0
  186. package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
  187. package/src/modules/billing/api/runs/route.ts +170 -0
  188. package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
  189. package/src/modules/billing/api/usage/route.ts +148 -0
  190. package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
  191. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
  192. package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
  193. package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
  194. package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
  195. package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
  196. package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
  197. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
  198. package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
  199. package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
  200. package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
  201. package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
  202. package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
  203. package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
  204. package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
  205. package/src/modules/billing/backend/billing/items/page.tsx +272 -0
  206. package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
  207. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
  208. package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
  209. package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
  210. package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
  211. package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
  212. package/src/modules/billing/ce.ts +74 -0
  213. package/src/modules/billing/cli.ts +89 -0
  214. package/src/modules/billing/commands/accounts.ts +205 -0
  215. package/src/modules/billing/commands/index.ts +13 -0
  216. package/src/modules/billing/commands/invoices.ts +542 -0
  217. package/src/modules/billing/commands/items.ts +438 -0
  218. package/src/modules/billing/commands/runs.ts +166 -0
  219. package/src/modules/billing/commands/usage.ts +118 -0
  220. package/src/modules/billing/components/AccountForm.tsx +517 -0
  221. package/src/modules/billing/components/DetailFields.tsx +60 -0
  222. package/src/modules/billing/components/ItemForm.tsx +558 -0
  223. package/src/modules/billing/components/LineFormDialog.tsx +253 -0
  224. package/src/modules/billing/components/UsageForm.tsx +326 -0
  225. package/src/modules/billing/data/entities.ts +533 -0
  226. package/src/modules/billing/data/entityIds.ts +25 -0
  227. package/src/modules/billing/data/validators.ts +562 -0
  228. package/src/modules/billing/di.ts +14 -0
  229. package/src/modules/billing/events.ts +47 -0
  230. package/src/modules/billing/i18n/en.json +336 -0
  231. package/src/modules/billing/i18n/pl.json +336 -0
  232. package/src/modules/billing/index.ts +22 -0
  233. package/src/modules/billing/lib/billPeriod.ts +181 -0
  234. package/src/modules/billing/lib/billRunEngine.ts +416 -0
  235. package/src/modules/billing/lib/billRunLabel.ts +14 -0
  236. package/src/modules/billing/lib/idempotency.ts +46 -0
  237. package/src/modules/billing/lib/invoiceStatus.ts +61 -0
  238. package/src/modules/billing/lib/invoiceWriter.ts +260 -0
  239. package/src/modules/billing/lib/itemSelector.ts +154 -0
  240. package/src/modules/billing/lib/seeds/demo.ts +199 -0
  241. package/src/modules/billing/lib/tenantLock.ts +87 -0
  242. package/src/modules/billing/lib/usageRater.ts +250 -0
  243. package/src/modules/billing/lib/usageRunner.ts +189 -0
  244. package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
  245. package/src/modules/billing/setup.ts +252 -0
  246. package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
  247. package/src/modules/billing/translations.ts +9 -0
  248. package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
@@ -0,0 +1,252 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
3
+ import { features as billingAclFeatures } from './acl'
4
+ import {
5
+ Dictionary,
6
+ DictionaryEntry,
7
+ } from '@open-mercato/core/modules/dictionaries/data/entities'
8
+ import { ModuleConfig } from '@open-mercato/core/modules/configs/data/entities'
9
+
10
+ /**
11
+ * Billing module setup — per-tenant defaults at module installation.
12
+ *
13
+ * Idempotency contract:
14
+ * - All seed helpers below check for an existing row before inserting.
15
+ * - Safe to re-run on every `mercato init` and on `auth sync-role-acls`.
16
+ *
17
+ * What this file seeds:
18
+ * - `billing.uom` dictionary with 16 global codes (operators can add more).
19
+ * - `sales.invoice_status` dictionary with `draft` / `posted` / `paid` /
20
+ * `void` so the core/sales Invoice entity's dictionary-driven status
21
+ * workflow has values to point at. (Core/sales does NOT seed these —
22
+ * billing owns them per Phase 0 validation.)
23
+ * - `billing.cron_schedule` + `billing.cron_enabled` module configs.
24
+ * - `billing.invoice_number.format` + `.reset_cycle` configs (per-tenant
25
+ * defaults; the actual atomic numbering uses core/sales'
26
+ * `SalesDocumentSequence` which is created by core/sales' own
27
+ * `onTenantCreated`).
28
+ *
29
+ * Default role-feature grants:
30
+ * - `admin` gets every `billing.*` feature.
31
+ * - `billing_admin` is the day-to-day finance operator.
32
+ * - `billing_finance_user` is read + dry-run (no posting / no real trigger).
33
+ * - `billing_usage_writer` is the narrow scope for external system API keys.
34
+ */
35
+
36
+ const BILLING_UOM_DICTIONARY_KEY = 'billing.uom'
37
+ const SALES_INVOICE_STATUS_DICTIONARY_KEY = 'sales.invoice_status'
38
+
39
+ const BILLING_UOM_CODES = [
40
+ // time
41
+ { value: 'hour', label: 'Hour' },
42
+ { value: 'day', label: 'Day' },
43
+ { value: 'week', label: 'Week' },
44
+ { value: 'month', label: 'Month' },
45
+ // volume
46
+ { value: 'gb', label: 'Gigabyte' },
47
+ { value: 'tb', label: 'Terabyte' },
48
+ { value: 'mb', label: 'Megabyte' },
49
+ // requests / tokens
50
+ { value: 'request', label: 'Request' },
51
+ { value: 'api_request', label: 'API Request' },
52
+ { value: 'token', label: 'Token' },
53
+ { value: 'input_token', label: 'Input Token' },
54
+ { value: 'output_token', label: 'Output Token' },
55
+ // seats / devices
56
+ { value: 'seat', label: 'Seat' },
57
+ { value: 'user', label: 'User' },
58
+ { value: 'device', label: 'Device' },
59
+ { value: 'active_user', label: 'Active User' },
60
+ ] as const
61
+
62
+ const INVOICE_STATUS_ENTRIES = [
63
+ // `posted` is the canonical "issued" state and is also the default a
64
+ // freshly created invoice transitions to — but billing creates drafts,
65
+ // so `draft` is the default DictionaryEntry for new rows.
66
+ { value: 'draft', label: 'Draft', isDefault: true, color: '#9ca3af' },
67
+ { value: 'posted', label: 'Posted', isDefault: false, color: '#2563eb' },
68
+ { value: 'paid', label: 'Paid', isDefault: false, color: '#16a34a' },
69
+ { value: 'void', label: 'Void', isDefault: false, color: '#dc2626' },
70
+ ] as const
71
+
72
+ type SeedScope = { tenantId: string; organizationId: string }
73
+
74
+ async function ensureDictionary(
75
+ em: EntityManager,
76
+ scope: SeedScope,
77
+ params: { key: string; name: string; description: string },
78
+ ): Promise<Dictionary> {
79
+ const existing = await em.findOne(Dictionary, {
80
+ tenantId: scope.tenantId,
81
+ organizationId: scope.organizationId,
82
+ key: params.key,
83
+ deletedAt: null,
84
+ })
85
+ if (existing) return existing
86
+ const dictionary = em.create(Dictionary, {
87
+ tenantId: scope.tenantId,
88
+ organizationId: scope.organizationId,
89
+ key: params.key,
90
+ name: params.name,
91
+ description: params.description,
92
+ isSystem: true,
93
+ isActive: true,
94
+ managerVisibility: 'hidden',
95
+ createdAt: new Date(),
96
+ updatedAt: new Date(),
97
+ })
98
+ em.persist(dictionary)
99
+ await em.flush()
100
+ return dictionary
101
+ }
102
+
103
+ async function ensureDictionaryEntries(
104
+ em: EntityManager,
105
+ scope: SeedScope,
106
+ dictionary: Dictionary,
107
+ entries: ReadonlyArray<{
108
+ value: string
109
+ label: string
110
+ isDefault?: boolean
111
+ color?: string
112
+ }>,
113
+ ): Promise<void> {
114
+ const existing = await em.find(DictionaryEntry, {
115
+ dictionary,
116
+ tenantId: scope.tenantId,
117
+ organizationId: scope.organizationId,
118
+ })
119
+ const existingValues = new Set(existing.map((e) => e.value))
120
+ let position = existing.length
121
+ for (const entry of entries) {
122
+ if (existingValues.has(entry.value)) continue
123
+ em.persist(
124
+ em.create(DictionaryEntry, {
125
+ dictionary,
126
+ tenantId: scope.tenantId,
127
+ organizationId: scope.organizationId,
128
+ value: entry.value,
129
+ normalizedValue: entry.value.trim().toLowerCase(),
130
+ label: entry.label,
131
+ color: entry.color ?? null,
132
+ position,
133
+ isDefault: entry.isDefault ?? false,
134
+ createdAt: new Date(),
135
+ updatedAt: new Date(),
136
+ }),
137
+ )
138
+ position += 1
139
+ }
140
+ await em.flush()
141
+ }
142
+
143
+ async function seedBillingUomDictionary(em: EntityManager, scope: SeedScope): Promise<void> {
144
+ const dict = await ensureDictionary(em, scope, {
145
+ key: BILLING_UOM_DICTIONARY_KEY,
146
+ name: 'Billing units of measure',
147
+ description: 'Units billing accepts on usage records (exact match — no conversion).',
148
+ })
149
+ await ensureDictionaryEntries(em, scope, dict, BILLING_UOM_CODES)
150
+ }
151
+
152
+ async function seedInvoiceStatusDictionary(em: EntityManager, scope: SeedScope): Promise<void> {
153
+ const dict = await ensureDictionary(em, scope, {
154
+ key: SALES_INVOICE_STATUS_DICTIONARY_KEY,
155
+ name: 'Sales invoice statuses',
156
+ description: 'draft → posted → paid (or void). Drives the billing draft/post lifecycle.',
157
+ })
158
+ await ensureDictionaryEntries(em, scope, dict, INVOICE_STATUS_ENTRIES)
159
+ }
160
+
161
+ async function ensureModuleConfig(
162
+ em: EntityManager,
163
+ params: { moduleId: string; name: string; value: unknown },
164
+ ): Promise<void> {
165
+ const existing = await em.findOne(ModuleConfig, {
166
+ moduleId: params.moduleId,
167
+ name: params.name,
168
+ })
169
+ if (existing) return
170
+ em.persist(
171
+ em.create(ModuleConfig, {
172
+ moduleId: params.moduleId,
173
+ name: params.name,
174
+ valueJson: params.value as Record<string, unknown> | null,
175
+ createdAt: new Date(),
176
+ updatedAt: new Date(),
177
+ }),
178
+ )
179
+ await em.flush()
180
+ }
181
+
182
+ async function seedBillingConfigs(em: EntityManager): Promise<void> {
183
+ await ensureModuleConfig(em, {
184
+ moduleId: 'billing',
185
+ name: 'cron_schedule',
186
+ value: '0 2 * * *',
187
+ })
188
+ await ensureModuleConfig(em, {
189
+ moduleId: 'billing',
190
+ name: 'cron_enabled',
191
+ value: true,
192
+ })
193
+ await ensureModuleConfig(em, {
194
+ moduleId: 'billing',
195
+ name: 'invoice_number.format',
196
+ value: 'INV-{YYYY}-{0000001}',
197
+ })
198
+ await ensureModuleConfig(em, {
199
+ moduleId: 'billing',
200
+ name: 'invoice_number.reset_cycle',
201
+ value: 'yearly',
202
+ })
203
+ }
204
+
205
+ // Derived from `acl.ts` so a new feature added there automatically lands
206
+ // in every role that should get "everything billing" — no parallel list
207
+ // to keep in sync.
208
+ const ALL_BILLING_FEATURES = billingAclFeatures.map((f) => f.id)
209
+
210
+ export const setup: ModuleSetupConfig = {
211
+ defaultRoleFeatures: {
212
+ // Org admins get every billing feature — `billing.*` wildcard is matched
213
+ // by the shared feature matcher (`hasFeature` is wildcard-aware).
214
+ admin: ['billing.*'],
215
+
216
+ // Day-to-day finance operator persona.
217
+ billing_admin: [...ALL_BILLING_FEATURES],
218
+
219
+ // Read-only + dry-run preview — auditor / finance-watcher persona.
220
+ billing_finance_user: [
221
+ 'billing.account.view',
222
+ 'billing.item.view',
223
+ 'billing.usage.view',
224
+ 'billing.run.view',
225
+ 'billing.run.dry_run',
226
+ 'billing.invoice.view',
227
+ ],
228
+
229
+ // Narrow scope for external usage-ingestion API keys.
230
+ billing_usage_writer: ['billing.usage.ingest'],
231
+ },
232
+
233
+ /**
234
+ * Tenant init: per-tenant module configs (cron, invoice number defaults).
235
+ * Runs inside `setupInitialTenant()`, always — no `--no-examples` gate.
236
+ */
237
+ async onTenantCreated({ em }) {
238
+ await seedBillingConfigs(em)
239
+ },
240
+
241
+ /**
242
+ * Reference data: dictionaries (UoM, invoice statuses). Always runs
243
+ * during `mercato init`. Idempotent — re-seed is safe.
244
+ */
245
+ async seedDefaults({ em, tenantId, organizationId }) {
246
+ const scope = { tenantId, organizationId }
247
+ await seedBillingUomDictionary(em, scope)
248
+ await seedInvoiceStatusDictionary(em, scope)
249
+ },
250
+ }
251
+
252
+ export default setup
@@ -0,0 +1,89 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { SalesInvoice } from '@open-mercato/core/modules/sales/data/entities'
4
+ import { resolveInvoiceStatusEntryId } from '../lib/invoiceStatus'
5
+
6
+ /**
7
+ * Subscriber: payment captured → billing invoice paid.
8
+ *
9
+ * Spec wishlist was `payments.payment.completed` carrying `invoice_id`.
10
+ * The actual upstream event is `payment_gateways.payment.captured` and
11
+ * its payload carries `paymentId` — set by whoever created the payment
12
+ * session. Billing flows that drive their own payment sessions can
13
+ * pass `paymentId = invoice.id`; the subscriber matches on that.
14
+ *
15
+ * Behaviour:
16
+ * 1. Try to find a `core/sales` invoice with `id = payload.paymentId`
17
+ * scoped to the same tenant/organization.
18
+ * 2. If found AND its `status == 'posted'`, flip it to `'paid'`
19
+ * (resolves the `paid` `DictionaryEntry`, updates `status` +
20
+ * `status_entry_id`). Drafts and already-paid / voided invoices
21
+ * are left untouched.
22
+ * 3. If `paymentId` does not point at a billing-known invoice (e.g.
23
+ * the payment was for a `sales.order`), the subscriber is a
24
+ * no-op — every other module's own subscriber owns its slice.
25
+ *
26
+ * Cross-tenant safety: we always re-scope by `tenantId` +
27
+ * `organizationId` from the event payload. A malformed payload (e.g.
28
+ * missing scope fields) short-circuits without touching the DB.
29
+ *
30
+ * Idempotency: re-firing the same captured event finds the invoice
31
+ * already paid and skips silently.
32
+ */
33
+
34
+ export const metadata = {
35
+ event: 'payment_gateways.payment.captured',
36
+ persistent: true,
37
+ id: 'billing-payment-captured-to-paid',
38
+ }
39
+
40
+ type CapturedPayload = {
41
+ paymentId?: string
42
+ transactionId?: string
43
+ organizationId?: string
44
+ tenantId?: string
45
+ providerKey?: string
46
+ }
47
+
48
+ export default async function handle(payload: CapturedPayload): Promise<void> {
49
+ if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) {
50
+ return
51
+ }
52
+
53
+ const container = await createRequestContainer()
54
+ const em = (container.resolve('em') as EntityManager).fork()
55
+
56
+ const invoice = await em.findOne(SalesInvoice, {
57
+ id: payload.paymentId,
58
+ tenantId: payload.tenantId,
59
+ organizationId: payload.organizationId,
60
+ deletedAt: null,
61
+ })
62
+ if (!invoice) {
63
+ // The captured payment isn't for a billing invoice — silent
64
+ // no-op. Other subscribers (sales, checkout) handle their slice.
65
+ return
66
+ }
67
+ if (invoice.status === 'paid') {
68
+ // Already paid — idempotent re-firing.
69
+ return
70
+ }
71
+ if (invoice.status !== 'posted') {
72
+ // A captured payment for a draft / void invoice is suspicious but
73
+ // not actionable here. Future iteration: log to audit_logs.
74
+ return
75
+ }
76
+
77
+ const paidStatusEntryId = await resolveInvoiceStatusEntryId(
78
+ em,
79
+ payload.tenantId,
80
+ payload.organizationId,
81
+ 'paid',
82
+ )
83
+ invoice.status = 'paid'
84
+ invoice.statusEntryId = paidStatusEntryId
85
+ invoice.paidTotalAmount = invoice.grandTotalGrossAmount
86
+ invoice.outstandingAmount = '0'
87
+ invoice.updatedAt = new Date()
88
+ await em.flush()
89
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Translatable fields for billing entities.
3
+ *
4
+ * Phase 0 keeps this empty — the Billing Account name and invoice line
5
+ * descriptions are user-entered free text, not localizable templates.
6
+ * If we ever ship localized invoice templates (Phase 4 admin UI), the
7
+ * template label fields land here.
8
+ */
9
+ export const translatableFields: Record<string, string[]> = {}
@@ -0,0 +1,57 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import type { FilterQuery } from '@mikro-orm/core'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { BillRun } from '../data/entities'
5
+
6
+ export const metadata: WorkerMeta = {
7
+ queue: 'billing-reap-stale-runs',
8
+ id: 'billing:reap-stale-bill-runs',
9
+ concurrency: 1,
10
+ }
11
+
12
+ type HandlerContext = JobContext & {
13
+ resolve: <T = unknown>(name: string) => T
14
+ }
15
+
16
+ /**
17
+ * Reaper for "zombie" Bill Runs left in `status='running'` after a crash.
18
+ *
19
+ * Background — the Bill Run engine acquires a per-tenant Postgres
20
+ * transaction-scoped advisory lock (`pg_try_advisory_xact_lock`). When the
21
+ * holding transaction ends (commit / rollback / process death) the lock is
22
+ * released automatically — so there is no lock leak — but the `BillRun`
23
+ * row's `status='running'` stays set forever, blocking restarts and
24
+ * confusing the dashboard.
25
+ *
26
+ * Phase 0 ships this as the **registered worker shell** so the module's
27
+ * worker auto-discovery includes it from day one. The active-advisory-lock
28
+ * check (which is what distinguishes a real zombie from a healthy
29
+ * long-running run) lands with the Bill Run engine itself in Phase 2.
30
+ *
31
+ * For now the heuristic is time-only: any `running` row older than 1 hour
32
+ * is marked failed with an explanatory message. That is conservative —
33
+ * real runs almost never take an hour even for 500k-account tenants
34
+ * (see spec Performance Considerations).
35
+ */
36
+ const STALE_THRESHOLD_MS = 60 * 60 * 1000 // 1 hour
37
+
38
+ export default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {
39
+ const em = ctx.resolve<EntityManager>('em')
40
+ const threshold = new Date(Date.now() - STALE_THRESHOLD_MS)
41
+ const stale = await em.find(BillRun, {
42
+ status: 'running',
43
+ startedAt: { $lt: threshold },
44
+ } satisfies FilterQuery<BillRun>)
45
+ if (stale.length === 0) return
46
+ const now = new Date()
47
+ for (const run of stale) {
48
+ run.status = 'failed'
49
+ run.finishedAt = now
50
+ const summary = (run.summary ?? {}) as Record<string, unknown>
51
+ summary.reaper_message =
52
+ 'Stale running run reaped after crash; advisory lock released'
53
+ summary.reaped_at = now.toISOString()
54
+ run.summary = summary
55
+ }
56
+ await em.flush()
57
+ }