@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,190 @@
|
|
|
1
|
+
import { rateUsageForItem, rateUsageQuantity } from '../../lib/usageRater'
|
|
2
|
+
import type { BillingItem } from '../../data/entities'
|
|
3
|
+
|
|
4
|
+
describe('rateUsageQuantity — simple flat rate', () => {
|
|
5
|
+
it('charges quantity × unit_price (canonical case)', () => {
|
|
6
|
+
const result = rateUsageQuantity({ unit_price: 0.001 }, 12000)
|
|
7
|
+
expect(result.amount).toBe('12.0000')
|
|
8
|
+
expect(result.breakdown).toEqual([
|
|
9
|
+
{ tierIndex: 0, upTo: null, rangeQuantity: 12000, rate: 0.001, amount: '12.0000' },
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns zero on zero quantity', () => {
|
|
14
|
+
const result = rateUsageQuantity({ unit_price: 0.001 }, 0)
|
|
15
|
+
expect(result.amount).toBe('0.0000')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rounds half-up to 2dp (1.005 → 1.01)', () => {
|
|
19
|
+
// unit_price=0.001 × quantity=1005 = 1.005 → 1.01
|
|
20
|
+
const result = rateUsageQuantity({ unit_price: 0.001 }, 1005)
|
|
21
|
+
expect(result.amount).toBe('1.0100')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('safe-guards negative quantity (treated as 0)', () => {
|
|
25
|
+
const result = rateUsageQuantity({ unit_price: 0.001 }, -100)
|
|
26
|
+
expect(result.amount).toBe('0.0000')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('rateUsageQuantity — volume tier (whole-quantity at top reached tier)', () => {
|
|
31
|
+
const volumeLadder = {
|
|
32
|
+
model: 'volume' as const,
|
|
33
|
+
tiers: [
|
|
34
|
+
{ up_to: 10000, unit_price: 0 },
|
|
35
|
+
{ up_to: 50000, unit_price: 0.001 },
|
|
36
|
+
{ up_to: null, unit_price: 0.0005 },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('25k requests at [10k=0, 50k=0.001, ∞=0.0005] → 25000 × 0.001 = 25 EUR (spec example)', () => {
|
|
41
|
+
const result = rateUsageQuantity(volumeLadder, 25000)
|
|
42
|
+
expect(result.amount).toBe('25.0000')
|
|
43
|
+
expect(result.breakdown[0].rangeQuantity).toBe(0)
|
|
44
|
+
expect(result.breakdown[1].rangeQuantity).toBe(25000)
|
|
45
|
+
expect(result.breakdown[2].rangeQuantity).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('5k requests fall into the bottom (free) tier → 0 EUR', () => {
|
|
49
|
+
const result = rateUsageQuantity(volumeLadder, 5000)
|
|
50
|
+
expect(result.amount).toBe('0.0000')
|
|
51
|
+
expect(result.breakdown[0].rangeQuantity).toBe(5000)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('100k requests overflow into the open-ended top tier → 100k × 0.0005 = 50 EUR', () => {
|
|
55
|
+
const result = rateUsageQuantity(volumeLadder, 100000)
|
|
56
|
+
expect(result.amount).toBe('50.0000')
|
|
57
|
+
expect(result.breakdown[2].rangeQuantity).toBe(100000)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('exact boundary (50k) stays in the middle tier — 50000 × 0.001 = 50', () => {
|
|
61
|
+
const result = rateUsageQuantity(volumeLadder, 50000)
|
|
62
|
+
expect(result.amount).toBe('50.0000')
|
|
63
|
+
expect(result.breakdown[1].rangeQuantity).toBe(50000)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('rateUsageQuantity — graduated tier (per-unit at tier)', () => {
|
|
68
|
+
const graduatedLadder = {
|
|
69
|
+
model: 'graduated' as const,
|
|
70
|
+
tiers: [
|
|
71
|
+
{ up_to: 10000, unit_price: 0 },
|
|
72
|
+
{ up_to: null, unit_price: 0.001 },
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('25k requests at [10k=0, ∞=0.001] → 10k×0 + 15k×0.001 = 15 EUR (spec example)', () => {
|
|
77
|
+
const result = rateUsageQuantity(graduatedLadder, 25000)
|
|
78
|
+
expect(result.amount).toBe('15.0000')
|
|
79
|
+
expect(result.breakdown[0].rangeQuantity).toBe(10000)
|
|
80
|
+
expect(result.breakdown[0].amount).toBe('0.0000')
|
|
81
|
+
expect(result.breakdown[1].rangeQuantity).toBe(15000)
|
|
82
|
+
expect(result.breakdown[1].amount).toBe('15.0000')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('5k requests stay in the free tier → 0 EUR', () => {
|
|
86
|
+
const result = rateUsageQuantity(graduatedLadder, 5000)
|
|
87
|
+
expect(result.amount).toBe('0.0000')
|
|
88
|
+
expect(result.breakdown[0].rangeQuantity).toBe(5000)
|
|
89
|
+
expect(result.breakdown[1].rangeQuantity).toBe(0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('exactly 10k stays free (boundary inclusive of the lower tier)', () => {
|
|
93
|
+
const result = rateUsageQuantity(graduatedLadder, 10000)
|
|
94
|
+
expect(result.amount).toBe('0.0000')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('three-tier ladder splits correctly', () => {
|
|
98
|
+
const ladder = {
|
|
99
|
+
model: 'graduated' as const,
|
|
100
|
+
tiers: [
|
|
101
|
+
{ up_to: 100, unit_price: 1 },
|
|
102
|
+
{ up_to: 200, unit_price: 0.5 },
|
|
103
|
+
{ up_to: null, unit_price: 0.1 },
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
const result = rateUsageQuantity(ladder, 350)
|
|
107
|
+
// 100 × 1 + 100 × 0.5 + 150 × 0.1 = 100 + 50 + 15 = 165
|
|
108
|
+
expect(result.amount).toBe('165.0000')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('rateUsageQuantity — flat tier (one fee for highest tier reached)', () => {
|
|
113
|
+
const flatLadder = {
|
|
114
|
+
model: 'flat' as const,
|
|
115
|
+
tiers: [
|
|
116
|
+
{ up_to: 10000, flat_amount: 50 },
|
|
117
|
+
{ up_to: 50000, flat_amount: 200 },
|
|
118
|
+
{ up_to: null, flat_amount: 1000 },
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it('25k requests → top tier reached is 50k → 200 EUR (spec example)', () => {
|
|
123
|
+
const result = rateUsageQuantity(flatLadder, 25000)
|
|
124
|
+
expect(result.amount).toBe('200.0000')
|
|
125
|
+
expect(result.breakdown[1].amount).toBe('200.0000')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('exactly 10k → bottom tier → 50 EUR', () => {
|
|
129
|
+
const result = rateUsageQuantity(flatLadder, 10000)
|
|
130
|
+
expect(result.amount).toBe('50.0000')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('overflows into open-ended top tier → 1000 EUR', () => {
|
|
134
|
+
const result = rateUsageQuantity(flatLadder, 75000)
|
|
135
|
+
expect(result.amount).toBe('1000.0000')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('zero quantity still hits the bottom tier (consistent with billable presence)', () => {
|
|
139
|
+
const result = rateUsageQuantity(flatLadder, 0)
|
|
140
|
+
// 0 is ≤ first tier's up_to=10000, so the rate is 50 EUR.
|
|
141
|
+
expect(result.amount).toBe('50.0000')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('rateUsageForItem (dispatch wrapper)', () => {
|
|
146
|
+
function makeItem(rate: unknown, type: 'usage' | 'recurring' = 'usage'): BillingItem {
|
|
147
|
+
return {
|
|
148
|
+
id: 'i1',
|
|
149
|
+
type,
|
|
150
|
+
rateJson: rate,
|
|
151
|
+
} as unknown as BillingItem
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
it('returns null for non-usage items (defensive)', () => {
|
|
155
|
+
expect(rateUsageForItem(makeItem({ unit_price: 0.001 }, 'recurring'), 100)).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns null for null rateJson', () => {
|
|
159
|
+
expect(rateUsageForItem(makeItem(null), 100)).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('returns null for tiered rate with empty tiers array', () => {
|
|
163
|
+
expect(
|
|
164
|
+
rateUsageForItem(makeItem({ model: 'graduated', tiers: [] }), 100),
|
|
165
|
+
).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('returns null for simple rate missing unit_price', () => {
|
|
169
|
+
expect(rateUsageForItem(makeItem({}), 100)).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('dispatches to simple-flat path for { unit_price }', () => {
|
|
173
|
+
const result = rateUsageForItem(makeItem({ unit_price: 0.001 }), 12000)
|
|
174
|
+
expect(result?.amount).toBe('12.0000')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('dispatches to graduated path for { model: graduated, tiers }', () => {
|
|
178
|
+
const result = rateUsageForItem(
|
|
179
|
+
makeItem({
|
|
180
|
+
model: 'graduated',
|
|
181
|
+
tiers: [
|
|
182
|
+
{ up_to: 10000, unit_price: 0 },
|
|
183
|
+
{ up_to: null, unit_price: 0.001 },
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
25000,
|
|
187
|
+
)
|
|
188
|
+
expect(result?.amount).toBe('15.0000')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package isolation guard for @dainamite/billing.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the @dainamite/cpq package-isolation test — proves the billing
|
|
5
|
+
* package can be loaded without any sibling `src/modules/<x>` modules and
|
|
6
|
+
* that every import resolves to a peerDependency or sibling file. See the
|
|
7
|
+
* cpq variant for the full rationale; this version reflects billing's
|
|
8
|
+
* narrower allowed-imports and required-modules surface.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { metadata } from '../index'
|
|
15
|
+
|
|
16
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..')
|
|
17
|
+
|
|
18
|
+
// Must mirror peerDependencies in packages/billing/package.json (plus
|
|
19
|
+
// relatives + node builtins). Adding a prefix here without adding it to
|
|
20
|
+
// peerDependencies = silent runtime failure for consumers that don't have
|
|
21
|
+
// the dep transitively.
|
|
22
|
+
const ALLOWED_IMPORT_PREFIXES = [
|
|
23
|
+
'./',
|
|
24
|
+
'../',
|
|
25
|
+
'@open-mercato/',
|
|
26
|
+
'@mikro-orm/',
|
|
27
|
+
'awilix',
|
|
28
|
+
'lucide-react',
|
|
29
|
+
'next',
|
|
30
|
+
'next/',
|
|
31
|
+
'react',
|
|
32
|
+
'react/',
|
|
33
|
+
'react-dom',
|
|
34
|
+
'react-dom/',
|
|
35
|
+
'zod',
|
|
36
|
+
'@dainamite/',
|
|
37
|
+
'node:',
|
|
38
|
+
'@tanstack/',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const NODE_BUILTINS = new Set([
|
|
42
|
+
'crypto',
|
|
43
|
+
'fs',
|
|
44
|
+
'path',
|
|
45
|
+
'os',
|
|
46
|
+
'util',
|
|
47
|
+
'stream',
|
|
48
|
+
'buffer',
|
|
49
|
+
'http',
|
|
50
|
+
'https',
|
|
51
|
+
'url',
|
|
52
|
+
'querystring',
|
|
53
|
+
'child_process',
|
|
54
|
+
'zlib',
|
|
55
|
+
'events',
|
|
56
|
+
'assert',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const FORBIDDEN_PREFIXES = [
|
|
60
|
+
'@/modules/',
|
|
61
|
+
'src/modules/',
|
|
62
|
+
'../../../../catalog/',
|
|
63
|
+
'../../../../sales/',
|
|
64
|
+
'../../../../customers/',
|
|
65
|
+
'../../../../dictionaries/',
|
|
66
|
+
'../../../../auth/',
|
|
67
|
+
'../../../../directory/',
|
|
68
|
+
'../../../../demo_',
|
|
69
|
+
'../../../../portal/',
|
|
70
|
+
'../../../../customer_accounts/',
|
|
71
|
+
'../../../../example/',
|
|
72
|
+
'../../../../cpq/',
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
async function walk(dir: string, acc: string[] = []): Promise<string[]> {
|
|
76
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue
|
|
79
|
+
if (entry.name === '__tests__' || entry.name === '__integration__') continue
|
|
80
|
+
const full = path.join(dir, entry.name)
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
await walk(full, acc)
|
|
83
|
+
} else if (/\.(ts|tsx|mts|cts)$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
|
|
84
|
+
acc.push(full)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return acc
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const IMPORT_RE = /(?:^|\n)\s*(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+(?:[^'"]+\s+from\s+)|import\s*\(\s*)["']([^"']+)["']/g
|
|
91
|
+
|
|
92
|
+
function extractImports(source: string): string[] {
|
|
93
|
+
const out: string[] = []
|
|
94
|
+
let m: RegExpExecArray | null
|
|
95
|
+
while ((m = IMPORT_RE.exec(source)) !== null) {
|
|
96
|
+
out.push(m[1])
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isAllowedSpecifier(spec: string): boolean {
|
|
102
|
+
if (NODE_BUILTINS.has(spec)) return true
|
|
103
|
+
return ALLOWED_IMPORT_PREFIXES.some((p) => spec === p || spec.startsWith(p))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isForbiddenSpecifier(spec: string): boolean {
|
|
107
|
+
return FORBIDDEN_PREFIXES.some((p) => spec.startsWith(p))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractCoreModuleSubpath(spec: string): string | null {
|
|
111
|
+
const prefix = '@open-mercato/core/modules/'
|
|
112
|
+
if (!spec.startsWith(prefix)) return null
|
|
113
|
+
const rest = spec.slice(prefix.length)
|
|
114
|
+
const slash = rest.indexOf('/')
|
|
115
|
+
return slash === -1 ? rest : rest.slice(0, slash)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('@dainamite/billing package isolation', () => {
|
|
119
|
+
let allFiles: string[] = []
|
|
120
|
+
let importsByFile = new Map<string, string[]>()
|
|
121
|
+
|
|
122
|
+
beforeAll(async () => {
|
|
123
|
+
allFiles = await walk(PACKAGE_ROOT)
|
|
124
|
+
importsByFile = new Map()
|
|
125
|
+
for (const file of allFiles) {
|
|
126
|
+
const source = await fs.readFile(file, 'utf-8')
|
|
127
|
+
importsByFile.set(file, extractImports(source))
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('declares all required modules in metadata.requires', () => {
|
|
132
|
+
expect(metadata.requires).toEqual(
|
|
133
|
+
expect.arrayContaining(['auth', 'directory', 'dictionaries', 'configs', 'sales']),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does not import from sibling app modules', () => {
|
|
138
|
+
const violations: Array<{ file: string; spec: string }> = []
|
|
139
|
+
for (const [file, specs] of importsByFile) {
|
|
140
|
+
for (const spec of specs) {
|
|
141
|
+
if (isForbiddenSpecifier(spec)) {
|
|
142
|
+
violations.push({ file: path.relative(PACKAGE_ROOT, file), spec })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (violations.length > 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Forbidden cross-module imports found:\n${violations
|
|
149
|
+
.map((v) => ` ${v.file} → ${v.spec}`)
|
|
150
|
+
.join('\n')}`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('only uses allowed import prefixes (no surprise package leaks)', () => {
|
|
156
|
+
const violations: Array<{ file: string; spec: string }> = []
|
|
157
|
+
for (const [file, specs] of importsByFile) {
|
|
158
|
+
for (const spec of specs) {
|
|
159
|
+
if (!isAllowedSpecifier(spec)) {
|
|
160
|
+
violations.push({ file: path.relative(PACKAGE_ROOT, file), spec })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (violations.length > 0) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Imports outside the allowed prefix set:\n${violations
|
|
167
|
+
.map((v) => ` ${v.file} → ${v.spec}`)
|
|
168
|
+
.join('\n')}\n` +
|
|
169
|
+
`If a new prefix is legitimate, add it to ALLOWED_IMPORT_PREFIXES in this test.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('only deep-imports from core modules declared in metadata.requires', () => {
|
|
175
|
+
const declared = new Set<string>(metadata.requires ?? [])
|
|
176
|
+
const violations: Array<{ file: string; spec: string; module: string }> = []
|
|
177
|
+
for (const [file, specs] of importsByFile) {
|
|
178
|
+
for (const spec of specs) {
|
|
179
|
+
const subModule = extractCoreModuleSubpath(spec)
|
|
180
|
+
if (subModule && !declared.has(subModule)) {
|
|
181
|
+
violations.push({ file: path.relative(PACKAGE_ROOT, file), spec, module: subModule })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (violations.length > 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Deep imports into @open-mercato/core/modules/<x> for modules not in metadata.requires:\n${violations
|
|
188
|
+
.map((v) => ` ${v.file} → ${v.spec} (add '${v.module}' to requires)`)
|
|
189
|
+
.join('\n')}`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('contains no @ManyToOne / @OneToMany / @ManyToMany / @OneToOne ORM relations', async () => {
|
|
195
|
+
const relRe = /@(ManyToOne|OneToMany|ManyToMany|OneToOne)\s*\(/g
|
|
196
|
+
const violations: Array<{ file: string; line: number; match: string }> = []
|
|
197
|
+
for (const file of allFiles) {
|
|
198
|
+
const source = await fs.readFile(file, 'utf-8')
|
|
199
|
+
const lines = source.split('\n')
|
|
200
|
+
lines.forEach((line, idx) => {
|
|
201
|
+
const m = line.match(relRe)
|
|
202
|
+
if (m) {
|
|
203
|
+
violations.push({ file: path.relative(PACKAGE_ROOT, file), line: idx + 1, match: m.join(', ') })
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
if (violations.length > 0) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Cross-entity ORM relations forbidden in @dainamite/billing (use FK string columns):\n${violations
|
|
210
|
+
.map((v) => ` ${v.file}:${v.line} — ${v.match}`)
|
|
211
|
+
.join('\n')}`,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('module index.ts loads without throwing', async () => {
|
|
217
|
+
const mod = await import('../index')
|
|
218
|
+
expect(mod.metadata).toBeDefined()
|
|
219
|
+
expect(mod.metadata.name).toBe('billing')
|
|
220
|
+
expect(mod.features).toBeDefined()
|
|
221
|
+
expect(Array.isArray(mod.features)).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup contract tests.
|
|
3
|
+
*
|
|
4
|
+
* These are structural tests on the exported `ModuleSetupConfig` — they
|
|
5
|
+
* verify the seed contract surface (role grants, callable hooks) without
|
|
6
|
+
* spinning up MikroORM. Behavioral idempotency of the dictionary / config
|
|
7
|
+
* seeds is exercised end-to-end via the integration suite once Phase 1
|
|
8
|
+
* lands a real REST API; the structural checks below catch the most
|
|
9
|
+
* common drift (missing role grant, deleted hook, wrong feature spelled
|
|
10
|
+
* differently from acl.ts).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { features } from '../acl'
|
|
14
|
+
import setup from '../setup'
|
|
15
|
+
|
|
16
|
+
const featureIds = new Set<string>(features.map((f) => f.id))
|
|
17
|
+
|
|
18
|
+
describe('billing setup — contract', () => {
|
|
19
|
+
it('exports onTenantCreated and seedDefaults hooks', () => {
|
|
20
|
+
expect(typeof setup.onTenantCreated).toBe('function')
|
|
21
|
+
expect(typeof setup.seedDefaults).toBe('function')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not declare seedExamples (billing ships no demo data)', () => {
|
|
25
|
+
expect(setup.seedExamples).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('grants the admin role the billing.* wildcard', () => {
|
|
29
|
+
expect(setup.defaultRoleFeatures?.admin).toEqual(['billing.*'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('billing_admin role gets every concrete billing feature', () => {
|
|
33
|
+
const grants = setup.defaultRoleFeatures?.billing_admin ?? []
|
|
34
|
+
for (const id of featureIds) {
|
|
35
|
+
expect(grants).toContain(id)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('billing_finance_user is read-only + dry-run (no posting / no real trigger)', () => {
|
|
40
|
+
const grants = setup.defaultRoleFeatures?.billing_finance_user ?? []
|
|
41
|
+
// Read-only baseline
|
|
42
|
+
expect(grants).toEqual(
|
|
43
|
+
expect.arrayContaining([
|
|
44
|
+
'billing.account.view',
|
|
45
|
+
'billing.item.view',
|
|
46
|
+
'billing.usage.view',
|
|
47
|
+
'billing.run.view',
|
|
48
|
+
'billing.invoice.view',
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
51
|
+
// Dry-run preview is explicitly granted
|
|
52
|
+
expect(grants).toContain('billing.run.dry_run')
|
|
53
|
+
// …but not the real trigger / posting / editing
|
|
54
|
+
expect(grants).not.toContain('billing.run.trigger')
|
|
55
|
+
expect(grants).not.toContain('billing.invoice.post')
|
|
56
|
+
expect(grants).not.toContain('billing.invoice.edit_draft')
|
|
57
|
+
expect(grants).not.toContain('billing.usage.ingest')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('billing_usage_writer is narrow scope: usage.ingest only', () => {
|
|
61
|
+
expect(setup.defaultRoleFeatures?.billing_usage_writer).toEqual(['billing.usage.ingest'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('every feature granted to any default role exists in acl.ts (no typos)', () => {
|
|
65
|
+
for (const [role, grants] of Object.entries(setup.defaultRoleFeatures ?? {})) {
|
|
66
|
+
for (const grant of grants ?? []) {
|
|
67
|
+
// Wildcards (`billing.*`) bypass the typo check by design.
|
|
68
|
+
if (grant.endsWith('.*')) continue
|
|
69
|
+
if (!featureIds.has(grant)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Role "${role}" grants unknown feature "${grant}" — not declared in acl.ts`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('billing acl — surface', () => {
|
|
80
|
+
it('exports exactly 12 features per Phase 0 spec', () => {
|
|
81
|
+
expect(features).toHaveLength(12)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('all feature IDs are prefixed `billing.` (no leaking module boundary)', () => {
|
|
85
|
+
for (const f of features) {
|
|
86
|
+
expect(f.id.startsWith('billing.')).toBe(true)
|
|
87
|
+
expect(f.module).toBe('billing')
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it.each([
|
|
92
|
+
'billing.account.manage',
|
|
93
|
+
'billing.account.view',
|
|
94
|
+
'billing.item.manage',
|
|
95
|
+
'billing.item.view',
|
|
96
|
+
'billing.usage.ingest',
|
|
97
|
+
'billing.usage.view',
|
|
98
|
+
'billing.run.trigger',
|
|
99
|
+
'billing.run.dry_run',
|
|
100
|
+
'billing.run.view',
|
|
101
|
+
'billing.invoice.post',
|
|
102
|
+
'billing.invoice.edit_draft',
|
|
103
|
+
'billing.invoice.view',
|
|
104
|
+
])('declares feature %s', (id) => {
|
|
105
|
+
expect(featureIds.has(id)).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
})
|