@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.
- package/CHANGELOG.md +658 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/modules/billing/acl.js +20 -0
- package/dist/modules/billing/acl.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +182 -0
- package/dist/modules/billing/api/accounts/route.js.map +7 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
- package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
- package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/post/route.js +74 -0
- package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/route.js +109 -0
- package/dist/modules/billing/api/invoices/route.js.map +7 -0
- package/dist/modules/billing/api/items/bulk/route.js +74 -0
- package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
- package/dist/modules/billing/api/items/route.js +224 -0
- package/dist/modules/billing/api/items/route.js.map +7 -0
- package/dist/modules/billing/api/openapi.js +25 -0
- package/dist/modules/billing/api/openapi.js.map +7 -0
- package/dist/modules/billing/api/run-outcomes/route.js +122 -0
- package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
- package/dist/modules/billing/api/runs/route.js +143 -0
- package/dist/modules/billing/api/runs/route.js.map +7 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +138 -0
- package/dist/modules/billing/api/usage/route.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
- package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
- package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
- package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.js +208 -0
- package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.js +341 -0
- package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
- package/dist/modules/billing/ce.js +56 -0
- package/dist/modules/billing/ce.js.map +7 -0
- package/dist/modules/billing/cli.js +67 -0
- package/dist/modules/billing/cli.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +157 -0
- package/dist/modules/billing/commands/accounts.js.map +7 -0
- package/dist/modules/billing/commands/index.js +6 -0
- package/dist/modules/billing/commands/index.js.map +7 -0
- package/dist/modules/billing/commands/invoices.js +401 -0
- package/dist/modules/billing/commands/invoices.js.map +7 -0
- package/dist/modules/billing/commands/items.js +317 -0
- package/dist/modules/billing/commands/items.js.map +7 -0
- package/dist/modules/billing/commands/runs.js +123 -0
- package/dist/modules/billing/commands/runs.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +88 -0
- package/dist/modules/billing/commands/usage.js.map +7 -0
- package/dist/modules/billing/components/AccountForm.js +420 -0
- package/dist/modules/billing/components/AccountForm.js.map +7 -0
- package/dist/modules/billing/components/DetailFields.js +37 -0
- package/dist/modules/billing/components/DetailFields.js.map +7 -0
- package/dist/modules/billing/components/ItemForm.js +440 -0
- package/dist/modules/billing/components/ItemForm.js.map +7 -0
- package/dist/modules/billing/components/LineFormDialog.js +195 -0
- package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
- package/dist/modules/billing/components/UsageForm.js +262 -0
- package/dist/modules/billing/components/UsageForm.js.map +7 -0
- package/dist/modules/billing/data/entities.js +433 -0
- package/dist/modules/billing/data/entities.js.map +7 -0
- package/dist/modules/billing/data/entityIds.js +12 -0
- package/dist/modules/billing/data/entityIds.js.map +7 -0
- package/dist/modules/billing/data/validators.js +396 -0
- package/dist/modules/billing/data/validators.js.map +7 -0
- package/dist/modules/billing/di.js +6 -0
- package/dist/modules/billing/di.js.map +7 -0
- package/dist/modules/billing/events.js +35 -0
- package/dist/modules/billing/events.js.map +7 -0
- package/dist/modules/billing/i18n/en.json +336 -0
- package/dist/modules/billing/i18n/pl.json +336 -0
- package/dist/modules/billing/index.js +17 -0
- package/dist/modules/billing/index.js.map +7 -0
- package/dist/modules/billing/lib/billPeriod.js +101 -0
- package/dist/modules/billing/lib/billPeriod.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +256 -0
- package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
- package/dist/modules/billing/lib/billRunLabel.js +8 -0
- package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
- package/dist/modules/billing/lib/idempotency.js +13 -0
- package/dist/modules/billing/lib/idempotency.js.map +7 -0
- package/dist/modules/billing/lib/invoiceStatus.js +38 -0
- package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
- package/dist/modules/billing/lib/invoiceWriter.js +153 -0
- package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
- package/dist/modules/billing/lib/itemSelector.js +77 -0
- package/dist/modules/billing/lib/itemSelector.js.map +7 -0
- package/dist/modules/billing/lib/seeds/demo.js +130 -0
- package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
- package/dist/modules/billing/lib/tenantLock.js +32 -0
- package/dist/modules/billing/lib/tenantLock.js.map +7 -0
- package/dist/modules/billing/lib/usageRater.js +150 -0
- package/dist/modules/billing/lib/usageRater.js.map +7 -0
- package/dist/modules/billing/lib/usageRunner.js +82 -0
- package/dist/modules/billing/lib/usageRunner.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
- package/dist/modules/billing/setup.js +190 -0
- package/dist/modules/billing/setup.js.map +7 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
- package/dist/modules/billing/translations.js +5 -0
- package/dist/modules/billing/translations.js.map +7 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
- package/package.json +148 -0
- package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
- package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
- package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
- package/src/modules/billing/__integration__/meta.ts +4 -0
- package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
- package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
- package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
- package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
- package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
- package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
- package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
- package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
- package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
- package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
- package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
- package/src/modules/billing/__tests__/setup.test.ts +107 -0
- package/src/modules/billing/__tests__/validators.test.ts +594 -0
- package/src/modules/billing/acl.ts +16 -0
- package/src/modules/billing/api/accounts/route.ts +198 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
- package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
- package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
- package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
- package/src/modules/billing/api/invoices/post/route.ts +97 -0
- package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
- package/src/modules/billing/api/invoices/route.ts +167 -0
- package/src/modules/billing/api/items/bulk/route.ts +92 -0
- package/src/modules/billing/api/items/route.ts +255 -0
- package/src/modules/billing/api/openapi.ts +33 -0
- package/src/modules/billing/api/run-outcomes/route.ts +149 -0
- package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
- package/src/modules/billing/api/runs/route.ts +170 -0
- package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
- package/src/modules/billing/api/usage/route.ts +148 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
- package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
- package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
- package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
- package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
- package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
- package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/items/page.tsx +272 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
- package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
- package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
- package/src/modules/billing/ce.ts +74 -0
- package/src/modules/billing/cli.ts +89 -0
- package/src/modules/billing/commands/accounts.ts +205 -0
- package/src/modules/billing/commands/index.ts +13 -0
- package/src/modules/billing/commands/invoices.ts +542 -0
- package/src/modules/billing/commands/items.ts +438 -0
- package/src/modules/billing/commands/runs.ts +166 -0
- package/src/modules/billing/commands/usage.ts +118 -0
- package/src/modules/billing/components/AccountForm.tsx +517 -0
- package/src/modules/billing/components/DetailFields.tsx +60 -0
- package/src/modules/billing/components/ItemForm.tsx +558 -0
- package/src/modules/billing/components/LineFormDialog.tsx +253 -0
- package/src/modules/billing/components/UsageForm.tsx +326 -0
- package/src/modules/billing/data/entities.ts +533 -0
- package/src/modules/billing/data/entityIds.ts +25 -0
- package/src/modules/billing/data/validators.ts +562 -0
- package/src/modules/billing/di.ts +14 -0
- package/src/modules/billing/events.ts +47 -0
- package/src/modules/billing/i18n/en.json +336 -0
- package/src/modules/billing/i18n/pl.json +336 -0
- package/src/modules/billing/index.ts +22 -0
- package/src/modules/billing/lib/billPeriod.ts +181 -0
- package/src/modules/billing/lib/billRunEngine.ts +416 -0
- package/src/modules/billing/lib/billRunLabel.ts +14 -0
- package/src/modules/billing/lib/idempotency.ts +46 -0
- package/src/modules/billing/lib/invoiceStatus.ts +61 -0
- package/src/modules/billing/lib/invoiceWriter.ts +260 -0
- package/src/modules/billing/lib/itemSelector.ts +154 -0
- package/src/modules/billing/lib/seeds/demo.ts +199 -0
- package/src/modules/billing/lib/tenantLock.ts +87 -0
- package/src/modules/billing/lib/usageRater.ts +250 -0
- package/src/modules/billing/lib/usageRunner.ts +189 -0
- package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
- package/src/modules/billing/setup.ts +252 -0
- package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
- package/src/modules/billing/translations.ts +9 -0
- package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { runBillRun, type RunBillRunParams } from '../../lib/billRunEngine'
|
|
2
|
+
import { __resetInvoiceWriterCacheForTests } from '../../lib/invoiceWriter'
|
|
3
|
+
import {
|
|
4
|
+
BillRun,
|
|
5
|
+
BillRunOutcome,
|
|
6
|
+
BillingAccount,
|
|
7
|
+
BillingAccountUsage,
|
|
8
|
+
BillingItem,
|
|
9
|
+
} from '../../data/entities'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* End-to-end engine orchestration tests using a thin EM mock.
|
|
13
|
+
*
|
|
14
|
+
* These exercise the *control flow* — lock acquisition, account
|
|
15
|
+
* iteration, savepoint isolation per account, dry/test/real branching,
|
|
16
|
+
* final summary aggregation. The per-account work (period derivation,
|
|
17
|
+
* item selection, invoice writing) is covered by the focused tests in
|
|
18
|
+
* `billPeriod` / `itemSelector` / `invoiceWriter` and is intentionally
|
|
19
|
+
* collapsed in the mock here (`em.execute` no-ops the open-draft check,
|
|
20
|
+
* `em.find(BillingItem, ...)` returns whatever the test stages).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Captured = {
|
|
24
|
+
persisted: unknown[]
|
|
25
|
+
flushes: number
|
|
26
|
+
findCalls: Array<{ entity: unknown; where: unknown }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createEnvironment(
|
|
30
|
+
options: {
|
|
31
|
+
accounts: BillingAccount[]
|
|
32
|
+
itemsByAccount: Map<string, BillingItem[]>
|
|
33
|
+
usageByAccount?: Map<string, BillingAccountUsage[]>
|
|
34
|
+
openDraftIds?: Map<string, string> // accountId → existing-draft id
|
|
35
|
+
lockAcquired?: boolean
|
|
36
|
+
accountFails?: Set<string>
|
|
37
|
+
},
|
|
38
|
+
): {
|
|
39
|
+
em: unknown
|
|
40
|
+
container: unknown
|
|
41
|
+
captured: Captured
|
|
42
|
+
numberGenerator: { generate: jest.MockedFunction<(...args: unknown[]) => unknown> }
|
|
43
|
+
usageMarkedAs: jest.MockedFunction<(entity: unknown, where: unknown, data: unknown) => Promise<number>>
|
|
44
|
+
} {
|
|
45
|
+
const captured: Captured = { persisted: [], flushes: 0, findCalls: [] }
|
|
46
|
+
const numberGenerator = {
|
|
47
|
+
generate: jest.fn(async () => ({
|
|
48
|
+
number: 'INV-2026-0000001',
|
|
49
|
+
format: 'INV-{YYYY}-{0000001}',
|
|
50
|
+
sequence: 1,
|
|
51
|
+
})),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type EmShape = {
|
|
55
|
+
transactional: jest.MockedFunction<(fn: (tem: EmShape) => Promise<unknown>) => Promise<unknown>>
|
|
56
|
+
execute: jest.MockedFunction<(sql: string, params?: unknown[]) => Promise<unknown>>
|
|
57
|
+
create: jest.MockedFunction<(ctor: unknown, data: Record<string, unknown>) => unknown>
|
|
58
|
+
persist: jest.MockedFunction<(entity: unknown) => void>
|
|
59
|
+
flush: jest.MockedFunction<() => Promise<void>>
|
|
60
|
+
find: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown[]>>
|
|
61
|
+
findOne: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown>>
|
|
62
|
+
nativeUpdate: jest.MockedFunction<(entity: unknown, where: unknown, data: unknown) => Promise<number>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const em: EmShape = {
|
|
66
|
+
transactional: jest.fn(async function (fn: (tem: EmShape) => Promise<unknown>) {
|
|
67
|
+
const failedAccountId = (this as { __failingAccount?: string })?.__failingAccount
|
|
68
|
+
if (failedAccountId) {
|
|
69
|
+
throw new Error(`mock per-account failure: ${failedAccountId}`)
|
|
70
|
+
}
|
|
71
|
+
return fn(em)
|
|
72
|
+
}),
|
|
73
|
+
execute: jest.fn(async (sql: string, params?: unknown[]) => {
|
|
74
|
+
// Usage aggregate: SELECT uom_code, SUM(quantity), COUNT(*) FROM
|
|
75
|
+
// billing_account_usage WHERE ... GROUP BY uom_code. Buckets the
|
|
76
|
+
// test fixture records by uom for the runner.
|
|
77
|
+
if (sql.includes('billing_account_usage') && sql.includes('GROUP BY uom_code')) {
|
|
78
|
+
const accountId = (params?.[2] ?? '') as string
|
|
79
|
+
const recs = (options.usageByAccount?.get(accountId) ?? []).filter(
|
|
80
|
+
(r) => r.ratedInBillRunId === null,
|
|
81
|
+
)
|
|
82
|
+
const grouped = new Map<string, { sum: number; count: number }>()
|
|
83
|
+
for (const r of recs) {
|
|
84
|
+
const prev = grouped.get(r.uomCode) ?? { sum: 0, count: 0 }
|
|
85
|
+
prev.sum += Number.parseFloat(r.quantity)
|
|
86
|
+
prev.count += 1
|
|
87
|
+
grouped.set(r.uomCode, prev)
|
|
88
|
+
}
|
|
89
|
+
return Array.from(grouped.entries()).map(([uom, agg]) => ({
|
|
90
|
+
uom_code: uom,
|
|
91
|
+
total_quantity: String(agg.sum),
|
|
92
|
+
record_count: String(agg.count),
|
|
93
|
+
})) as unknown
|
|
94
|
+
}
|
|
95
|
+
// Open-draft check: SELECT id FROM sales_invoices ... bill_account_id = ?
|
|
96
|
+
if (sql.includes('sales_invoices') && params && Array.isArray(params) && params.length >= 3) {
|
|
97
|
+
const accountId = params[2] as string
|
|
98
|
+
const existing = options.openDraftIds?.get(accountId)
|
|
99
|
+
if (existing) return [{ id: existing }] as unknown
|
|
100
|
+
}
|
|
101
|
+
// Advisory-lock acquire query for the outer transaction
|
|
102
|
+
return [{ acquired: options.lockAcquired ?? true }] as unknown
|
|
103
|
+
}),
|
|
104
|
+
create: jest.fn((_ctor: unknown, data: Record<string, unknown>) => ({
|
|
105
|
+
...data,
|
|
106
|
+
id: (data.id as string | undefined) ?? `gen-${captured.persisted.length + 1}`,
|
|
107
|
+
})),
|
|
108
|
+
persist: jest.fn((entity: unknown) => {
|
|
109
|
+
captured.persisted.push(entity)
|
|
110
|
+
}),
|
|
111
|
+
flush: jest.fn(async () => {
|
|
112
|
+
captured.flushes += 1
|
|
113
|
+
}),
|
|
114
|
+
find: jest.fn(async (entity: unknown, where: unknown) => {
|
|
115
|
+
captured.findCalls.push({ entity, where })
|
|
116
|
+
if (entity === BillingAccount) return options.accounts as unknown[]
|
|
117
|
+
if (entity === BillingItem) {
|
|
118
|
+
const accountId = (where as { billAccountId?: string }).billAccountId
|
|
119
|
+
if (!accountId) return []
|
|
120
|
+
return (options.itemsByAccount.get(accountId) ?? []) as unknown[]
|
|
121
|
+
}
|
|
122
|
+
if (entity === BillingAccountUsage) {
|
|
123
|
+
const accountId = (where as { billAccountId?: string }).billAccountId
|
|
124
|
+
if (!accountId) return []
|
|
125
|
+
const records = options.usageByAccount?.get(accountId) ?? []
|
|
126
|
+
// Apply mock filtering for rated_in_bill_run_id IS NULL
|
|
127
|
+
return records.filter((r) => r.ratedInBillRunId === null) as unknown[]
|
|
128
|
+
}
|
|
129
|
+
return []
|
|
130
|
+
}),
|
|
131
|
+
findOne: jest.fn(async (_entity: unknown, _where: unknown) => null),
|
|
132
|
+
nativeUpdate: jest.fn(async (entity: unknown, where: unknown, data: unknown) => {
|
|
133
|
+
if (entity === BillingAccountUsage) {
|
|
134
|
+
// Predicate-based bulk update (post-Phase-4f refactor): the
|
|
135
|
+
// engine now passes `{ billAccountId, uomCode: { $in: [...] },
|
|
136
|
+
// ratedInBillRunId: null, periodEnd: { $lte: <date> }, ... }`
|
|
137
|
+
// and we never see an explicit id list.
|
|
138
|
+
const w = where as {
|
|
139
|
+
billAccountId?: string
|
|
140
|
+
uomCode?: { $in?: string[] }
|
|
141
|
+
ratedInBillRunId?: null
|
|
142
|
+
}
|
|
143
|
+
const accountId = w.billAccountId
|
|
144
|
+
const uoms = new Set(w.uomCode?.$in ?? [])
|
|
145
|
+
if (!accountId || uoms.size === 0) return 0
|
|
146
|
+
const records = options.usageByAccount?.get(accountId) ?? []
|
|
147
|
+
let touched = 0
|
|
148
|
+
for (const record of records) {
|
|
149
|
+
if (record.ratedInBillRunId !== null) continue
|
|
150
|
+
if (!uoms.has(record.uomCode)) continue
|
|
151
|
+
record.ratedInBillRunId =
|
|
152
|
+
(data as { ratedInBillRunId?: string }).ratedInBillRunId ?? null
|
|
153
|
+
touched += 1
|
|
154
|
+
}
|
|
155
|
+
return touched
|
|
156
|
+
}
|
|
157
|
+
return 0
|
|
158
|
+
}),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// For failure injection — wire the per-account hook
|
|
162
|
+
if (options.accountFails && options.accountFails.size > 0) {
|
|
163
|
+
let accountIdx = 0
|
|
164
|
+
em.transactional = jest.fn(async function (fn: (tem: EmShape) => Promise<unknown>) {
|
|
165
|
+
// The FIRST transactional call is the outer (lock) wrap — pass
|
|
166
|
+
// through. Subsequent calls are per-account savepoints — fail
|
|
167
|
+
// the ones in `accountFails`.
|
|
168
|
+
if (accountIdx === 0) {
|
|
169
|
+
accountIdx += 1
|
|
170
|
+
return fn(em)
|
|
171
|
+
}
|
|
172
|
+
const accountId = options.accounts[accountIdx - 1]?.id
|
|
173
|
+
accountIdx += 1
|
|
174
|
+
if (accountId && options.accountFails!.has(accountId)) {
|
|
175
|
+
throw new Error(`Synthetic per-account failure: ${accountId}`)
|
|
176
|
+
}
|
|
177
|
+
return fn(em)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const container = { resolve: jest.fn((token: string) => {
|
|
182
|
+
if (token === 'salesDocumentNumberGenerator') return numberGenerator
|
|
183
|
+
return null
|
|
184
|
+
}) }
|
|
185
|
+
|
|
186
|
+
return { em, container, captured, numberGenerator, usageMarkedAs: em.nativeUpdate }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function makeAccount(overrides: Partial<BillingAccount>): BillingAccount {
|
|
190
|
+
return {
|
|
191
|
+
id: overrides.id ?? 'acc-1',
|
|
192
|
+
organizationId: overrides.organizationId ?? 'org-1',
|
|
193
|
+
tenantId: overrides.tenantId ?? 'tenant-1',
|
|
194
|
+
customerId: overrides.customerId ?? 'cust-1',
|
|
195
|
+
name: overrides.name ?? 'Acme',
|
|
196
|
+
currencyCode: overrides.currencyCode ?? 'EUR',
|
|
197
|
+
billCycle: overrides.billCycle ?? 'monthly',
|
|
198
|
+
billCycleAnchor: overrides.billCycleAnchor ?? '1',
|
|
199
|
+
invoiceEmail: overrides.invoiceEmail ?? 'ar@acme.test',
|
|
200
|
+
invoiceLanguage: overrides.invoiceLanguage ?? 'en',
|
|
201
|
+
taxId: overrides.taxId ?? null,
|
|
202
|
+
invoiceAddress: overrides.invoiceAddress ?? {
|
|
203
|
+
line1: 'X',
|
|
204
|
+
city: 'Y',
|
|
205
|
+
postal_code: 'Z',
|
|
206
|
+
country: 'PL',
|
|
207
|
+
},
|
|
208
|
+
nextBillDate: overrides.nextBillDate ?? new Date('2026-06-01T00:00:00Z'),
|
|
209
|
+
lastBillDate: overrides.lastBillDate ?? null,
|
|
210
|
+
isActive: overrides.isActive ?? true,
|
|
211
|
+
createdAt: overrides.createdAt ?? new Date(),
|
|
212
|
+
updatedAt: overrides.updatedAt ?? new Date(),
|
|
213
|
+
deletedAt: overrides.deletedAt ?? null,
|
|
214
|
+
} as BillingAccount
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function makeRecurringItem(billAccountId: string, id = 'item-mrc'): BillingItem {
|
|
218
|
+
return {
|
|
219
|
+
id,
|
|
220
|
+
organizationId: 'org-1',
|
|
221
|
+
tenantId: 'tenant-1',
|
|
222
|
+
billAccountId,
|
|
223
|
+
type: 'recurring',
|
|
224
|
+
billStartDate: new Date('2026-01-01T00:00:00Z'),
|
|
225
|
+
billEndDate: null,
|
|
226
|
+
description: 'MRC',
|
|
227
|
+
rateJson: { unit_price: 49.99 },
|
|
228
|
+
uomCode: null,
|
|
229
|
+
subscriptionId: null,
|
|
230
|
+
subscriptionItemId: null,
|
|
231
|
+
sourceRef: null,
|
|
232
|
+
currencyMismatch: false,
|
|
233
|
+
billedToDate: null,
|
|
234
|
+
isActive: true,
|
|
235
|
+
createdAt: new Date(),
|
|
236
|
+
updatedAt: new Date(),
|
|
237
|
+
deletedAt: null,
|
|
238
|
+
} as BillingItem
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
__resetInvoiceWriterCacheForTests()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const params = (overrides: Partial<RunBillRunParams>): RunBillRunParams => ({
|
|
246
|
+
tenantId: 'tenant-1',
|
|
247
|
+
organizationId: 'org-1',
|
|
248
|
+
asOfDate: new Date('2026-06-01T00:00:00Z'),
|
|
249
|
+
triggeredBy: 'manual',
|
|
250
|
+
mode: 'real',
|
|
251
|
+
...overrides,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('runBillRun — orchestration', () => {
|
|
255
|
+
it('happy path: one account, one recurring item → one draft + success outcome', async () => {
|
|
256
|
+
const account = makeAccount({})
|
|
257
|
+
const item = makeRecurringItem(account.id)
|
|
258
|
+
const { em, container, captured } = createEnvironment({
|
|
259
|
+
accounts: [account],
|
|
260
|
+
itemsByAccount: new Map([[account.id, [item]]]),
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
264
|
+
|
|
265
|
+
expect(billRun.status).toBe('completed')
|
|
266
|
+
expect(outcomes).toHaveLength(1)
|
|
267
|
+
expect(outcomes[0].status).toBe('success')
|
|
268
|
+
expect(outcomes[0].draftInvoiceId).toMatch(/^gen-/)
|
|
269
|
+
// Real-mode advances next_bill_date
|
|
270
|
+
expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-07-01')
|
|
271
|
+
expect(item.billedToDate?.toISOString().slice(0, 10)).toBe('2026-05-31')
|
|
272
|
+
// Persisted entities: BillRun + SalesInvoice + 1 line + BillingAccountUsage advance + BillRunOutcome
|
|
273
|
+
expect(captured.persisted.length).toBeGreaterThan(0)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('zero accounts → completed run with summary={accounts_processed: 0}', async () => {
|
|
277
|
+
const { em, container } = createEnvironment({
|
|
278
|
+
accounts: [],
|
|
279
|
+
itemsByAccount: new Map(),
|
|
280
|
+
})
|
|
281
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
282
|
+
expect(billRun.status).toBe('completed')
|
|
283
|
+
expect(billRun.summary).toMatchObject({ accounts_processed: 0 })
|
|
284
|
+
expect(outcomes).toHaveLength(0)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('open-draft check: account with existing draft is skipped, no advance', async () => {
|
|
288
|
+
const account = makeAccount({})
|
|
289
|
+
const item = makeRecurringItem(account.id)
|
|
290
|
+
const { em, container } = createEnvironment({
|
|
291
|
+
accounts: [account],
|
|
292
|
+
itemsByAccount: new Map([[account.id, [item]]]),
|
|
293
|
+
openDraftIds: new Map([[account.id, 'existing-draft-1']]),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
297
|
+
|
|
298
|
+
expect(outcomes[0].status).toBe('skipped_existing_draft')
|
|
299
|
+
expect(outcomes[0].draftInvoiceId).toBe('existing-draft-1')
|
|
300
|
+
// next_bill_date does NOT advance when we skip
|
|
301
|
+
expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
|
|
302
|
+
// Item not marked billed
|
|
303
|
+
expect(item.billedToDate).toBeNull()
|
|
304
|
+
expect(billRun.summary).toMatchObject({ drafts_skipped_existing: 1 })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('dry-run mode: persists nothing, advances nothing, computes outcomes', async () => {
|
|
308
|
+
const account = makeAccount({})
|
|
309
|
+
const item = makeRecurringItem(account.id)
|
|
310
|
+
const { em, container } = createEnvironment({
|
|
311
|
+
accounts: [account],
|
|
312
|
+
itemsByAccount: new Map([[account.id, [item]]]),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({ mode: 'dry' }))
|
|
316
|
+
|
|
317
|
+
expect(billRun.dryRun).toBe(true)
|
|
318
|
+
expect(outcomes[0].status).toBe('success')
|
|
319
|
+
// Dry-run: no draft invoice id stored on the outcome
|
|
320
|
+
expect(outcomes[0].draftInvoiceId).toBeNull()
|
|
321
|
+
// No advancement
|
|
322
|
+
expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
|
|
323
|
+
expect(item.billedToDate).toBeNull()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('test mode: persists draft with test_run flag, does NOT advance state', async () => {
|
|
327
|
+
const account = makeAccount({})
|
|
328
|
+
const item = makeRecurringItem(account.id)
|
|
329
|
+
const { em, container } = createEnvironment({
|
|
330
|
+
accounts: [account],
|
|
331
|
+
itemsByAccount: new Map([[account.id, [item]]]),
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({ mode: 'test' }))
|
|
335
|
+
|
|
336
|
+
expect(billRun.testMode).toBe(true)
|
|
337
|
+
expect(outcomes[0].status).toBe('success')
|
|
338
|
+
expect(outcomes[0].draftInvoiceId).toMatch(/^gen-/)
|
|
339
|
+
// No state advance
|
|
340
|
+
expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-06-01')
|
|
341
|
+
expect(item.billedToDate).toBeNull()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('currency-mismatch item flags the outcome with success_with_warnings', async () => {
|
|
345
|
+
const account = makeAccount({})
|
|
346
|
+
const item = makeRecurringItem(account.id)
|
|
347
|
+
item.currencyMismatch = true
|
|
348
|
+
const { em, container } = createEnvironment({
|
|
349
|
+
accounts: [account],
|
|
350
|
+
itemsByAccount: new Map([[account.id, [item]]]),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const { outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
354
|
+
expect(outcomes[0].status).toBe('success_with_warnings')
|
|
355
|
+
expect((outcomes[0].warnings as { currency_mismatch_items?: string[] } | null)
|
|
356
|
+
?.currency_mismatch_items).toEqual([item.id])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('account with no eligible items still advances next_bill_date in real mode', async () => {
|
|
360
|
+
const account = makeAccount({})
|
|
361
|
+
const { em, container } = createEnvironment({
|
|
362
|
+
accounts: [account],
|
|
363
|
+
itemsByAccount: new Map([[account.id, []]]),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const { outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
367
|
+
expect(outcomes[0].status).toBe('success')
|
|
368
|
+
expect(account.nextBillDate.toISOString().slice(0, 10)).toBe('2026-07-01')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// ─── Phase 3 — usage flow ────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function makeUsageItem(
|
|
374
|
+
billAccountId: string,
|
|
375
|
+
uomCode: string,
|
|
376
|
+
rate: Record<string, unknown>,
|
|
377
|
+
): BillingItem {
|
|
378
|
+
return {
|
|
379
|
+
id: `usage-item-${uomCode}`,
|
|
380
|
+
organizationId: 'org-1',
|
|
381
|
+
tenantId: 'tenant-1',
|
|
382
|
+
billAccountId,
|
|
383
|
+
type: 'usage',
|
|
384
|
+
billStartDate: new Date('2026-01-01T00:00:00Z'),
|
|
385
|
+
billEndDate: null,
|
|
386
|
+
description: `Usage — ${uomCode}`,
|
|
387
|
+
rateJson: rate,
|
|
388
|
+
uomCode,
|
|
389
|
+
subscriptionId: null,
|
|
390
|
+
subscriptionItemId: null,
|
|
391
|
+
sourceRef: null,
|
|
392
|
+
currencyMismatch: false,
|
|
393
|
+
billedToDate: null,
|
|
394
|
+
isActive: true,
|
|
395
|
+
createdAt: new Date(),
|
|
396
|
+
updatedAt: new Date(),
|
|
397
|
+
deletedAt: null,
|
|
398
|
+
} as BillingItem
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function makeUsageRecord(
|
|
402
|
+
id: string,
|
|
403
|
+
billAccountId: string,
|
|
404
|
+
uomCode: string,
|
|
405
|
+
quantity: number,
|
|
406
|
+
): BillingAccountUsage {
|
|
407
|
+
return {
|
|
408
|
+
id,
|
|
409
|
+
organizationId: 'org-1',
|
|
410
|
+
tenantId: 'tenant-1',
|
|
411
|
+
billAccountId,
|
|
412
|
+
uomCode,
|
|
413
|
+
quantity: quantity.toFixed(4),
|
|
414
|
+
periodStart: new Date('2026-05-01T00:00:00Z'),
|
|
415
|
+
periodEnd: new Date('2026-05-31T23:59:59Z'),
|
|
416
|
+
lineDescription: null,
|
|
417
|
+
sourceRef: null,
|
|
418
|
+
ratedInBillRunId: null,
|
|
419
|
+
isActive: true,
|
|
420
|
+
createdAt: new Date(),
|
|
421
|
+
updatedAt: new Date(),
|
|
422
|
+
deletedAt: null,
|
|
423
|
+
} as BillingAccountUsage
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
it('aggregates usage records: graduated 25k → 15 EUR + marks rated_in_bill_run_id', async () => {
|
|
427
|
+
const account = makeAccount({})
|
|
428
|
+
const usageItem = makeUsageItem(account.id, 'api_request', {
|
|
429
|
+
model: 'graduated',
|
|
430
|
+
tiers: [
|
|
431
|
+
{ up_to: 10000, unit_price: 0 },
|
|
432
|
+
{ up_to: null, unit_price: 0.001 },
|
|
433
|
+
],
|
|
434
|
+
})
|
|
435
|
+
const u1 = makeUsageRecord('u1', account.id, 'api_request', 10000)
|
|
436
|
+
const u2 = makeUsageRecord('u2', account.id, 'api_request', 15000)
|
|
437
|
+
const { em, container, captured } = createEnvironment({
|
|
438
|
+
accounts: [account],
|
|
439
|
+
itemsByAccount: new Map([[account.id, [usageItem]]]),
|
|
440
|
+
usageByAccount: new Map([[account.id, [u1, u2]]]),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const { billRun, outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
444
|
+
|
|
445
|
+
expect(outcomes[0].status).toBe('success')
|
|
446
|
+
// Persisted SalesInvoiceLine for the usage item with the right total
|
|
447
|
+
const usageLine = captured.persisted.find((p) => {
|
|
448
|
+
const m = (p as { metadata?: { billing_type?: string } }).metadata
|
|
449
|
+
return m?.billing_type === 'usage'
|
|
450
|
+
})
|
|
451
|
+
expect(usageLine).toBeDefined()
|
|
452
|
+
expect((usageLine as { totalNetAmount?: string }).totalNetAmount).toBe('15.0000')
|
|
453
|
+
|
|
454
|
+
// Real-mode marks records as rated
|
|
455
|
+
expect(u1.ratedInBillRunId).toBe(billRun.id)
|
|
456
|
+
expect(u2.ratedInBillRunId).toBe(billRun.id)
|
|
457
|
+
expect(billRun.summary).toMatchObject({ usage_records_rated: 2 })
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('unmatched usage uom → success_with_warnings, records left un-rated', async () => {
|
|
461
|
+
const account = makeAccount({})
|
|
462
|
+
const orphan = makeUsageRecord('u-orphan', account.id, 'mystery_meter', 999)
|
|
463
|
+
const { em, container } = createEnvironment({
|
|
464
|
+
accounts: [account],
|
|
465
|
+
itemsByAccount: new Map([[account.id, []]]),
|
|
466
|
+
usageByAccount: new Map([[account.id, [orphan]]]),
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
const { outcomes } = await runBillRun(em as never, container as never, params({}))
|
|
470
|
+
|
|
471
|
+
expect(outcomes[0].status).toBe('success_with_warnings')
|
|
472
|
+
expect((outcomes[0].warnings as { unmatched_usage_uoms?: string[] } | null)
|
|
473
|
+
?.unmatched_usage_uoms).toEqual(['mystery_meter'])
|
|
474
|
+
// Critically: orphan is NOT marked rated
|
|
475
|
+
expect(orphan.ratedInBillRunId).toBeNull()
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('simple flat-rate usage emits unit_price on the invoice line', async () => {
|
|
479
|
+
const account = makeAccount({})
|
|
480
|
+
const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
|
|
481
|
+
const u1 = makeUsageRecord('u1', account.id, 'api_request', 12000)
|
|
482
|
+
const { em, container, captured } = createEnvironment({
|
|
483
|
+
accounts: [account],
|
|
484
|
+
itemsByAccount: new Map([[account.id, [usageItem]]]),
|
|
485
|
+
usageByAccount: new Map([[account.id, [u1]]]),
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
await runBillRun(em as never, container as never, params({}))
|
|
489
|
+
|
|
490
|
+
const usageLine = captured.persisted.find((p) => {
|
|
491
|
+
const m = (p as { metadata?: { billing_type?: string } }).metadata
|
|
492
|
+
return m?.billing_type === 'usage'
|
|
493
|
+
}) as { unitPriceNet?: string; totalNetAmount?: string; quantity?: string } | undefined
|
|
494
|
+
expect(usageLine?.unitPriceNet).toBe('0.0010')
|
|
495
|
+
expect(usageLine?.quantity).toBe('12000.0000')
|
|
496
|
+
expect(usageLine?.totalNetAmount).toBe('12.0000')
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('test mode: rates usage on draft but does NOT mark records as rated', async () => {
|
|
500
|
+
const account = makeAccount({})
|
|
501
|
+
const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
|
|
502
|
+
const u1 = makeUsageRecord('u1', account.id, 'api_request', 1000)
|
|
503
|
+
const { em, container } = createEnvironment({
|
|
504
|
+
accounts: [account],
|
|
505
|
+
itemsByAccount: new Map([[account.id, [usageItem]]]),
|
|
506
|
+
usageByAccount: new Map([[account.id, [u1]]]),
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
await runBillRun(em as never, container as never, params({ mode: 'test' }))
|
|
510
|
+
|
|
511
|
+
expect(u1.ratedInBillRunId).toBeNull()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('mixed recurring + usage: invoice has BOTH lines, total = sum', async () => {
|
|
515
|
+
const account = makeAccount({})
|
|
516
|
+
const mrc = makeRecurringItem(account.id, 'mrc')
|
|
517
|
+
const usageItem = makeUsageItem(account.id, 'api_request', { unit_price: 0.001 })
|
|
518
|
+
const u1 = makeUsageRecord('u1', account.id, 'api_request', 12000)
|
|
519
|
+
const { em, container, captured } = createEnvironment({
|
|
520
|
+
accounts: [account],
|
|
521
|
+
itemsByAccount: new Map([[account.id, [mrc, usageItem]]]),
|
|
522
|
+
usageByAccount: new Map([[account.id, [u1]]]),
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
await runBillRun(em as never, container as never, params({}))
|
|
526
|
+
|
|
527
|
+
const lines = captured.persisted.filter((p) => {
|
|
528
|
+
const m = (p as { metadata?: { billing_type?: string } }).metadata
|
|
529
|
+
return m?.billing_type === 'recurring' || m?.billing_type === 'usage'
|
|
530
|
+
})
|
|
531
|
+
expect(lines).toHaveLength(2)
|
|
532
|
+
// Invoice header totalNet should be 49.99 (MRC) + 12.00 (usage) = 61.99
|
|
533
|
+
const invoice = captured.persisted.find(
|
|
534
|
+
(p) => (p as { invoiceNumber?: string }).invoiceNumber,
|
|
535
|
+
) as { grandTotalNetAmount?: string } | undefined
|
|
536
|
+
expect(invoice?.grandTotalNetAmount).toBe('61.9900')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('summary aggregates drafts_created / accounts_failed / accounts_with_warnings', async () => {
|
|
540
|
+
const a1 = makeAccount({ id: 'a1' })
|
|
541
|
+
const a2 = makeAccount({ id: 'a2' })
|
|
542
|
+
const i1 = makeRecurringItem('a1')
|
|
543
|
+
const i2 = makeRecurringItem('a2')
|
|
544
|
+
i2.currencyMismatch = true
|
|
545
|
+
const { em, container } = createEnvironment({
|
|
546
|
+
accounts: [a1, a2],
|
|
547
|
+
itemsByAccount: new Map([
|
|
548
|
+
['a1', [i1]],
|
|
549
|
+
['a2', [i2]],
|
|
550
|
+
]),
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
const { billRun } = await runBillRun(em as never, container as never, params({}))
|
|
554
|
+
expect(billRun.summary).toMatchObject({
|
|
555
|
+
accounts_processed: 2,
|
|
556
|
+
drafts_created: 2,
|
|
557
|
+
accounts_with_warnings: 1,
|
|
558
|
+
accounts_failed: 0,
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { findBySourceRef } from '../../lib/idempotency'
|
|
2
|
+
|
|
3
|
+
class FakeEntity {
|
|
4
|
+
id = 'fake-id'
|
|
5
|
+
sourceRef?: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type MockEm = {
|
|
9
|
+
findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createMockEm(): MockEm {
|
|
13
|
+
return { findOne: jest.fn() }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TENANT_ID = '00000000-0000-0000-0000-0000000000aa'
|
|
17
|
+
const ACCOUNT_ID = '00000000-0000-0000-0000-0000000000bb'
|
|
18
|
+
|
|
19
|
+
describe('findBySourceRef', () => {
|
|
20
|
+
it('queries with the full idempotency scope (tenant, account, sourceRef, not deleted)', async () => {
|
|
21
|
+
const em = createMockEm()
|
|
22
|
+
em.findOne.mockResolvedValueOnce(null)
|
|
23
|
+
|
|
24
|
+
await findBySourceRef(em as never, FakeEntity, {
|
|
25
|
+
tenantId: TENANT_ID,
|
|
26
|
+
billAccountId: ACCOUNT_ID,
|
|
27
|
+
sourceRef: 'cpq-sub-1-mrc',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(em.findOne).toHaveBeenCalledTimes(1)
|
|
31
|
+
const [entity, where] = em.findOne.mock.calls[0]
|
|
32
|
+
expect(entity).toBe(FakeEntity)
|
|
33
|
+
expect(where).toEqual({
|
|
34
|
+
tenantId: TENANT_ID,
|
|
35
|
+
billAccountId: ACCOUNT_ID,
|
|
36
|
+
sourceRef: 'cpq-sub-1-mrc',
|
|
37
|
+
deletedAt: null,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns the existing row when one matches', async () => {
|
|
42
|
+
const em = createMockEm()
|
|
43
|
+
const existing = Object.assign(new FakeEntity(), { id: 'existing-uuid' })
|
|
44
|
+
em.findOne.mockResolvedValueOnce(existing)
|
|
45
|
+
|
|
46
|
+
const result = await findBySourceRef(em as never, FakeEntity, {
|
|
47
|
+
tenantId: TENANT_ID,
|
|
48
|
+
billAccountId: ACCOUNT_ID,
|
|
49
|
+
sourceRef: 'cpq-sub-1-mrc',
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(result).toBe(existing)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns null when no row matches (caller proceeds with insert)', async () => {
|
|
56
|
+
const em = createMockEm()
|
|
57
|
+
em.findOne.mockResolvedValueOnce(null)
|
|
58
|
+
|
|
59
|
+
const result = await findBySourceRef(em as never, FakeEntity, {
|
|
60
|
+
tenantId: TENANT_ID,
|
|
61
|
+
billAccountId: ACCOUNT_ID,
|
|
62
|
+
sourceRef: 'never-seen',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(result).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('excludes soft-deleted rows (deletedAt IS NULL)', async () => {
|
|
69
|
+
const em = createMockEm()
|
|
70
|
+
em.findOne.mockResolvedValueOnce(null)
|
|
71
|
+
|
|
72
|
+
await findBySourceRef(em as never, FakeEntity, {
|
|
73
|
+
tenantId: TENANT_ID,
|
|
74
|
+
billAccountId: ACCOUNT_ID,
|
|
75
|
+
sourceRef: 'src',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const [, where] = em.findOne.mock.calls[0]
|
|
79
|
+
expect((where as { deletedAt: unknown }).deletedAt).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|