@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,284 @@
1
+ /**
2
+ * Unit tests for `billing.items.bulk_create`.
3
+ *
4
+ * Focus: the batching contract — one account query, one idempotency
5
+ * query, one flush — plus the dedup paths (existing source_ref,
6
+ * in-batch repeated source_ref) and the missing-account 404.
7
+ */
8
+
9
+ import { bulkCreateItemsCommand } from '../../commands/items'
10
+ import { BillingAccount, BillingItem } from '../../data/entities'
11
+
12
+ const TENANT = '11111111-1111-4111-8111-111111111111'
13
+ const ORG = '22222222-2222-4222-8222-222222222222'
14
+ const ACCOUNT_A = '33333333-3333-4333-8333-333333333333'
15
+ const ACCOUNT_B = '44444444-4444-4444-8444-444444444444'
16
+
17
+ type FakeItem = {
18
+ id: string
19
+ billAccountId: string
20
+ sourceRef: string | null
21
+ }
22
+
23
+ type Env = {
24
+ ctx: { container: { resolve: jest.MockedFunction<(name: string) => unknown> }; auth: null }
25
+ em: {
26
+ find: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown[]>>
27
+ create: jest.MockedFunction<(entity: unknown, data: Record<string, unknown>) => unknown>
28
+ persist: jest.MockedFunction<(entity: unknown) => unknown>
29
+ flush: jest.MockedFunction<() => Promise<void>>
30
+ fork: jest.MockedFunction<() => Env['em']>
31
+ }
32
+ flushCount: () => number
33
+ persistedCount: () => number
34
+ }
35
+
36
+ let createSeq = 0
37
+
38
+ function createEnv(
39
+ options: {
40
+ existingAccountIds?: string[]
41
+ existingItems?: FakeItem[]
42
+ } = {},
43
+ ): Env {
44
+ const existingAccountIds = options.existingAccountIds ?? [ACCOUNT_A, ACCOUNT_B]
45
+ const existingItems = options.existingItems ?? []
46
+ let flushes = 0
47
+ let persists = 0
48
+ const pendingPersist: Record<string, unknown>[] = []
49
+
50
+ const em: Env['em'] = {
51
+ find: jest.fn(async (entity: unknown, where: unknown) => {
52
+ const ctor = (entity as { name?: string }).name ?? ''
53
+ if (ctor === 'BillingAccount') {
54
+ const ids = (where as { id?: { $in?: string[] } }).id?.$in ?? []
55
+ return ids
56
+ .filter((id) => existingAccountIds.includes(id))
57
+ .map((id) => ({ id, tenantId: TENANT, organizationId: ORG }))
58
+ }
59
+ if (ctor === 'BillingItem') {
60
+ const refs = (where as { sourceRef?: { $in?: string[] } }).sourceRef?.$in ?? []
61
+ const accountIds = (where as { billAccountId?: { $in?: string[] } }).billAccountId?.$in ?? []
62
+ return existingItems.filter(
63
+ (it) =>
64
+ it.sourceRef != null &&
65
+ refs.includes(it.sourceRef) &&
66
+ accountIds.includes(it.billAccountId),
67
+ )
68
+ }
69
+ return []
70
+ }) as never,
71
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
72
+ // Real MikroORM leaves the UUID PK unassigned until flush — the
73
+ // mock mirrors that so id-before-flush bugs surface in tests.
74
+ return { ...data }
75
+ }) as never,
76
+ persist: jest.fn((entity: unknown) => {
77
+ persists += 1
78
+ pendingPersist.push(entity as Record<string, unknown>)
79
+ }) as never,
80
+ flush: jest.fn(async () => {
81
+ flushes += 1
82
+ // The flush is what assigns the DB-generated UUID PK.
83
+ for (const entity of pendingPersist) {
84
+ if (!entity.id) {
85
+ createSeq += 1
86
+ entity.id = `new-item-${createSeq}`
87
+ }
88
+ }
89
+ pendingPersist.length = 0
90
+ }) as never,
91
+ fork: jest.fn() as never,
92
+ }
93
+ em.fork = jest.fn(() => em) as never
94
+
95
+ const container = {
96
+ resolve: jest.fn((name: string) => {
97
+ if (name === 'em') return em
98
+ if (name === 'dataEngine') return { markOrmEntityChange: jest.fn() }
99
+ return null
100
+ }) as never,
101
+ }
102
+
103
+ return {
104
+ ctx: { container, auth: null },
105
+ em,
106
+ flushCount: () => flushes,
107
+ persistedCount: () => persists,
108
+ }
109
+ }
110
+
111
+ beforeEach(() => {
112
+ createSeq = 0
113
+ })
114
+
115
+ function recurringEntry(billAccountId: string, sourceRef?: string) {
116
+ // `billStartDate` is a Date here because `BillingItemBulkCreateInput`
117
+ // is the *parsed* schema type (`z.coerce.date()` output). The route
118
+ // layer hands the command already-coerced values.
119
+ return {
120
+ billAccountId,
121
+ type: 'recurring' as const,
122
+ billStartDate: new Date('2026-05-01T00:00:00.000Z'),
123
+ description: 'MRC',
124
+ rateJson: { unit_price: 49.99 } as unknown,
125
+ ...(sourceRef ? { sourceRef } : {}),
126
+ }
127
+ }
128
+
129
+ describe('bulkCreateItemsCommand', () => {
130
+ it('creates every item in a clean batch with a single flush', async () => {
131
+ const env = createEnv()
132
+ const result = await bulkCreateItemsCommand.execute(
133
+ {
134
+ tenantId: TENANT,
135
+ organizationId: ORG,
136
+ items: [
137
+ recurringEntry(ACCOUNT_A, 'ref-1'),
138
+ recurringEntry(ACCOUNT_A, 'ref-2'),
139
+ recurringEntry(ACCOUNT_B, 'ref-3'),
140
+ ],
141
+ },
142
+ env.ctx as never,
143
+ )
144
+ expect(result.created).toBe(3)
145
+ expect(result.deduplicated).toBe(0)
146
+ expect(result.items).toHaveLength(3)
147
+ // Every created row carries the id the flush assigned — the
148
+ // result must not snapshot the pre-flush (undefined) value.
149
+ for (const item of result.items) {
150
+ expect(item.id).toMatch(/^new-item-/)
151
+ expect(item.deduplicated).toBe(false)
152
+ }
153
+ // The batching contract: ONE flush for the whole batch.
154
+ expect(env.flushCount()).toBe(1)
155
+ expect(env.persistedCount()).toBe(3)
156
+ })
157
+
158
+ it('uses a single batched idempotency query (not one per item)', async () => {
159
+ const env = createEnv()
160
+ await bulkCreateItemsCommand.execute(
161
+ {
162
+ tenantId: TENANT,
163
+ organizationId: ORG,
164
+ items: [
165
+ recurringEntry(ACCOUNT_A, 'ref-1'),
166
+ recurringEntry(ACCOUNT_A, 'ref-2'),
167
+ recurringEntry(ACCOUNT_A, 'ref-3'),
168
+ ],
169
+ },
170
+ env.ctx as never,
171
+ )
172
+ // find calls: 1 for BillingAccount, 1 for BillingItem idempotency.
173
+ const billingItemFinds = env.em.find.mock.calls.filter(
174
+ (call) => (call[0] as { name?: string }).name === 'BillingItem',
175
+ )
176
+ expect(billingItemFinds).toHaveLength(1)
177
+ })
178
+
179
+ it('dedups against an existing source_ref (returns the existing id)', async () => {
180
+ const env = createEnv({
181
+ existingItems: [{ id: 'existing-99', billAccountId: ACCOUNT_A, sourceRef: 'ref-dup' }],
182
+ })
183
+ const result = await bulkCreateItemsCommand.execute(
184
+ {
185
+ tenantId: TENANT,
186
+ organizationId: ORG,
187
+ items: [
188
+ recurringEntry(ACCOUNT_A, 'ref-dup'),
189
+ recurringEntry(ACCOUNT_A, 'ref-new'),
190
+ ],
191
+ },
192
+ env.ctx as never,
193
+ )
194
+ expect(result.created).toBe(1)
195
+ expect(result.deduplicated).toBe(1)
196
+ const dup = result.items.find((i) => i.sourceRef === 'ref-dup')
197
+ expect(dup).toMatchObject({ id: 'existing-99', deduplicated: true })
198
+ })
199
+
200
+ it('dedups a source_ref repeated within the same payload (first wins)', async () => {
201
+ const env = createEnv()
202
+ const result = await bulkCreateItemsCommand.execute(
203
+ {
204
+ tenantId: TENANT,
205
+ organizationId: ORG,
206
+ items: [
207
+ recurringEntry(ACCOUNT_A, 'ref-twice'),
208
+ recurringEntry(ACCOUNT_A, 'ref-twice'),
209
+ ],
210
+ },
211
+ env.ctx as never,
212
+ )
213
+ // Only ONE row actually persisted — the unique index would
214
+ // otherwise reject the second on flush.
215
+ expect(result.created).toBe(1)
216
+ expect(result.deduplicated).toBe(1)
217
+ expect(env.persistedCount()).toBe(1)
218
+ // The repeat reports the SAME id as the row that persisted, and
219
+ // that id is the flush-assigned value (not '' captured pre-flush).
220
+ const created = result.items.find((i) => !i.deduplicated)
221
+ const repeated = result.items.find((i) => i.deduplicated)
222
+ expect(created?.id).toMatch(/^new-item-/)
223
+ expect(repeated?.id).toBe(created?.id)
224
+ })
225
+
226
+ it('items without source_ref are always created (no dedup)', async () => {
227
+ const env = createEnv()
228
+ const result = await bulkCreateItemsCommand.execute(
229
+ {
230
+ tenantId: TENANT,
231
+ organizationId: ORG,
232
+ items: [recurringEntry(ACCOUNT_A), recurringEntry(ACCOUNT_A)],
233
+ },
234
+ env.ctx as never,
235
+ )
236
+ expect(result.created).toBe(2)
237
+ expect(result.deduplicated).toBe(0)
238
+ })
239
+
240
+ it('throws 404 when any referenced account is missing', async () => {
241
+ const env = createEnv({ existingAccountIds: [ACCOUNT_A] })
242
+ await expect(
243
+ bulkCreateItemsCommand.execute(
244
+ {
245
+ tenantId: TENANT,
246
+ organizationId: ORG,
247
+ items: [recurringEntry(ACCOUNT_A), recurringEntry(ACCOUNT_B)],
248
+ },
249
+ env.ctx as never,
250
+ ),
251
+ ).rejects.toMatchObject({
252
+ status: 404,
253
+ body: expect.objectContaining({ billAccountId: ACCOUNT_B }),
254
+ })
255
+ })
256
+
257
+ it('queries the distinct account ids in one $in (not one per item)', async () => {
258
+ const env = createEnv()
259
+ await bulkCreateItemsCommand.execute(
260
+ {
261
+ tenantId: TENANT,
262
+ organizationId: ORG,
263
+ items: [
264
+ recurringEntry(ACCOUNT_A),
265
+ recurringEntry(ACCOUNT_A),
266
+ recurringEntry(ACCOUNT_B),
267
+ ],
268
+ },
269
+ env.ctx as never,
270
+ )
271
+ const accountFinds = env.em.find.mock.calls.filter(
272
+ (call) => (call[0] as { name?: string }).name === 'BillingAccount',
273
+ )
274
+ expect(accountFinds).toHaveLength(1)
275
+ const where = accountFinds[0][1] as { id?: { $in?: string[] } }
276
+ // Distinct — A appears twice in the payload but once in the query.
277
+ expect(new Set(where.id?.$in)).toEqual(new Set([ACCOUNT_A, ACCOUNT_B]))
278
+ })
279
+ })
280
+
281
+ // Suppress unused-import warnings — the entity classes are referenced
282
+ // only through the mock's `.name` discriminator.
283
+ void BillingAccount
284
+ void BillingItem
@@ -0,0 +1,131 @@
1
+ import {
2
+ advanceNextBillDate,
3
+ cloneAtMidnight,
4
+ cycleContaining,
5
+ deriveBillPeriod,
6
+ isCycleDue,
7
+ } from '../../lib/billPeriod'
8
+
9
+ function date(iso: string): Date {
10
+ return new Date(`${iso}T00:00:00.000Z`)
11
+ }
12
+
13
+ function iso(d: Date): string {
14
+ return d.toISOString().slice(0, 10)
15
+ }
16
+
17
+ describe('deriveBillPeriod — spec reference table', () => {
18
+ it('monthly anchor 1: next=2026-06-01 → 2026-05-01..2026-05-31', () => {
19
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-06-01'), 'monthly')
20
+ expect(iso(periodStart)).toBe('2026-05-01')
21
+ expect(iso(periodEnd)).toBe('2026-05-31')
22
+ })
23
+
24
+ it('monthly anchor 1: next=2026-03-01 → 2026-02-01..2026-02-28', () => {
25
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-03-01'), 'monthly')
26
+ expect(iso(periodStart)).toBe('2026-02-01')
27
+ expect(iso(periodEnd)).toBe('2026-02-28')
28
+ })
29
+
30
+ it('monthly anchor 1: leap year — next=2024-03-01 → 2024-02-01..2024-02-29', () => {
31
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2024-03-01'), 'monthly')
32
+ expect(iso(periodStart)).toBe('2024-02-01')
33
+ expect(iso(periodEnd)).toBe('2024-02-29')
34
+ })
35
+
36
+ it('quarterly anchor 1: next=2026-07-01 → 2026-04-01..2026-06-30', () => {
37
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-07-01'), 'quarterly')
38
+ expect(iso(periodStart)).toBe('2026-04-01')
39
+ expect(iso(periodEnd)).toBe('2026-06-30')
40
+ })
41
+
42
+ it('weekly anchor mon: next=2026-05-04 (Mon) → 2026-04-27 (Mon)..2026-05-03 (Sun)', () => {
43
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-05-04'), 'weekly')
44
+ expect(iso(periodStart)).toBe('2026-04-27')
45
+ expect(iso(periodEnd)).toBe('2026-05-03')
46
+ })
47
+
48
+ it('annually: next=2026-01-01 → 2025-01-01..2025-12-31', () => {
49
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-01-01'), 'annually')
50
+ expect(iso(periodStart)).toBe('2025-01-01')
51
+ expect(iso(periodEnd)).toBe('2025-12-31')
52
+ })
53
+
54
+ it('month-end clamp: next=2026-03-31 → 2026-02-28..2026-03-30 (Feb has 28 days)', () => {
55
+ const { periodStart, periodEnd } = deriveBillPeriod(date('2026-03-31'), 'monthly')
56
+ expect(iso(periodStart)).toBe('2026-02-28')
57
+ expect(iso(periodEnd)).toBe('2026-03-30')
58
+ })
59
+ })
60
+
61
+ describe('advanceNextBillDate', () => {
62
+ it.each([
63
+ ['monthly', '2026-05-01', '2026-06-01'],
64
+ ['monthly', '2024-01-31', '2024-02-29'], // leap clamp
65
+ ['quarterly', '2026-04-01', '2026-07-01'],
66
+ ['weekly', '2026-04-27', '2026-05-04'],
67
+ ['annually', '2025-01-01', '2026-01-01'],
68
+ ] as const)('%s: %s → %s', (cycle, from, to) => {
69
+ expect(iso(advanceNextBillDate(date(from), cycle))).toBe(to)
70
+ })
71
+
72
+ it('chained advances stay anchored (monthly: 2024-01-31 → … → 2024-04-30)', () => {
73
+ let d = date('2024-01-31')
74
+ d = advanceNextBillDate(d, 'monthly') // → 2024-02-29
75
+ d = advanceNextBillDate(d, 'monthly') // → 2024-03-29 (drifts off 31)
76
+ d = advanceNextBillDate(d, 'monthly') // → 2024-04-29
77
+ // Calendar drift is the documented JS semantic — once we clamp on
78
+ // Feb 29 we lose the "always last day of month" intent. This test
79
+ // pins the behavior so it doesn't quietly change.
80
+ expect(iso(d)).toBe('2024-04-29')
81
+ })
82
+ })
83
+
84
+ describe('isCycleDue', () => {
85
+ it('returns true when nextBillDate ≤ asOfDate', () => {
86
+ expect(isCycleDue(date('2026-06-01'), date('2026-06-01'))).toBe(true)
87
+ expect(isCycleDue(date('2026-05-15'), date('2026-06-01'))).toBe(true)
88
+ })
89
+
90
+ it('returns false when nextBillDate > asOfDate', () => {
91
+ expect(isCycleDue(date('2026-06-02'), date('2026-06-01'))).toBe(false)
92
+ })
93
+
94
+ it('ignores time-of-day (date-only comparison)', () => {
95
+ const next = new Date('2026-06-01T23:59:59Z')
96
+ const asOf = new Date('2026-06-01T00:00:00Z')
97
+ expect(isCycleDue(next, asOf)).toBe(true)
98
+ })
99
+ })
100
+
101
+ describe('cloneAtMidnight', () => {
102
+ it('zeros UTC time without mutating the source', () => {
103
+ const src = new Date('2026-06-01T13:45:30Z')
104
+ const result = cloneAtMidnight(src)
105
+ expect(result.toISOString()).toBe('2026-06-01T00:00:00.000Z')
106
+ expect(src.toISOString()).toBe('2026-06-01T13:45:30.000Z')
107
+ })
108
+ })
109
+
110
+ describe('cycleContaining', () => {
111
+ it.each([
112
+ // anchor (a known boundary), cycle, target, expectedStart, expectedEnd
113
+ ['2026-06-01', 'monthly', '2026-05-15', '2026-05-01', '2026-05-31'],
114
+ ['2026-06-01', 'monthly', '2026-06-01', '2026-06-01', '2026-06-30'], // anchor = cycle start
115
+ ['2026-06-01', 'monthly', '2026-06-10', '2026-06-01', '2026-06-30'],
116
+ ['2026-06-01', 'monthly', '2026-07-15', '2026-07-01', '2026-07-31'], // walk forward
117
+ ['2026-06-01', 'monthly', '2026-02-15', '2026-02-01', '2026-02-28'], // walk backward
118
+ ['2026-05-04', 'weekly', '2026-05-06', '2026-05-04', '2026-05-10'],
119
+ ['2026-07-01', 'quarterly', '2026-05-20', '2026-04-01', '2026-06-30'],
120
+ ] as const)('%s %s containing %s → %s..%s', (anchor, cycle, target, start, end) => {
121
+ const period = cycleContaining(date(anchor), cycle, date(target))
122
+ expect(iso(period.periodStart)).toBe(start)
123
+ expect(iso(period.periodEnd)).toBe(end)
124
+ })
125
+
126
+ it('accepts YYYY-MM-DD strings for anchor and target', () => {
127
+ const period = cycleContaining('2026-06-01', 'monthly', '2026-05-15')
128
+ expect(iso(period.periodStart)).toBe('2026-05-01')
129
+ expect(iso(period.periodEnd)).toBe('2026-05-31')
130
+ })
131
+ })