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