@dainamite/billing 0.19.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.
- package/CHANGELOG.md +82 -0
- package/README.md +14 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +5 -5
- package/dist/modules/billing/api/export/account/[id]/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/add-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/add-line/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/edit-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/remove-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +1 -1
- package/dist/modules/billing/api/run-outcomes/route.js +2 -2
- package/dist/modules/billing/api/run-outcomes/route.js.map +2 -2
- package/dist/modules/billing/api/runs/retry-failed/route.js +2 -2
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +2 -2
- package/dist/modules/billing/api/runs/route.js +2 -2
- package/dist/modules/billing/api/runs/route.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/accounts/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/invoices/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/items/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/runs/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/statements/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/topups/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/transactions/page.js.map +1 -1
- package/dist/modules/billing/ce.js.map +2 -2
- package/dist/modules/billing/commands/invoices.js +4 -4
- package/dist/modules/billing/commands/invoices.js.map +2 -2
- package/dist/modules/billing/commands/runs.js +3 -3
- package/dist/modules/billing/commands/runs.js.map +2 -2
- package/dist/modules/billing/components/PrepaidPanel.js.map +1 -1
- package/dist/modules/billing/data/entities.js +56 -56
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/data/entityIds.js.map +2 -2
- package/dist/modules/billing/events.js +20 -0
- package/dist/modules/billing/events.js.map +2 -2
- package/dist/modules/billing/lib/billRunEngine.js +4 -4
- package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
- package/dist/modules/billing/lib/billRunLabel.js.map +1 -1
- package/dist/modules/billing/lib/prepaidPeriodClose.js.map +1 -1
- package/dist/modules/billing/lib/tenantLock.js.map +1 -1
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +2 -2
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +2 -2
- package/generated/entities/billing_account/index.ts +23 -0
- package/generated/entities/billing_account_balance/index.ts +13 -0
- package/generated/entities/billing_account_transaction/index.ts +21 -0
- package/generated/entities/billing_account_usage/index.ts +18 -0
- package/generated/entities/billing_draft_invoice_edit/index.ts +14 -0
- package/generated/entities/billing_item/index.ts +22 -0
- package/generated/entities/billing_run/index.ts +21 -0
- package/generated/entities/billing_run_outcome/index.ts +17 -0
- package/generated/entities/billing_statement/index.ts +21 -0
- package/generated/entities/billing_topup/index.ts +20 -0
- package/generated/entities.ids.generated.ts +28 -0
- package/package.json +11 -10
- package/src/modules/billing/__tests__/commands/invoices.test.ts +5 -5
- package/src/modules/billing/__tests__/entityRegistry.test.ts +319 -0
- package/src/modules/billing/__tests__/events.test.ts +142 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +3 -3
- package/src/modules/billing/api/export/account/[id]/route.ts +7 -7
- package/src/modules/billing/api/invoices/add-line/route.ts +1 -1
- package/src/modules/billing/api/invoices/edit-line/route.ts +2 -2
- package/src/modules/billing/api/invoices/remove-line/route.ts +1 -1
- package/src/modules/billing/api/run-outcomes/route.ts +2 -2
- package/src/modules/billing/api/runs/retry-failed/route.ts +2 -2
- package/src/modules/billing/api/runs/route.ts +2 -2
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/accounts/page.tsx +1 -1
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/invoices/page.tsx +1 -1
- package/src/modules/billing/backend/billing/items/page.tsx +1 -1
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/runs/page.tsx +1 -1
- package/src/modules/billing/backend/billing/statements/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/statements/page.tsx +1 -1
- package/src/modules/billing/backend/billing/topups/page.tsx +1 -1
- package/src/modules/billing/backend/billing/transactions/page.tsx +1 -1
- package/src/modules/billing/ce.ts +8 -7
- package/src/modules/billing/commands/invoices.ts +9 -9
- package/src/modules/billing/commands/runs.ts +3 -3
- package/src/modules/billing/components/PrepaidPanel.tsx +1 -1
- package/src/modules/billing/data/entities.ts +31 -10
- package/src/modules/billing/data/entityIds.ts +16 -11
- package/src/modules/billing/events.ts +26 -0
- package/src/modules/billing/lib/billRunEngine.ts +12 -12
- package/src/modules/billing/lib/billRunLabel.ts +1 -1
- package/src/modules/billing/lib/prepaidPeriodClose.ts +3 -3
- package/src/modules/billing/lib/tenantLock.ts +1 -1
- 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
|
-
|
|
5
|
-
|
|
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:
|
|
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
|
-
|
|
14
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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 `
|
|
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 `
|
|
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
|
-
'`
|
|
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 `
|
|
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,
|
|
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:
|
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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'
|