@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,97 @@
1
+ import {
2
+ __resetInvoiceStatusCacheForTests,
3
+ resolveInvoiceStatusEntryId,
4
+ } from '../../lib/invoiceStatus'
5
+
6
+ type MockEm = {
7
+ findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
8
+ }
9
+
10
+ function createMockEm(): MockEm {
11
+ return { findOne: jest.fn() }
12
+ }
13
+
14
+ const TENANT = 'tenant-1'
15
+ const ORG = 'org-1'
16
+
17
+ beforeEach(() => {
18
+ __resetInvoiceStatusCacheForTests()
19
+ })
20
+
21
+ describe('resolveInvoiceStatusEntryId', () => {
22
+ it('looks up dictionary then matching entry', async () => {
23
+ const em = createMockEm()
24
+ em.findOne
25
+ .mockResolvedValueOnce({ id: 'dict-1' })
26
+ .mockResolvedValueOnce({ id: 'entry-draft' })
27
+
28
+ const result = await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'draft')
29
+
30
+ expect(result).toBe('entry-draft')
31
+ expect(em.findOne).toHaveBeenCalledTimes(2)
32
+ const [, dictWhere] = em.findOne.mock.calls[0]
33
+ expect((dictWhere as { key: string }).key).toBe('sales.invoice_status')
34
+ const [, entryWhere] = em.findOne.mock.calls[1]
35
+ expect((entryWhere as { value: string }).value).toBe('draft')
36
+ })
37
+
38
+ it('caches the resolved id per (tenant, org, status)', async () => {
39
+ const em = createMockEm()
40
+ em.findOne
41
+ .mockResolvedValueOnce({ id: 'dict-1' })
42
+ .mockResolvedValueOnce({ id: 'entry-paid' })
43
+
44
+ const a = await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'paid')
45
+ const b = await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'paid')
46
+
47
+ expect(a).toBe('entry-paid')
48
+ expect(b).toBe('entry-paid')
49
+ // Second call hits cache — no extra findOne invocations.
50
+ expect(em.findOne).toHaveBeenCalledTimes(2)
51
+ })
52
+
53
+ it('separate cache entries per status (draft vs posted vs paid vs void)', async () => {
54
+ const em = createMockEm()
55
+ em.findOne
56
+ .mockResolvedValueOnce({ id: 'dict-1' })
57
+ .mockResolvedValueOnce({ id: 'entry-draft' })
58
+ .mockResolvedValueOnce({ id: 'dict-1' })
59
+ .mockResolvedValueOnce({ id: 'entry-posted' })
60
+
61
+ expect(await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'draft')).toBe(
62
+ 'entry-draft',
63
+ )
64
+ expect(await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'posted')).toBe(
65
+ 'entry-posted',
66
+ )
67
+ })
68
+
69
+ it('returns null when the dictionary is missing', async () => {
70
+ const em = createMockEm()
71
+ em.findOne.mockResolvedValueOnce(null)
72
+ expect(await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'draft')).toBeNull()
73
+ })
74
+
75
+ it('returns null when the entry value is missing', async () => {
76
+ const em = createMockEm()
77
+ em.findOne.mockResolvedValueOnce({ id: 'dict-1' }).mockResolvedValueOnce(null)
78
+ expect(await resolveInvoiceStatusEntryId(em as never, TENANT, ORG, 'void')).toBeNull()
79
+ })
80
+
81
+ it('separate cache entries per tenant', async () => {
82
+ const em = createMockEm()
83
+ em.findOne
84
+ .mockResolvedValueOnce({ id: 'dict-A' })
85
+ .mockResolvedValueOnce({ id: 'entry-A' })
86
+ .mockResolvedValueOnce({ id: 'dict-B' })
87
+ .mockResolvedValueOnce({ id: 'entry-B' })
88
+
89
+ expect(await resolveInvoiceStatusEntryId(em as never, 't-A', ORG, 'draft')).toBe(
90
+ 'entry-A',
91
+ )
92
+ expect(await resolveInvoiceStatusEntryId(em as never, 't-B', ORG, 'draft')).toBe(
93
+ 'entry-B',
94
+ )
95
+ expect(em.findOne).toHaveBeenCalledTimes(4)
96
+ })
97
+ })
@@ -0,0 +1,119 @@
1
+ import { buildInvoiceLinesFromItems } from '../../lib/invoiceWriter'
2
+ import type { BillingItem } from '../../data/entities'
3
+
4
+ function makeItem(overrides: Partial<BillingItem>): BillingItem {
5
+ return {
6
+ id: overrides.id ?? 'item-1',
7
+ organizationId: overrides.organizationId ?? 'org-1',
8
+ tenantId: overrides.tenantId ?? 'tenant-1',
9
+ billAccountId: overrides.billAccountId ?? 'account-1',
10
+ type: overrides.type ?? 'recurring',
11
+ billStartDate: overrides.billStartDate ?? new Date('2026-01-01'),
12
+ billEndDate: overrides.billEndDate ?? null,
13
+ description: overrides.description ?? 'desc',
14
+ rateJson: overrides.rateJson ?? { unit_price: 49.99 },
15
+ uomCode: overrides.uomCode ?? null,
16
+ subscriptionId: overrides.subscriptionId ?? null,
17
+ subscriptionItemId: overrides.subscriptionItemId ?? null,
18
+ sourceRef: overrides.sourceRef ?? null,
19
+ currencyMismatch: overrides.currencyMismatch ?? false,
20
+ billedToDate: overrides.billedToDate ?? null,
21
+ isActive: overrides.isActive ?? true,
22
+ createdAt: overrides.createdAt ?? new Date(),
23
+ updatedAt: overrides.updatedAt ?? new Date(),
24
+ deletedAt: overrides.deletedAt ?? null,
25
+ } as BillingItem
26
+ }
27
+
28
+ describe('buildInvoiceLinesFromItems — recurring', () => {
29
+ it('builds one line per recurring item with unit_price = rateJson.unit_price', () => {
30
+ const items = [
31
+ makeItem({ id: 'a', type: 'recurring', description: 'MRC A', rateJson: { unit_price: 49.99 } }),
32
+ makeItem({ id: 'b', type: 'recurring', description: 'MRC B', rateJson: { unit_price: 29 } }),
33
+ ]
34
+ const lines = buildInvoiceLinesFromItems(items)
35
+ expect(lines).toHaveLength(2)
36
+ expect(lines[0]).toEqual({
37
+ billingItemId: 'a',
38
+ billingType: 'recurring',
39
+ description: 'MRC A',
40
+ unitPriceNet: '49.9900',
41
+ quantity: '1',
42
+ totalNetAmount: '49.9900',
43
+ })
44
+ expect(lines[1].unitPriceNet).toBe('29.0000')
45
+ })
46
+
47
+ it('rounds unit_price half-up to 2dp before persisting', () => {
48
+ const items = [
49
+ makeItem({ type: 'recurring', rateJson: { unit_price: 1.005 } }), // half-up → 1.01
50
+ makeItem({ type: 'recurring', rateJson: { unit_price: 1.004 } }), // half-down → 1.00
51
+ ]
52
+ const lines = buildInvoiceLinesFromItems(items)
53
+ expect(lines[0].unitPriceNet).toBe('1.0100')
54
+ expect(lines[1].unitPriceNet).toBe('1.0000')
55
+ })
56
+
57
+ it('skips a recurring item with malformed rateJson (defensive)', () => {
58
+ const wrong = makeItem({ type: 'recurring', rateJson: { amount: 49 } as never })
59
+ // Override directly because `makeItem`'s `??` would replace a null
60
+ // with the default — we need the actual null on the persisted item
61
+ // to exercise the builder's guard.
62
+ const nullRate = makeItem({ type: 'recurring' })
63
+ ;(nullRate as { rateJson: unknown }).rateJson = null
64
+ expect(buildInvoiceLinesFromItems([wrong, nullRate])).toEqual([])
65
+ })
66
+ })
67
+
68
+ describe('buildInvoiceLinesFromItems — one_time', () => {
69
+ it('builds one line with amount = rateJson.amount, quantity 1', () => {
70
+ const items = [
71
+ makeItem({
72
+ id: 'fee',
73
+ type: 'one_time',
74
+ description: 'Activation fee',
75
+ rateJson: { amount: 99 },
76
+ }),
77
+ ]
78
+ const lines = buildInvoiceLinesFromItems(items)
79
+ expect(lines).toHaveLength(1)
80
+ expect(lines[0]).toEqual({
81
+ billingItemId: 'fee',
82
+ billingType: 'one_time',
83
+ description: 'Activation fee',
84
+ unitPriceNet: '99.0000',
85
+ quantity: '1',
86
+ totalNetAmount: '99.0000',
87
+ })
88
+ })
89
+
90
+ it('preserves negative amounts (credit / refund / negative proration)', () => {
91
+ const items = [
92
+ makeItem({
93
+ type: 'one_time',
94
+ description: 'Cancellation credit',
95
+ rateJson: { amount: -25.5 },
96
+ }),
97
+ ]
98
+ const lines = buildInvoiceLinesFromItems(items)
99
+ expect(lines[0].unitPriceNet).toBe('-25.5000')
100
+ })
101
+ })
102
+
103
+ describe('buildInvoiceLinesFromItems — mixed + usage exclusion', () => {
104
+ it('mixes one_time and recurring while excluding usage', () => {
105
+ const items = [
106
+ makeItem({ id: 'mrc', type: 'recurring', description: 'MRC', rateJson: { unit_price: 49 } }),
107
+ makeItem({ id: 'fee', type: 'one_time', description: 'Setup', rateJson: { amount: 99 } }),
108
+ makeItem({
109
+ id: 'metered',
110
+ type: 'usage',
111
+ description: 'API',
112
+ rateJson: { unit_price: 0.001 },
113
+ }),
114
+ ]
115
+ const lines = buildInvoiceLinesFromItems(items)
116
+ expect(lines.map((l) => l.billingType)).toEqual(['recurring', 'one_time'])
117
+ expect(lines.find((l) => l.billingItemId === 'metered')).toBeUndefined()
118
+ })
119
+ })
@@ -0,0 +1,192 @@
1
+ import {
2
+ selectItemsForPeriod,
3
+ shouldIncludeItem,
4
+ type SelectableItem,
5
+ } from '../../lib/itemSelector'
6
+ import { deriveBillPeriod } from '../../lib/billPeriod'
7
+
8
+ function date(iso: string): Date {
9
+ return new Date(`${iso}T00:00:00.000Z`)
10
+ }
11
+
12
+ function baseItem(overrides: Partial<SelectableItem> = {}): SelectableItem {
13
+ return {
14
+ id: overrides.id ?? 'item-x',
15
+ type: overrides.type ?? 'recurring',
16
+ billStartDate: overrides.billStartDate ?? date('2026-01-01'),
17
+ billEndDate: overrides.billEndDate ?? null,
18
+ billedToDate: overrides.billedToDate ?? null,
19
+ description: overrides.description ?? 'desc',
20
+ rateJson: overrides.rateJson ?? { unit_price: 49.99 },
21
+ isActive: overrides.isActive ?? true,
22
+ deletedAt: overrides.deletedAt ?? null,
23
+ }
24
+ }
25
+
26
+ // May 2026 monthly period: 2026-05-01..2026-05-31
27
+ const MAY_PERIOD = deriveBillPeriod(date('2026-06-01'), 'monthly')
28
+
29
+ describe('shouldIncludeItem — type=recurring', () => {
30
+ it('includes a standard ongoing item (started before, no end)', () => {
31
+ const decision = shouldIncludeItem(baseItem({ type: 'recurring' }), MAY_PERIOD)
32
+ expect(decision.include).toBe(true)
33
+ })
34
+
35
+ it('skips an item that starts mid-cycle (2026-05-15)', () => {
36
+ const decision = shouldIncludeItem(
37
+ baseItem({ type: 'recurring', billStartDate: date('2026-05-15') }),
38
+ MAY_PERIOD,
39
+ )
40
+ expect(decision).toEqual({ include: false, reason: 'recurring-starts-mid-cycle' })
41
+ })
42
+
43
+ it('skips an item that ends mid-cycle (2026-05-15)', () => {
44
+ const decision = shouldIncludeItem(
45
+ baseItem({ type: 'recurring', billEndDate: date('2026-05-15') }),
46
+ MAY_PERIOD,
47
+ )
48
+ expect(decision).toEqual({ include: false, reason: 'recurring-ends-mid-cycle' })
49
+ })
50
+
51
+ it('skips an item already billed through this period (billed_to_date >= period_end)', () => {
52
+ const decision = shouldIncludeItem(
53
+ baseItem({ type: 'recurring', billedToDate: date('2026-05-31') }),
54
+ MAY_PERIOD,
55
+ )
56
+ expect(decision).toEqual({
57
+ include: false,
58
+ reason: 'recurring-already-billed-through-period',
59
+ })
60
+ })
61
+
62
+ it('includes when billed_to_date is from a prior cycle (< period_end)', () => {
63
+ const decision = shouldIncludeItem(
64
+ baseItem({ type: 'recurring', billedToDate: date('2026-04-30') }),
65
+ MAY_PERIOD,
66
+ )
67
+ expect(decision.include).toBe(true)
68
+ })
69
+
70
+ it('includes when item starts exactly on period_start', () => {
71
+ const decision = shouldIncludeItem(
72
+ baseItem({ type: 'recurring', billStartDate: date('2026-05-01') }),
73
+ MAY_PERIOD,
74
+ )
75
+ expect(decision.include).toBe(true)
76
+ })
77
+
78
+ it('includes when item ends exactly on period_end (full cycle covered)', () => {
79
+ const decision = shouldIncludeItem(
80
+ baseItem({
81
+ type: 'recurring',
82
+ billStartDate: date('2026-05-01'),
83
+ billEndDate: date('2026-05-31'),
84
+ }),
85
+ MAY_PERIOD,
86
+ )
87
+ expect(decision.include).toBe(true)
88
+ })
89
+ })
90
+
91
+ describe('shouldIncludeItem — type=one_time', () => {
92
+ it('includes when window overlaps period and billed_to_date is null', () => {
93
+ const decision = shouldIncludeItem(
94
+ baseItem({ type: 'one_time', rateJson: { amount: 99 } }),
95
+ MAY_PERIOD,
96
+ )
97
+ expect(decision.include).toBe(true)
98
+ })
99
+
100
+ it('skips when already billed (billed_to_date set)', () => {
101
+ const decision = shouldIncludeItem(
102
+ baseItem({
103
+ type: 'one_time',
104
+ rateJson: { amount: 99 },
105
+ billedToDate: date('2026-04-30'),
106
+ }),
107
+ MAY_PERIOD,
108
+ )
109
+ expect(decision).toEqual({ include: false, reason: 'one_time-already-billed' })
110
+ })
111
+
112
+ it('skips when window is entirely after period_end', () => {
113
+ const decision = shouldIncludeItem(
114
+ baseItem({
115
+ type: 'one_time',
116
+ billStartDate: date('2026-06-01'),
117
+ rateJson: { amount: 99 },
118
+ }),
119
+ MAY_PERIOD,
120
+ )
121
+ expect(decision).toEqual({ include: false, reason: 'one_time-window-outside-period' })
122
+ })
123
+
124
+ it('skips when window ends before period_start', () => {
125
+ const decision = shouldIncludeItem(
126
+ baseItem({
127
+ type: 'one_time',
128
+ billStartDate: date('2026-04-01'),
129
+ billEndDate: date('2026-04-15'),
130
+ rateJson: { amount: 99 },
131
+ }),
132
+ MAY_PERIOD,
133
+ )
134
+ expect(decision).toEqual({ include: false, reason: 'one_time-window-outside-period' })
135
+ })
136
+
137
+ it('includes a mid-cycle proration item (the canonical add-package case)', () => {
138
+ // Mirrors spec Story 2: New package on 2026-05-15 → one_time proration
139
+ // with bill_start_date=2026-05-15. Overlaps May period → included.
140
+ const decision = shouldIncludeItem(
141
+ baseItem({
142
+ type: 'one_time',
143
+ billStartDate: date('2026-05-15'),
144
+ rateJson: { amount: 15.9 },
145
+ }),
146
+ MAY_PERIOD,
147
+ )
148
+ expect(decision.include).toBe(true)
149
+ })
150
+ })
151
+
152
+ describe('shouldIncludeItem — guards', () => {
153
+ it('skips inactive items', () => {
154
+ expect(
155
+ shouldIncludeItem(baseItem({ isActive: false }), MAY_PERIOD),
156
+ ).toEqual({ include: false, reason: 'item-inactive' })
157
+ })
158
+
159
+ it('skips soft-deleted items', () => {
160
+ expect(
161
+ shouldIncludeItem(baseItem({ deletedAt: date('2026-04-01') }), MAY_PERIOD),
162
+ ).toEqual({ include: false, reason: 'item-soft-deleted' })
163
+ })
164
+
165
+ it('skips usage items (handled by Phase 3 rater)', () => {
166
+ expect(
167
+ shouldIncludeItem(
168
+ baseItem({ type: 'usage', rateJson: { unit_price: 0.001 } }),
169
+ MAY_PERIOD,
170
+ ),
171
+ ).toEqual({ include: false, reason: 'usage-handled-by-rater' })
172
+ })
173
+ })
174
+
175
+ describe('selectItemsForPeriod', () => {
176
+ it('partitions a mix into included + skipped with reasons', () => {
177
+ const items = [
178
+ baseItem({ id: 'a', type: 'recurring' }), // include
179
+ baseItem({ id: 'b', type: 'recurring', billStartDate: date('2026-05-15') }), // mid-cycle start
180
+ baseItem({ id: 'c', type: 'one_time', rateJson: { amount: 99 } }), // include
181
+ baseItem({ id: 'd', type: 'usage', rateJson: { unit_price: 0.001 } }), // usage → skipped
182
+ baseItem({ id: 'e', type: 'recurring', billedToDate: date('2026-05-31') }), // already billed
183
+ ]
184
+ const { included, skipped } = selectItemsForPeriod(items, MAY_PERIOD)
185
+ expect(included.map((i) => i.id)).toEqual(['a', 'c'])
186
+ expect(skipped.map((s) => ({ id: s.item.id, reason: s.reason }))).toEqual([
187
+ { id: 'b', reason: 'recurring-starts-mid-cycle' },
188
+ { id: 'd', reason: 'usage-handled-by-rater' },
189
+ { id: 'e', reason: 'recurring-already-billed-through-period' },
190
+ ])
191
+ })
192
+ })
@@ -0,0 +1,86 @@
1
+ import { seedBillingDemo, resetBillingDemo } from '../../lib/seeds/demo'
2
+
3
+ /**
4
+ * In-memory mock EM that mirrors the find-or-create contract the seed relies
5
+ * on: `findOne` matches accounts by `name` and items/usage by `sourceRef`,
6
+ * `create` mints an id, `persist` stores, `nativeDelete` records calls.
7
+ */
8
+ function makeMockEm() {
9
+ const store: Array<Record<string, unknown>> = []
10
+ let idSeq = 0
11
+ const nativeDeleteCalls: unknown[] = []
12
+
13
+ const em = {
14
+ store,
15
+ nativeDeleteCalls,
16
+ findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) => {
17
+ return (
18
+ store.find((row) => {
19
+ if (typeof where.name === 'string') return row.name === where.name
20
+ if (typeof where.sourceRef === 'string') return row.sourceRef === where.sourceRef
21
+ return false
22
+ }) ?? null
23
+ )
24
+ }),
25
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
26
+ return { id: `id-${++idSeq}`, ...data }
27
+ }),
28
+ persist: jest.fn((entity: Record<string, unknown>) => {
29
+ store.push(entity)
30
+ return entity
31
+ }),
32
+ flush: jest.fn(async () => undefined),
33
+ nativeDelete: jest.fn(async (_entity: unknown, where: unknown) => {
34
+ nativeDeleteCalls.push(where)
35
+ return 0
36
+ }),
37
+ }
38
+ return em as never as Parameters<typeof seedBillingDemo>[0] & {
39
+ store: Array<Record<string, unknown>>
40
+ nativeDeleteCalls: unknown[]
41
+ }
42
+ }
43
+
44
+ const SCOPE = { tenantId: 'tnt-1', organizationId: 'org-1' }
45
+ const NOW = new Date('2026-06-08T12:00:00Z')
46
+
47
+ describe('seedBillingDemo', () => {
48
+ it('creates the demo accounts, items and usage on a fresh tenant', async () => {
49
+ const em = makeMockEm()
50
+ const res = await seedBillingDemo(em, SCOPE, { now: NOW })
51
+
52
+ expect(res).toEqual({ accountsCreated: 2, itemsCreated: 3, usageCreated: 1 })
53
+ })
54
+
55
+ it('is idempotent — a second run creates nothing', async () => {
56
+ const em = makeMockEm()
57
+ await seedBillingDemo(em, SCOPE, { now: NOW })
58
+ const second = await seedBillingDemo(em, SCOPE, { now: NOW })
59
+
60
+ expect(second).toEqual({ accountsCreated: 0, itemsCreated: 0, usageCreated: 0 })
61
+ })
62
+
63
+ it('dates the primary account so the previous month is billable', async () => {
64
+ const em = makeMockEm()
65
+ await seedBillingDemo(em, SCOPE, { now: NOW })
66
+
67
+ const account = em.store.find((r) => r.name === 'Demo — Northwind Telecom') as
68
+ | { nextBillDate: Date }
69
+ | undefined
70
+ const usage = em.store.find((r) => r.sourceRef === 'demo-billing-nw-usage') as
71
+ | { periodStart: Date; periodEnd: Date }
72
+ | undefined
73
+
74
+ // next_bill = 1st of the current (UTC) month → due for any later as-of date.
75
+ expect(account?.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
76
+ // usage sits in the previous month, the period that run will cover.
77
+ expect(usage?.periodStart.toISOString().slice(0, 10)).toBe('2026-05-01')
78
+ expect(usage?.periodEnd.toISOString().slice(0, 10)).toBe('2026-05-31')
79
+ })
80
+
81
+ it('reset issues a delete for usage, items and accounts', async () => {
82
+ const em = makeMockEm()
83
+ await resetBillingDemo(em, SCOPE)
84
+ expect(em.nativeDelete).toHaveBeenCalledTimes(3)
85
+ })
86
+ })
@@ -0,0 +1,126 @@
1
+ import {
2
+ buildLockKey,
3
+ TenantLockBusyError,
4
+ withTenantLock,
5
+ } from '../../lib/tenantLock'
6
+
7
+ type MockEm = {
8
+ transactional: jest.MockedFunction<
9
+ (fn: (tem: MockEm) => Promise<unknown>) => Promise<unknown>
10
+ >
11
+ execute: jest.MockedFunction<(sql: string, params?: unknown[]) => Promise<unknown>>
12
+ }
13
+
14
+ function createMockEm(): MockEm {
15
+ const em: MockEm = {
16
+ transactional: jest.fn(async (fn) => fn(em)),
17
+ execute: jest.fn(),
18
+ }
19
+ return em
20
+ }
21
+
22
+ const TENANT_A = '00000000-0000-0000-0000-000000000001'
23
+ const TENANT_B = '00000000-0000-0000-0000-000000000002'
24
+
25
+ describe('buildLockKey', () => {
26
+ it('builds a stable, namespaced key', () => {
27
+ expect(buildLockKey(TENANT_A, 'billing-run')).toBe(
28
+ `dainamite-billing:${TENANT_A}:billing-run`,
29
+ )
30
+ })
31
+
32
+ it('returns distinct keys per tenant', () => {
33
+ expect(buildLockKey(TENANT_A, 'billing-run')).not.toEqual(
34
+ buildLockKey(TENANT_B, 'billing-run'),
35
+ )
36
+ })
37
+
38
+ it('returns distinct keys per lockName', () => {
39
+ expect(buildLockKey(TENANT_A, 'billing-run')).not.toEqual(
40
+ buildLockKey(TENANT_A, 'other-job'),
41
+ )
42
+ })
43
+ })
44
+
45
+ describe('withTenantLock', () => {
46
+ it('runs the callback when the advisory lock is acquired', async () => {
47
+ const em = createMockEm()
48
+ em.execute.mockResolvedValueOnce([{ acquired: true }])
49
+
50
+ const inner = jest.fn(async () => 'work-result')
51
+ const result = await withTenantLock(
52
+ em as never,
53
+ { tenantId: TENANT_A, lockName: 'billing-run' },
54
+ inner,
55
+ )
56
+
57
+ expect(result).toBe('work-result')
58
+ expect(em.transactional).toHaveBeenCalledTimes(1)
59
+ expect(em.execute).toHaveBeenCalledTimes(1)
60
+ expect(inner).toHaveBeenCalledTimes(1)
61
+ })
62
+
63
+ it('passes the expected lock key into pg_try_advisory_xact_lock', async () => {
64
+ const em = createMockEm()
65
+ em.execute.mockResolvedValueOnce([{ acquired: true }])
66
+
67
+ await withTenantLock(
68
+ em as never,
69
+ { tenantId: TENANT_A, lockName: 'billing-run' },
70
+ async () => 'ok',
71
+ )
72
+
73
+ const [sql, params] = em.execute.mock.calls[0]
74
+ expect(sql).toMatch(/pg_try_advisory_xact_lock/i)
75
+ expect(sql).toMatch(/hashtext/i)
76
+ expect(params).toEqual([buildLockKey(TENANT_A, 'billing-run')])
77
+ })
78
+
79
+ it('throws TenantLockBusyError when the lock is already held', async () => {
80
+ const em = createMockEm()
81
+ em.execute.mockResolvedValueOnce([{ acquired: false }])
82
+
83
+ const inner = jest.fn(async () => 'should-not-run')
84
+
85
+ await expect(
86
+ withTenantLock(
87
+ em as never,
88
+ { tenantId: TENANT_A, lockName: 'billing-run' },
89
+ inner,
90
+ ),
91
+ ).rejects.toBeInstanceOf(TenantLockBusyError)
92
+
93
+ expect(inner).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it('TenantLockBusyError carries tenantId + lockName for HTTP 409 mapping', async () => {
97
+ const em = createMockEm()
98
+ em.execute.mockResolvedValueOnce([{ acquired: false }])
99
+
100
+ try {
101
+ await withTenantLock(
102
+ em as never,
103
+ { tenantId: TENANT_A, lockName: 'billing-run' },
104
+ async () => 'never',
105
+ )
106
+ throw new Error('expected throw')
107
+ } catch (err) {
108
+ expect(err).toBeInstanceOf(TenantLockBusyError)
109
+ const lockErr = err as TenantLockBusyError
110
+ expect(lockErr.tenantId).toBe(TENANT_A)
111
+ expect(lockErr.lockName).toBe('billing-run')
112
+ }
113
+ })
114
+
115
+ it('tolerates the driver returning a single row object instead of an array', async () => {
116
+ const em = createMockEm()
117
+ em.execute.mockResolvedValueOnce({ acquired: true } as never)
118
+
119
+ const result = await withTenantLock(
120
+ em as never,
121
+ { tenantId: TENANT_A, lockName: 'billing-run' },
122
+ async () => 'ok',
123
+ )
124
+ expect(result).toBe('ok')
125
+ })
126
+ })