@dainamite/billing 0.18.0 → 0.20.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 (93) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -0
  3. package/dist/modules/billing/api/export/account/[id]/route.js +5 -5
  4. package/dist/modules/billing/api/export/account/[id]/route.js.map +1 -1
  5. package/dist/modules/billing/api/invoices/add-line/route.js +1 -1
  6. package/dist/modules/billing/api/invoices/add-line/route.js.map +1 -1
  7. package/dist/modules/billing/api/invoices/edit-line/route.js +1 -1
  8. package/dist/modules/billing/api/invoices/edit-line/route.js.map +1 -1
  9. package/dist/modules/billing/api/invoices/remove-line/route.js +1 -1
  10. package/dist/modules/billing/api/invoices/remove-line/route.js.map +1 -1
  11. package/dist/modules/billing/api/run-outcomes/route.js +2 -2
  12. package/dist/modules/billing/api/run-outcomes/route.js.map +2 -2
  13. package/dist/modules/billing/api/runs/retry-failed/route.js +2 -2
  14. package/dist/modules/billing/api/runs/retry-failed/route.js.map +2 -2
  15. package/dist/modules/billing/api/runs/route.js +2 -2
  16. package/dist/modules/billing/api/runs/route.js.map +2 -2
  17. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +1 -1
  18. package/dist/modules/billing/backend/billing/accounts/page.js.map +1 -1
  19. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +1 -1
  20. package/dist/modules/billing/backend/billing/invoices/page.js.map +1 -1
  21. package/dist/modules/billing/backend/billing/items/page.js.map +1 -1
  22. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +1 -1
  23. package/dist/modules/billing/backend/billing/runs/page.js.map +1 -1
  24. package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +1 -1
  25. package/dist/modules/billing/backend/billing/statements/page.js.map +1 -1
  26. package/dist/modules/billing/backend/billing/topups/page.js.map +1 -1
  27. package/dist/modules/billing/backend/billing/transactions/page.js.map +1 -1
  28. package/dist/modules/billing/ce.js.map +2 -2
  29. package/dist/modules/billing/commands/invoices.js +4 -4
  30. package/dist/modules/billing/commands/invoices.js.map +2 -2
  31. package/dist/modules/billing/commands/runs.js +3 -3
  32. package/dist/modules/billing/commands/runs.js.map +2 -2
  33. package/dist/modules/billing/components/PrepaidPanel.js.map +1 -1
  34. package/dist/modules/billing/data/entities.js +56 -56
  35. package/dist/modules/billing/data/entities.js.map +2 -2
  36. package/dist/modules/billing/data/entityIds.js.map +2 -2
  37. package/dist/modules/billing/events.js +20 -0
  38. package/dist/modules/billing/events.js.map +2 -2
  39. package/dist/modules/billing/lib/billRunEngine.js +4 -4
  40. package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
  41. package/dist/modules/billing/lib/billRunLabel.js.map +1 -1
  42. package/dist/modules/billing/lib/prepaidPeriodClose.js.map +1 -1
  43. package/dist/modules/billing/lib/tenantLock.js.map +1 -1
  44. package/dist/modules/billing/workers/reap-stale-bill-runs.js +2 -2
  45. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +2 -2
  46. package/generated/entities/billing_account/index.ts +23 -0
  47. package/generated/entities/billing_account_balance/index.ts +13 -0
  48. package/generated/entities/billing_account_transaction/index.ts +21 -0
  49. package/generated/entities/billing_account_usage/index.ts +18 -0
  50. package/generated/entities/billing_draft_invoice_edit/index.ts +14 -0
  51. package/generated/entities/billing_item/index.ts +22 -0
  52. package/generated/entities/billing_run/index.ts +21 -0
  53. package/generated/entities/billing_run_outcome/index.ts +17 -0
  54. package/generated/entities/billing_statement/index.ts +21 -0
  55. package/generated/entities/billing_topup/index.ts +20 -0
  56. package/generated/entities.ids.generated.ts +28 -0
  57. package/package.json +11 -10
  58. package/src/modules/billing/__tests__/commands/invoices.test.ts +8 -8
  59. package/src/modules/billing/__tests__/commands/topups.test.ts +1 -1
  60. package/src/modules/billing/__tests__/commands/transactions.test.ts +25 -2
  61. package/src/modules/billing/__tests__/entityRegistry.test.ts +319 -0
  62. package/src/modules/billing/__tests__/events.test.ts +142 -0
  63. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +3 -3
  64. package/src/modules/billing/api/export/account/[id]/route.ts +7 -7
  65. package/src/modules/billing/api/invoices/add-line/route.ts +1 -1
  66. package/src/modules/billing/api/invoices/edit-line/route.ts +2 -2
  67. package/src/modules/billing/api/invoices/remove-line/route.ts +1 -1
  68. package/src/modules/billing/api/run-outcomes/route.ts +2 -2
  69. package/src/modules/billing/api/runs/retry-failed/route.ts +2 -2
  70. package/src/modules/billing/api/runs/route.ts +2 -2
  71. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +1 -1
  72. package/src/modules/billing/backend/billing/accounts/page.tsx +1 -1
  73. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +1 -1
  74. package/src/modules/billing/backend/billing/invoices/page.tsx +1 -1
  75. package/src/modules/billing/backend/billing/items/page.tsx +1 -1
  76. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +1 -1
  77. package/src/modules/billing/backend/billing/runs/page.tsx +1 -1
  78. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +1 -1
  79. package/src/modules/billing/backend/billing/statements/page.tsx +1 -1
  80. package/src/modules/billing/backend/billing/topups/page.tsx +1 -1
  81. package/src/modules/billing/backend/billing/transactions/page.tsx +1 -1
  82. package/src/modules/billing/ce.ts +8 -7
  83. package/src/modules/billing/commands/invoices.ts +9 -9
  84. package/src/modules/billing/commands/runs.ts +3 -3
  85. package/src/modules/billing/components/PrepaidPanel.tsx +1 -1
  86. package/src/modules/billing/data/entities.ts +31 -10
  87. package/src/modules/billing/data/entityIds.ts +16 -11
  88. package/src/modules/billing/events.ts +26 -0
  89. package/src/modules/billing/lib/billRunEngine.ts +12 -12
  90. package/src/modules/billing/lib/billRunLabel.ts +1 -1
  91. package/src/modules/billing/lib/prepaidPeriodClose.ts +3 -3
  92. package/src/modules/billing/lib/tenantLock.ts +1 -1
  93. package/src/modules/billing/workers/reap-stale-bill-runs.ts +4 -4
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Shipped entity-id registry (`packages/billing/generated/`).
3
+ *
4
+ * A standalone Open Mercato app only registers billing's entity ids if the
5
+ * package ships `generated/entities.ids.generated.ts` — the compiled
6
+ * `dist/.../data/entities.js` uses `export { … }`, which the CLI's class
7
+ * scanner cannot see, so without the registry the app gets
8
+ * `"billing": {}`. Two things then break:
9
+ * - the query-index subscriber rejects every billing write
10
+ * (`QueryIndexScopeError: Query index entity type is not registered`);
11
+ * - `mercato entities install` registers the ids as virtual custom
12
+ * entities and the query engine stops reading the billing tables.
13
+ *
14
+ * These tests pin the registry contents, check it is in sync with its
15
+ * sources, run the real `@open-mercato/cli` generator over the package,
16
+ * and feed the ids through the framework functions that failed, against
17
+ * billing's real MikroORM metadata (discovered without a database) — down
18
+ * to what `billing.accounts.create` hands the real data engine.
19
+ */
20
+
21
+ import { execFileSync } from 'node:child_process'
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+
25
+ import type { EntityManager } from '@mikro-orm/postgresql'
26
+ import { MikroORM } from '@mikro-orm/postgresql'
27
+ import {
28
+ QueryIndexScopeError,
29
+ resolveQueryIndexSourceMetadata,
30
+ } from '@open-mercato/core/modules/query_index/lib/subscriber-scope'
31
+ import { DefaultDataEngine, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
32
+ import { registerEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
33
+ import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
34
+ import { isEventDeclared } from '@open-mercato/shared/modules/events'
35
+
36
+ import { createAccountCommand } from '../commands/accounts'
37
+ import '../events'
38
+ import * as billingEntities from '../data/entities'
39
+ import { billingEntityIds } from '../data/entityIds'
40
+ import { E, M } from '../../../../generated/entities.ids.generated'
41
+
42
+ const PACKAGE_ROOT = path.resolve(__dirname, '../../../..')
43
+ const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..')
44
+ const GENERATED_ROOT = path.join(PACKAGE_ROOT, 'generated')
45
+
46
+ const ALL_IDS = Object.values(billingEntityIds)
47
+ const suffixOf = (id: string) => id.split(':')[1]
48
+
49
+ /** Every billing id → the table the framework must resolve it to. */
50
+ const EXPECTED_TABLES: Record<string, string> = {
51
+ [billingEntityIds.account]: 'billing_accounts',
52
+ [billingEntityIds.item]: 'billing_items',
53
+ [billingEntityIds.usage]: 'billing_account_usage',
54
+ [billingEntityIds.run]: 'billing_runs',
55
+ [billingEntityIds.runOutcome]: 'billing_run_outcomes',
56
+ [billingEntityIds.draftInvoiceEdit]: 'billing_draft_invoice_edits',
57
+ [billingEntityIds.balance]: 'billing_account_balances',
58
+ [billingEntityIds.transaction]: 'billing_account_transactions',
59
+ [billingEntityIds.topup]: 'billing_topups',
60
+ [billingEntityIds.statement]: 'billing_statements',
61
+ }
62
+
63
+ /** PascalCase of an id suffix — the class name the framework looks up. */
64
+ const classNameOf = (id: string) =>
65
+ suffixOf(id)
66
+ .split('_')
67
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
68
+ .join('')
69
+
70
+ /** A registry as a real app would have it: core ids present, billing empty (the bug). */
71
+ const REGISTRY_WITHOUT_BILLING = {
72
+ directory: { tenant: 'directory:tenant', organization: 'directory:organization' },
73
+ billing: {},
74
+ }
75
+
76
+ function readShippedColumns(key: string): string[] {
77
+ const file = path.join(GENERATED_ROOT, 'entities', key, 'index.ts')
78
+ const source = fs.readFileSync(file, 'utf8')
79
+ return Array.from(source.matchAll(/^export const \w+ = "([^"]+)"$/gm), (m) => m[1])
80
+ }
81
+
82
+ let orm: MikroORM
83
+ let em: EntityManager
84
+
85
+ beforeAll(() => {
86
+ // Every runtime export of data/entities.ts is an entity class.
87
+ const entityClasses = Object.values(billingEntities)
88
+ // The constructor discovers metadata synchronously; no connection is opened.
89
+ orm = new MikroORM({ entities: entityClasses, dbName: 'billing_entity_registry_test' })
90
+ em = orm.em.fork() as EntityManager
91
+ })
92
+
93
+ afterAll(async () => {
94
+ await orm.close(true)
95
+ })
96
+
97
+ describe('shipped registry — generated/entities.ids.generated.ts', () => {
98
+ it('declares the billing module', () => {
99
+ expect(M).toEqual({ billing: 'billing' })
100
+ })
101
+
102
+ it('lists every billingEntityIds value under its id suffix, and nothing else', () => {
103
+ // The CLI ignores the values and registers `billing:<key>`, so the keys
104
+ // are what matters.
105
+ const expected = Object.fromEntries(ALL_IDS.map((id) => [suffixOf(id), id]))
106
+ expect(E.billing).toEqual(expected)
107
+ expect(Object.keys(E.billing)).toHaveLength(10)
108
+ })
109
+
110
+ it('ships one column file per entity, matching the ORM columns', () => {
111
+ const shippedDirs = fs.readdirSync(path.join(GENERATED_ROOT, 'entities')).sort()
112
+ expect(shippedDirs).toEqual(ALL_IDS.map(suffixOf).sort())
113
+
114
+ for (const id of ALL_IDS) {
115
+ const table = EXPECTED_TABLES[id]
116
+ const meta = orm.getMetadata().getAll()
117
+ const entityMeta = Array.from(meta.values()).find((candidate) => candidate.tableName === table)
118
+ expect(entityMeta).toBeDefined()
119
+ const ormColumns = entityMeta!.props.flatMap((prop) => prop.fieldNames ?? [])
120
+ expect({ id, columns: readShippedColumns(suffixOf(id)).sort() }).toEqual({ id, columns: [...ormColumns].sort() })
121
+ }
122
+ })
123
+
124
+ it('is in sync with data/entityIds.ts and data/entities.ts', () => {
125
+ // Throws (exit 1) and names the stale files when generated/ drifted.
126
+ expect(() =>
127
+ execFileSync(process.execPath, [path.join(PACKAGE_ROOT, 'scripts', 'generate-entity-registry.mjs'), '--check'], {
128
+ cwd: PACKAGE_ROOT,
129
+ stdio: 'pipe',
130
+ timeout: 60_000,
131
+ }),
132
+ ).not.toThrow()
133
+ })
134
+
135
+ it('is what `mercato generate` registers for billing in a standalone app', () => {
136
+ // Runs the real @open-mercato/cli entity-ids generator over the package,
137
+ // resolved the way a standalone app resolves it (dist/modules when built,
138
+ // else src/modules). Without the registry this yields `billing: {}` (dist)
139
+ // or class-derived ids such as `billing:bill_run` (src).
140
+ const script = `
141
+ import fs from 'node:fs'
142
+ import os from 'node:os'
143
+ import path from 'node:path'
144
+ import { createRequire } from 'node:module'
145
+ const [repoRoot] = process.argv.slice(1)
146
+ const require = createRequire(path.join(repoRoot, 'package.json'))
147
+ const { generateEntityIds } = await import(require.resolve('@open-mercato/cli/lib/generators/entity-ids'))
148
+ const pkgRoot = path.join(repoRoot, 'node_modules', '@dainamite', 'billing')
149
+ const distModules = path.join(pkgRoot, 'dist', 'modules')
150
+ const pkgModules = fs.existsSync(distModules) ? distModules : path.join(pkgRoot, 'src', 'modules')
151
+ const out = fs.mkdtempSync(path.join(os.tmpdir(), 'billing-entity-ids-'))
152
+ try {
153
+ await generateEntityIds({
154
+ quiet: true,
155
+ resolver: {
156
+ isMonorepo: () => false,
157
+ getOutputDir: () => out,
158
+ getPackageOutputDir: () => out,
159
+ loadEnabledModules: () => [{ id: 'billing', from: '@dainamite/billing' }],
160
+ getModulePaths: (entry) => ({
161
+ appBase: path.join(repoRoot, 'src', 'modules', entry.id),
162
+ pkgBase: path.join(pkgModules, entry.id),
163
+ }),
164
+ getModuleImportBase: (entry) => ({ appBase: '@/modules/' + entry.id, pkgBase: entry.from + '/modules/' + entry.id }),
165
+ getPackageRoot: (from) => path.join(repoRoot, 'node_modules', from),
166
+ },
167
+ })
168
+ const source = fs.readFileSync(path.join(out, 'entities.ids.generated.ts'), 'utf8')
169
+ const match = source.match(/E = (\\{[\\s\\S]*?\\}) as const/)
170
+ process.stdout.write(match ? match[1] : 'null')
171
+ } finally {
172
+ fs.rmSync(out, { recursive: true, force: true })
173
+ }
174
+ `
175
+ const output = execFileSync(process.execPath, ['--input-type=module', '-e', script, REPO_ROOT], {
176
+ cwd: REPO_ROOT,
177
+ encoding: 'utf8',
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ timeout: 60_000,
180
+ })
181
+ const generated = JSON.parse(output) as { billing?: Record<string, string> }
182
+ expect(generated.billing).toEqual(E.billing)
183
+ }, 90_000)
184
+ })
185
+
186
+ describe('entity id → ORM table (resolveRegisteredEntityTableName)', () => {
187
+ it.each(ALL_IDS)('resolves %s to its table', (id) => {
188
+ expect(resolveRegisteredEntityTableName(em, id)).toBe(EXPECTED_TABLES[id])
189
+ })
190
+
191
+ it.each(ALL_IDS)('%s matches its entity class name', (id) => {
192
+ // The class-name lookup is the only path that works on Open Mercato 0.9
193
+ // (see the canary below). `billing_run` was `BillRun` until 0.20.0.
194
+ expect(classNameOf(id) in billingEntities).toBe(true)
195
+ // Look up by class-name string, as the framework does (MikroORM 7 types
196
+ // only admit class references here, but the runtime accepts names).
197
+ expect(orm.getMetadata().find(classNameOf(id) as never)?.tableName).toBe(EXPECTED_TABLES[id])
198
+ })
199
+
200
+ it('canary: the framework table-name fallback never matches on MikroORM 7', () => {
201
+ // `billing:runs` → candidate table `billing_runs`, which exists. The
202
+ // fallback iterates `MetadataStorage.getAll()` as an array, but MikroORM 7
203
+ // returns a Map, so it finds nothing. If this starts failing, upstream
204
+ // fixed it — the class-name rule in scripts/generate-entity-registry.mjs
205
+ // can then be relaxed.
206
+ expect(resolveRegisteredEntityTableName(em, 'billing:runs')).toBeNull()
207
+ })
208
+ })
209
+
210
+ describe('query index accepts billing writes', () => {
211
+ it('rejects billing ids while the app registry has `billing: {}` (the bug)', () => {
212
+ registerEntityIds(REGISTRY_WITHOUT_BILLING as never)
213
+ expect(() => resolveQueryIndexSourceMetadata(em, billingEntityIds.account)).toThrow(QueryIndexScopeError)
214
+ expect(() => resolveQueryIndexSourceMetadata(em, billingEntityIds.account)).toThrow(
215
+ 'Query index entity type is not registered: billing:billing_account',
216
+ )
217
+ })
218
+
219
+ it.each(ALL_IDS)('resolves the tenant-scoped source of %s once the shipped registry is registered', (id) => {
220
+ registerEntityIds({ ...REGISTRY_WITHOUT_BILLING, ...E } as never)
221
+ expect(resolveQueryIndexSourceMetadata(em, id)).toEqual({
222
+ table: EXPECTED_TABLES[id],
223
+ organizationColumn: 'organization_id',
224
+ tenantColumn: 'tenant_id',
225
+ })
226
+ })
227
+ })
228
+
229
+ describe('query engine reads billing ids from their tables', () => {
230
+ // The hybrid query engine checks `isOrmBackedSystemEntityId` before it
231
+ // consults `custom_entities`. Only a registered id short-circuits there, so
232
+ // an active `custom_entities` row (written by `mercato entities install`)
233
+ // can no longer route billing reads to custom-entity storage.
234
+ it('does not treat billing ids as ORM-backed while the registry has `billing: {}`', () => {
235
+ registerEntityIds(REGISTRY_WITHOUT_BILLING as never)
236
+ for (const id of ALL_IDS) {
237
+ expect({ id, ormBacked: isOrmBackedSystemEntityId(em, id) }).toEqual({ id, ormBacked: false })
238
+ }
239
+ })
240
+
241
+ it('treats every billing id as ORM-backed with the shipped registry', () => {
242
+ registerEntityIds({ ...REGISTRY_WITHOUT_BILLING, ...E } as never)
243
+ for (const id of ALL_IDS) {
244
+ expect({ id, ormBacked: isOrmBackedSystemEntityId(em, id) }).toEqual({ id, ormBacked: true })
245
+ }
246
+ })
247
+ })
248
+
249
+ describe('billing.accounts.create side effects', () => {
250
+ const TENANT = '11111111-1111-4111-8111-111111111111'
251
+ const ORG = '22222222-2222-4222-8222-222222222222'
252
+ const ACCOUNT_ID = '33333333-3333-4333-8333-333333333333'
253
+
254
+ it('emits a declared CRUD event and a query-index upsert the index accepts', async () => {
255
+ registerEntityIds({ ...REGISTRY_WITHOUT_BILLING, ...E } as never)
256
+
257
+ const emitted: Array<{ event: string; payload: Record<string, unknown> }> = []
258
+ const eventBus = {
259
+ emitEvent: jest.fn(async (event: string, payload: Record<string, unknown>) => {
260
+ emitted.push({ event, payload })
261
+ }),
262
+ }
263
+ const commandEm = {
264
+ fork: () => commandEm,
265
+ create: (_entity: unknown, data: Record<string, unknown>) => ({ id: ACCOUNT_ID, ...data }),
266
+ persist: jest.fn(),
267
+ flush: jest.fn(async () => undefined),
268
+ }
269
+ const container = {
270
+ resolve: (name: string) => {
271
+ if (name === 'eventBus') return eventBus
272
+ if (name === 'em') return commandEm
273
+ if (name === 'dataEngine') return dataEngine
274
+ throw new Error(`unexpected resolve(${name})`)
275
+ },
276
+ }
277
+ // The real data engine: it turns the command's side effects into the
278
+ // CRUD event and the `query_index.upsert_one` call.
279
+ const dataEngine = new DefaultDataEngine(em as never, container as never)
280
+
281
+ await createAccountCommand.execute(
282
+ {
283
+ tenantId: TENANT,
284
+ organizationId: ORG,
285
+ customerId: 'cust-1',
286
+ name: 'Acme',
287
+ currencyCode: 'EUR',
288
+ billCycle: 'monthly',
289
+ billCycleAnchor: '1',
290
+ invoiceEmail: 'billing@acme.test',
291
+ invoiceLanguage: 'en',
292
+ invoiceAddress: { line1: 'Main St 1', city: 'Berlin', postal_code: '10115', country: 'DE' },
293
+ nextBillDate: '2026-12-01',
294
+ } as never,
295
+ { auth: null, container } as never,
296
+ )
297
+ // The command bus flushes queued side effects after `execute`.
298
+ await dataEngine.flushOrmEntityChanges()
299
+
300
+ const crudEvent = emitted.find((entry) => entry.event.startsWith('billing.'))
301
+ expect(crudEvent?.event).toBe('billing.account.created')
302
+ expect(isEventDeclared('billing.account.created')).toBe(true)
303
+
304
+ const upsert = emitted.find((entry) => entry.event === 'query_index.upsert_one')
305
+ expect(upsert?.payload).toMatchObject({
306
+ entityType: 'billing:billing_account',
307
+ recordId: ACCOUNT_ID,
308
+ tenantId: TENANT,
309
+ organizationId: ORG,
310
+ })
311
+ // The first thing the `query_index.upsert_one` subscriber does — the
312
+ // call that threw QueryIndexScopeError and wrote `indexer_error_logs`.
313
+ expect(resolveQueryIndexSourceMetadata(em, String(upsert!.payload.entityType))).toEqual({
314
+ table: 'billing_accounts',
315
+ organizationColumn: 'organization_id',
316
+ tenantColumn: 'tenant_id',
317
+ })
318
+ })
319
+ })
@@ -0,0 +1,142 @@
1
+ /**
2
+ * CRUD event declarations.
3
+ *
4
+ * Commands emit CRUD events indirectly: `emitCrudSideEffects({ action,
5
+ * events: { module: 'billing', entity } })` makes the data engine emit
6
+ * `billing.<entity>.<action>`. An id missing from events.ts is still emitted,
7
+ * but logs "Emitting undeclared event" and stays invisible to the event
8
+ * registry (workflow triggers, subscriber discovery).
9
+ *
10
+ * The scan below reads every `emitCrudSideEffects` / `emitCrudUndoSideEffects`
11
+ * call in commands/ and requires the declared `crud` events to match exactly:
12
+ * nothing emitted is undeclared, nothing declared is never emitted.
13
+ */
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import ts from 'typescript'
18
+
19
+ import { isEventDeclared } from '@open-mercato/shared/modules/events'
20
+
21
+ import eventsConfig from '../events'
22
+
23
+ const COMMANDS_DIR = path.resolve(__dirname, '../commands')
24
+ const SIDE_EFFECT_HELPERS = new Set(['emitCrudSideEffects', 'emitCrudUndoSideEffects'])
25
+
26
+ type Emission = { eventId: string; file: string }
27
+
28
+ function unwrap(expression: ts.Expression): ts.Expression {
29
+ if (ts.isAsExpression(expression) || ts.isParenthesizedExpression(expression) || ts.isSatisfiesExpression(expression)) {
30
+ return unwrap(expression.expression)
31
+ }
32
+ return expression
33
+ }
34
+
35
+ function propertyOf(objectLiteral: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined {
36
+ for (const property of objectLiteral.properties) {
37
+ if (ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === name) {
38
+ return unwrap(property.initializer)
39
+ }
40
+ if (ts.isShorthandPropertyAssignment(property) && property.name.text === name) {
41
+ return property.name
42
+ }
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ function stringValue(expression: ts.Expression | undefined): string | undefined {
48
+ return expression && ts.isStringLiteralLike(expression) ? expression.text : undefined
49
+ }
50
+
51
+ /** Every `billing.<entity>.<action>` a command file emits through the CRUD helpers. */
52
+ function scanCommandFile(file: string): Emission[] {
53
+ const sourceFile = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS)
54
+
55
+ // Module-level `const xEvents = { module, entity, … } as const`.
56
+ const eventConfigs = new Map<string, ts.ObjectLiteralExpression>()
57
+ for (const statement of sourceFile.statements) {
58
+ if (!ts.isVariableStatement(statement)) continue
59
+ for (const declaration of statement.declarationList.declarations) {
60
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue
61
+ const initializer = unwrap(declaration.initializer)
62
+ if (ts.isObjectLiteralExpression(initializer)) eventConfigs.set(declaration.name.text, initializer)
63
+ }
64
+ }
65
+
66
+ const emissions: Emission[] = []
67
+ const relativeFile = path.relative(COMMANDS_DIR, file)
68
+ const visit = (node: ts.Node) => {
69
+ if (
70
+ ts.isCallExpression(node) &&
71
+ ts.isIdentifier(node.expression) &&
72
+ SIDE_EFFECT_HELPERS.has(node.expression.text) &&
73
+ node.arguments[0] &&
74
+ ts.isObjectLiteralExpression(unwrap(node.arguments[0]))
75
+ ) {
76
+ const options = unwrap(node.arguments[0]) as ts.ObjectLiteralExpression
77
+ const eventsExpression = propertyOf(options, 'events')
78
+ if (eventsExpression) {
79
+ const config = ts.isIdentifier(eventsExpression)
80
+ ? eventConfigs.get(eventsExpression.text)
81
+ : ts.isObjectLiteralExpression(eventsExpression)
82
+ ? eventsExpression
83
+ : undefined
84
+ const moduleId = config ? stringValue(propertyOf(config, 'module')) : undefined
85
+ const entity = config ? stringValue(propertyOf(config, 'entity')) : undefined
86
+ const action = stringValue(propertyOf(options, 'action'))
87
+ if (!moduleId || !entity || !action) {
88
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
89
+ throw new Error(
90
+ `${relativeFile}:${line + 1}: cannot statically read the CRUD event of this ${node.expression.text} call — ` +
91
+ 'use a literal `action` and a module-level `events` config so this test can check it',
92
+ )
93
+ }
94
+ emissions.push({ eventId: `${moduleId}.${entity}.${action}`, file: relativeFile })
95
+ }
96
+ }
97
+ ts.forEachChild(node, visit)
98
+ }
99
+ visit(sourceFile)
100
+ return emissions
101
+ }
102
+
103
+ const emissions = fs
104
+ .readdirSync(COMMANDS_DIR)
105
+ .filter((name) => name.endsWith('.ts'))
106
+ .flatMap((name) => scanCommandFile(path.join(COMMANDS_DIR, name)))
107
+ const emittedIds = Array.from(new Set(emissions.map((emission) => emission.eventId))).sort()
108
+ const declaredCrudIds = eventsConfig.events
109
+ .filter((event) => event.category === 'crud')
110
+ .map((event) => event.id)
111
+ .sort()
112
+
113
+ describe('billing CRUD events', () => {
114
+ it('finds the CRUD emissions in commands/', () => {
115
+ // Guards the scanner itself: if it silently found nothing, the checks
116
+ // below would pass vacuously.
117
+ expect(emittedIds).toEqual(
118
+ expect.arrayContaining(['billing.account.created', 'billing.item.updated', 'billing.topup.created']),
119
+ )
120
+ })
121
+
122
+ it.each(emittedIds)('declares %s, which a command emits', (eventId) => {
123
+ expect(declaredCrudIds).toContain(eventId)
124
+ expect(isEventDeclared(eventId)).toBe(true)
125
+ })
126
+
127
+ it('declares no CRUD event that no command emits', () => {
128
+ expect(declaredCrudIds).toEqual(emittedIds)
129
+ })
130
+
131
+ it('declares every CRUD event against the entity in its id', () => {
132
+ for (const event of eventsConfig.events.filter((candidate) => candidate.category === 'crud')) {
133
+ expect({ id: event.id, entity: event.entity }).toEqual({ id: event.id, entity: event.id.split('.')[1] })
134
+ }
135
+ })
136
+
137
+ it('keeps the per-upload usage event out of workflow triggers, like billing.usage.rated', () => {
138
+ const byId = new Map(eventsConfig.events.map((event) => [event.id, event]))
139
+ expect(byId.get('billing.usage.created')?.excludeFromTriggers).toBe(true)
140
+ expect(byId.get('billing.usage.rated')?.excludeFromTriggers).toBe(true)
141
+ })
142
+ })
@@ -1,8 +1,8 @@
1
1
  import { runBillRun, type RunBillRunParams } from '../../lib/billRunEngine'
2
2
  import { __resetInvoiceWriterCacheForTests } from '../../lib/invoiceWriter'
3
3
  import {
4
- BillRun,
5
- BillRunOutcome,
4
+ BillingRun,
5
+ BillingRunOutcome,
6
6
  BillingAccount,
7
7
  BillingAccountUsage,
8
8
  BillingItem,
@@ -269,7 +269,7 @@ describe('runBillRun — orchestration', () => {
269
269
  // Real-mode advances next_bill_date
270
270
  expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-07-01')
271
271
  expect(item.billedToDate?.toISOString().slice(0, 10)).toBe('2026-05-31')
272
- // Persisted entities: BillRun + SalesInvoice + 1 line + BillingAccountUsage advance + BillRunOutcome
272
+ // Persisted entities: BillingRun + SalesInvoice + 1 line + BillingAccountUsage advance + BillingRunOutcome
273
273
  expect(captured.persisted.length).toBeGreaterThan(0)
274
274
  })
275
275
 
@@ -10,8 +10,8 @@ import {
10
10
  BillingItem,
11
11
  BillingStatement,
12
12
  BillingTopup,
13
- BillRunOutcome,
14
- DraftInvoiceEdit,
13
+ BillingRunOutcome,
14
+ BillingDraftInvoiceEdit,
15
15
  } from '../../../../data/entities'
16
16
 
17
17
  /**
@@ -22,10 +22,10 @@ import {
22
22
  * - The account row itself.
23
23
  * - Every active + soft-deleted `BillingItem` for the account.
24
24
  * - Every `BillingAccountUsage` record.
25
- * - Every `BillRunOutcome` row referencing the account.
25
+ * - Every `BillingRunOutcome` row referencing the account.
26
26
  * - Every `core/sales` invoice the engine emitted for the account
27
27
  * (matched via `metadata->>'bill_account_id'`).
28
- * - Every `DraftInvoiceEdit` audit row for those invoices.
28
+ * - Every `BillingDraftInvoiceEdit` audit row for those invoices.
29
29
  *
30
30
  * Scope: the caller's auth tenant/organization is the authority — a
31
31
  * 404 is returned even for valid UUIDs that belong to a different
@@ -108,7 +108,7 @@ export async function GET(
108
108
  { limit: EXPORT_HARD_LIMIT, orderBy: { periodEnd: 'DESC' } },
109
109
  ),
110
110
  em.find(
111
- BillRunOutcome,
111
+ BillingRunOutcome,
112
112
  {
113
113
  tenantId,
114
114
  organizationId,
@@ -177,7 +177,7 @@ export async function GET(
177
177
  const draftEdits =
178
178
  invoiceIds.length > 0
179
179
  ? await em.find(
180
- DraftInvoiceEdit,
180
+ BillingDraftInvoiceEdit,
181
181
  {
182
182
  tenantId,
183
183
  organizationId,
@@ -272,7 +272,7 @@ export const openApi = {
272
272
  },
273
273
  ],
274
274
  description:
275
- "Returns a JSON dump of a single Billing Account: the account itself, every Billing Item (no time cap — items often outlive a single export window), and the Usage records / BillRunOutcomes / emitted core/sales invoices / DraftInvoiceEdit rows whose timestamps fall in the [since, until] window (defaults to the last 12 months). Each collection is independently capped by EXPORT_HARD_LIMIT = 50,000 rows. Lifetime dumps for outlier accounts need a follow-up chunked background-job approach — out of scope for this v1 endpoint.",
275
+ "Returns a JSON dump of a single Billing Account: the account itself, every Billing Item (no time cap — items often outlive a single export window), and the Usage records / BillRunOutcomes / emitted core/sales invoices / BillingDraftInvoiceEdit rows whose timestamps fall in the [since, until] window (defaults to the last 12 months). Each collection is independently capped by EXPORT_HARD_LIMIT = 50,000 rows. Lifetime dumps for outlier accounts need a follow-up chunked background-job approach — out of scope for this v1 endpoint.",
276
276
  },
277
277
  },
278
278
  }
@@ -67,7 +67,7 @@ export const openApi = createBillingCrudOpenApi({
67
67
  responseSchema: addLineResponseSchema,
68
68
  description:
69
69
  'Appends a new line to a draft invoice (e.g. operator adds a ' +
70
- 'one-off adjustment). Writes a `DraftInvoiceEdit` audit row ' +
70
+ 'one-off adjustment). Writes a `BillingDraftInvoiceEdit` audit row ' +
71
71
  '(action=`line_added`, `before_json=null`). Recomputes invoice ' +
72
72
  'totals. Refuses non-draft invoices.',
73
73
  },
@@ -14,7 +14,7 @@ import { createBillingCrudOpenApi } from '../../openapi'
14
14
  /**
15
15
  * `POST /api/billing/invoices/edit-line` — edits one line of a draft
16
16
  * invoice. Refuses non-drafts (409 / `billing.invoice.not_draft`).
17
- * Writes a `DraftInvoiceEdit` audit row per spec. Recomputes invoice
17
+ * Writes a `BillingDraftInvoiceEdit` audit row per spec. Recomputes invoice
18
18
  * totals from the surviving lines after the edit.
19
19
  *
20
20
  * Operator surface: route is gated by `billing.invoice.edit_draft`.
@@ -82,7 +82,7 @@ export const openApi = createBillingCrudOpenApi({
82
82
  description:
83
83
  'Edits one line of a draft invoice (description, quantity, ' +
84
84
  "unit_price, or explicit `totalNetAmount` override). Writes a " +
85
- '`DraftInvoiceEdit` audit row with before+after snapshots and ' +
85
+ '`BillingDraftInvoiceEdit` audit row with before+after snapshots and ' +
86
86
  'recomputes invoice totals. Refuses non-draft invoices with ' +
87
87
  'HTTP 409 + `billing.invoice.not_draft`.',
88
88
  },
@@ -68,7 +68,7 @@ export const openApi = createBillingCrudOpenApi({
68
68
  description:
69
69
  'Removes one line from a draft invoice (hard delete — the line ' +
70
70
  'never existed as far as future readers are concerned). Writes ' +
71
- 'a `DraftInvoiceEdit` audit row (action=`line_removed`, ' +
71
+ 'a `BillingDraftInvoiceEdit` audit row (action=`line_removed`, ' +
72
72
  '`after_json=null`) carrying the before-snapshot for forensics. ' +
73
73
  'Recomputes invoice totals. Refuses non-draft invoices.',
74
74
  },
@@ -3,7 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { AwilixContainer } from 'awilix'
4
4
  import { SalesInvoice } from '@open-mercato/core/modules/sales/data/entities'
5
5
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
6
- import { BillingAccount, BillRunOutcome } from '../../data/entities'
6
+ import { BillingAccount, BillingRunOutcome } from '../../data/entities'
7
7
  import { billingEntityIds } from '../../data/entityIds'
8
8
  import {
9
9
  BILL_RUN_OUTCOME_STATUSES,
@@ -56,7 +56,7 @@ const billRunOutcomeListItemSchema = z.object({
56
56
  const crud = makeCrudRoute({
57
57
  metadata: routeMetadata,
58
58
  orm: {
59
- entity: BillRunOutcome,
59
+ entity: BillingRunOutcome,
60
60
  idField: 'id',
61
61
  orgField: 'organizationId',
62
62
  tenantField: 'tenantId',
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
3
  import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
- import { BillRun } from '../../../data/entities'
5
+ import { BillingRun } from '../../../data/entities'
6
6
  import { billingEntityIds } from '../../../data/entityIds'
7
7
  import { billingRunRetryFailedSchema } from '../../../data/validators'
8
8
  import { createBillingCrudOpenApi } from '../../openapi'
@@ -37,7 +37,7 @@ const rawBodySchema = z.object({}).passthrough()
37
37
  const crud = makeCrudRoute({
38
38
  metadata: routeMetadata,
39
39
  orm: {
40
- entity: BillRun,
40
+ entity: BillingRun,
41
41
  idField: 'id',
42
42
  orgField: 'organizationId',
43
43
  tenantField: 'tenantId',
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
3
  import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
- import { BillRun } from '../../data/entities'
5
+ import { BillingRun } from '../../data/entities'
6
6
  import { billingEntityIds } from '../../data/entityIds'
7
7
  import {
8
8
  billingRunListQuerySchema,
@@ -64,7 +64,7 @@ const rawBodySchema = z.object({}).passthrough()
64
64
  const crud = makeCrudRoute({
65
65
  metadata: routeMetadata,
66
66
  orm: {
67
- entity: BillRun,
67
+ entity: BillingRun,
68
68
  idField: 'id',
69
69
  orgField: 'organizationId',
70
70
  tenantField: 'tenantId',
@@ -3,7 +3,7 @@ import * as React from 'react'
3
3
  import Link from 'next/link'
4
4
  import { Pencil, Plus } from 'lucide-react'
5
5
  import { useParams, useRouter } from 'next/navigation'
6
- import type { ColumnDef } from '@tanstack/react-table'
6
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
9
9
  import { FormHeader } from '@open-mercato/ui/backend/forms'
@@ -3,7 +3,7 @@ import * as React from 'react'
3
3
  import Link from 'next/link'
4
4
  import { useRouter } from 'next/navigation'
5
5
  import { Plus } from 'lucide-react'
6
- import type { ColumnDef } from '@tanstack/react-table'
6
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
9
9
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import Link from 'next/link'
4
4
  import { useParams } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { Pencil, Plus, Trash2 } from 'lucide-react'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'