@dainamite/billing 0.17.0 → 0.18.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 +81 -19
- package/dist/modules/billing/acl.js +9 -1
- package/dist/modules/billing/acl.js.map +2 -2
- package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
- package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
- package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
- package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +7 -0
- package/dist/modules/billing/api/accounts/route.js.map +2 -2
- package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
- package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
- package/dist/modules/billing/api/statements/generate/route.js +68 -0
- package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
- package/dist/modules/billing/api/statements/route.js +110 -0
- package/dist/modules/billing/api/statements/route.js.map +7 -0
- package/dist/modules/billing/api/topups/route.js +139 -0
- package/dist/modules/billing/api/topups/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
- package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
- package/dist/modules/billing/api/transactions/route.js +109 -0
- package/dist/modules/billing/api/transactions/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +30 -6
- package/dist/modules/billing/api/usage/route.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
- package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
- package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.js +152 -0
- package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.js +147 -0
- package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
- package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +26 -1
- package/dist/modules/billing/commands/accounts.js.map +2 -2
- package/dist/modules/billing/commands/index.js +3 -0
- package/dist/modules/billing/commands/index.js.map +2 -2
- package/dist/modules/billing/commands/invoices.js +21 -1
- package/dist/modules/billing/commands/invoices.js.map +2 -2
- package/dist/modules/billing/commands/statements.js +74 -0
- package/dist/modules/billing/commands/statements.js.map +7 -0
- package/dist/modules/billing/commands/topups.js +139 -0
- package/dist/modules/billing/commands/topups.js.map +7 -0
- package/dist/modules/billing/commands/transactions.js +119 -0
- package/dist/modules/billing/commands/transactions.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +72 -0
- package/dist/modules/billing/commands/usage.js.map +2 -2
- package/dist/modules/billing/components/AccountForm.js +53 -1
- package/dist/modules/billing/components/AccountForm.js.map +2 -2
- package/dist/modules/billing/components/PrepaidPanel.js +314 -0
- package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
- package/dist/modules/billing/components/accountNames.js +20 -0
- package/dist/modules/billing/components/accountNames.js.map +7 -0
- package/dist/modules/billing/data/entities.js +290 -0
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/data/entityIds.js +6 -1
- package/dist/modules/billing/data/entityIds.js.map +2 -2
- package/dist/modules/billing/data/validators.js +107 -2
- package/dist/modules/billing/data/validators.js.map +2 -2
- package/dist/modules/billing/events.js +52 -0
- package/dist/modules/billing/events.js.map +2 -2
- package/dist/modules/billing/i18n/en.json +112 -4
- package/dist/modules/billing/i18n/pl.json +112 -4
- package/dist/modules/billing/index.js +1 -1
- package/dist/modules/billing/index.js.map +2 -2
- package/dist/modules/billing/lib/accountStatus.js +106 -0
- package/dist/modules/billing/lib/accountStatus.js.map +7 -0
- package/dist/modules/billing/lib/balanceEvents.js +48 -0
- package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
- package/dist/modules/billing/lib/balanceLedger.js +136 -0
- package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
- package/dist/modules/billing/lib/balanceStatus.js +29 -0
- package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +40 -2
- package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
- package/dist/modules/billing/lib/creditStatus.js +65 -0
- package/dist/modules/billing/lib/creditStatus.js.map +7 -0
- package/dist/modules/billing/lib/money.js +47 -0
- package/dist/modules/billing/lib/money.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConfig.js +45 -0
- package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
- package/dist/modules/billing/lib/prepaidConsume.js +214 -0
- package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
- package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
- package/dist/modules/billing/lib/topupCapture.js +62 -0
- package/dist/modules/billing/lib/topupCapture.js.map +7 -0
- package/dist/modules/billing/lib/topupReceipt.js +97 -0
- package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
- package/dist/modules/billing/lib/topupTransition.js +19 -0
- package/dist/modules/billing/lib/topupTransition.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
- package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
- package/dist/modules/billing/setup.js +32 -1
- package/dist/modules/billing/setup.js.map +2 -2
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
- package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
- package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
- package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
- package/package.json +1 -1
- package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
- package/src/modules/billing/__tests__/commands/transactions.test.ts +84 -0
- package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
- package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
- package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
- package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
- package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
- package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
- package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
- package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
- package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
- package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
- package/src/modules/billing/__tests__/setup.test.ts +28 -2
- package/src/modules/billing/acl.ts +8 -0
- package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
- package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
- package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
- package/src/modules/billing/api/accounts/route.ts +7 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
- package/src/modules/billing/api/statements/generate/route.ts +78 -0
- package/src/modules/billing/api/statements/route.ts +117 -0
- package/src/modules/billing/api/topups/route.ts +154 -0
- package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
- package/src/modules/billing/api/transactions/route.ts +116 -0
- package/src/modules/billing/api/usage/route.ts +36 -5
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
- package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
- package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
- package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
- package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
- package/src/modules/billing/commands/accounts.ts +42 -1
- package/src/modules/billing/commands/index.ts +3 -0
- package/src/modules/billing/commands/invoices.ts +26 -2
- package/src/modules/billing/commands/statements.ts +101 -0
- package/src/modules/billing/commands/topups.ts +181 -0
- package/src/modules/billing/commands/transactions.ts +152 -0
- package/src/modules/billing/commands/usage.ts +114 -4
- package/src/modules/billing/components/AccountForm.tsx +66 -0
- package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
- package/src/modules/billing/components/accountNames.ts +23 -0
- package/src/modules/billing/data/entities.ts +383 -0
- package/src/modules/billing/data/entityIds.ts +5 -0
- package/src/modules/billing/data/validators.ts +144 -0
- package/src/modules/billing/events.ts +52 -0
- package/src/modules/billing/i18n/en.json +112 -4
- package/src/modules/billing/i18n/pl.json +112 -4
- package/src/modules/billing/index.ts +1 -1
- package/src/modules/billing/lib/accountStatus.ts +155 -0
- package/src/modules/billing/lib/balanceEvents.ts +74 -0
- package/src/modules/billing/lib/balanceLedger.ts +258 -0
- package/src/modules/billing/lib/balanceStatus.ts +78 -0
- package/src/modules/billing/lib/billRunEngine.ts +66 -5
- package/src/modules/billing/lib/creditStatus.ts +153 -0
- package/src/modules/billing/lib/money.ts +73 -0
- package/src/modules/billing/lib/prepaidConfig.ts +58 -0
- package/src/modules/billing/lib/prepaidConsume.ts +299 -0
- package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
- package/src/modules/billing/lib/topupCapture.ts +117 -0
- package/src/modules/billing/lib/topupReceipt.ts +153 -0
- package/src/modules/billing/lib/topupTransition.ts +31 -0
- package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
- package/src/modules/billing/setup.ts +45 -0
- package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
- package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
- package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
- package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { captureTopup } from '../../lib/topupCapture'
|
|
2
|
+
import { __resetInvoiceStatusCacheForTests } from '../../lib/invoiceStatus'
|
|
3
|
+
import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
|
|
4
|
+
|
|
5
|
+
const ACCOUNT = {
|
|
6
|
+
id: 'acct-1',
|
|
7
|
+
organizationId: 'org-1',
|
|
8
|
+
tenantId: 'ten-1',
|
|
9
|
+
currencyCode: 'EUR',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeDeps() {
|
|
13
|
+
return {
|
|
14
|
+
numberGenerator: {
|
|
15
|
+
generate: jest.fn(async () => ({ number: 'FV/2026/06/0042', format: 'x', sequence: 42 })),
|
|
16
|
+
},
|
|
17
|
+
taxService: {
|
|
18
|
+
calculateUnitAmounts: jest.fn(async (input: { amount: number }) => ({
|
|
19
|
+
netAmount: input.amount,
|
|
20
|
+
grossAmount: input.amount,
|
|
21
|
+
taxAmount: 0,
|
|
22
|
+
taxRate: 0,
|
|
23
|
+
})),
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeTopup(overrides: Record<string, unknown> = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id: 'topup-1',
|
|
31
|
+
status: 'pending',
|
|
32
|
+
amount: '100.0000',
|
|
33
|
+
paymentId: 'pay-1',
|
|
34
|
+
providerKey: 'stripe',
|
|
35
|
+
billAccountId: 'acct-1',
|
|
36
|
+
tenantId: 'ten-1',
|
|
37
|
+
organizationId: 'org-1',
|
|
38
|
+
...overrides,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
__resetInvoiceStatusCacheForTests()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('captureTopup', () => {
|
|
47
|
+
it('credits the balance, issues a receipt, and flips the top-up to captured', async () => {
|
|
48
|
+
const topup = makeTopup()
|
|
49
|
+
const em = createPrepaidMockEm({
|
|
50
|
+
topup,
|
|
51
|
+
balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '0.0000' },
|
|
52
|
+
})
|
|
53
|
+
const result = await captureTopup(em as never, makeDeps(), {
|
|
54
|
+
topup: topup as never,
|
|
55
|
+
account: ACCOUNT,
|
|
56
|
+
taxRate: 0,
|
|
57
|
+
gatewayTransactionId: 'gw-1',
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(result.status).toBe('captured')
|
|
61
|
+
expect(result.balanceAfter).toBe('100.0000')
|
|
62
|
+
expect(result.transactionId).toBeTruthy()
|
|
63
|
+
expect(result.receiptInvoiceId).toBeTruthy()
|
|
64
|
+
expect(result.invoiceNumber).toBe('FV/2026/06/0042')
|
|
65
|
+
|
|
66
|
+
// The credit transaction carries the deterministic source_ref.
|
|
67
|
+
expect(em.transactions).toHaveLength(1)
|
|
68
|
+
expect(em.transactions[0].type).toBe('topup')
|
|
69
|
+
expect(em.transactions[0].amount).toBe('100.0000')
|
|
70
|
+
expect(em.transactions[0].sourceRef).toBe('topup-pay-1')
|
|
71
|
+
|
|
72
|
+
// The top-up itself transitioned + backfilled.
|
|
73
|
+
expect(topup.status).toBe('captured')
|
|
74
|
+
expect((topup as Record<string, unknown>).transactionId).toBe(result.transactionId)
|
|
75
|
+
expect((topup as Record<string, unknown>).receiptInvoiceId).toBe(result.receiptInvoiceId)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('is a no-op on an already-captured top-up (idempotent re-fire)', async () => {
|
|
79
|
+
const topup = makeTopup({ status: 'captured' })
|
|
80
|
+
const em = createPrepaidMockEm({
|
|
81
|
+
topup,
|
|
82
|
+
balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '100.0000' },
|
|
83
|
+
})
|
|
84
|
+
const result = await captureTopup(em as never, makeDeps(), {
|
|
85
|
+
topup: topup as never,
|
|
86
|
+
account: ACCOUNT,
|
|
87
|
+
taxRate: 0,
|
|
88
|
+
})
|
|
89
|
+
expect(result.status).toBe('already_captured')
|
|
90
|
+
expect(em.transactions).toHaveLength(0) // no second credit
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not double-credit OR issue a second receipt when the source_ref exists', async () => {
|
|
94
|
+
// Simulates an at-least-once / concurrent re-fire that raced past the stale
|
|
95
|
+
// status check: the credit dedups AND no second fiscal receipt is minted.
|
|
96
|
+
const topup = makeTopup({ transactionId: 'txn-prior', receiptInvoiceId: 'inv-prior' })
|
|
97
|
+
const em = createPrepaidMockEm({
|
|
98
|
+
topup,
|
|
99
|
+
balance: { tenantId: 'ten-1', billAccountId: 'acct-1', balance: '100.0000' },
|
|
100
|
+
})
|
|
101
|
+
em.transactions.push({
|
|
102
|
+
id: 'txn-prior',
|
|
103
|
+
type: 'topup',
|
|
104
|
+
amount: '100.0000',
|
|
105
|
+
balanceAfter: '100.0000',
|
|
106
|
+
sourceRef: 'topup-pay-1',
|
|
107
|
+
billAccountId: 'acct-1',
|
|
108
|
+
createdAt: new Date('2026-06-10'),
|
|
109
|
+
})
|
|
110
|
+
const deps = makeDeps()
|
|
111
|
+
const result = await captureTopup(em as never, deps, {
|
|
112
|
+
topup: topup as never,
|
|
113
|
+
account: ACCOUNT,
|
|
114
|
+
taxRate: 0,
|
|
115
|
+
})
|
|
116
|
+
expect(result.status).toBe('already_captured')
|
|
117
|
+
expect(result.balanceAfter).toBe('100.0000') // unchanged — dedup hit
|
|
118
|
+
expect(result.receiptInvoiceId).toBe('inv-prior') // returns the existing receipt
|
|
119
|
+
expect(em.transactions).toHaveLength(1) // no new credit transaction
|
|
120
|
+
// No second fiscal document: the number generator was never called.
|
|
121
|
+
expect(deps.numberGenerator.generate).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createTopupReceipt, type TopupReceiptDeps } from '../../lib/topupReceipt'
|
|
2
|
+
import { __resetInvoiceStatusCacheForTests } from '../../lib/invoiceStatus'
|
|
3
|
+
import { createPrepaidMockEm } from '../helpers/prepaidMockEm'
|
|
4
|
+
|
|
5
|
+
const ACCOUNT = {
|
|
6
|
+
id: 'acct-1',
|
|
7
|
+
organizationId: 'org-1',
|
|
8
|
+
tenantId: 'ten-1',
|
|
9
|
+
currencyCode: 'EUR',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeDeps(taxRateOut: number | null): TopupReceiptDeps {
|
|
13
|
+
return {
|
|
14
|
+
numberGenerator: {
|
|
15
|
+
generate: jest.fn(async () => ({
|
|
16
|
+
number: 'FV/2026/06/0042',
|
|
17
|
+
format: 'FV/{yyyy}/{mm}/{seq:4}',
|
|
18
|
+
sequence: 42,
|
|
19
|
+
})),
|
|
20
|
+
},
|
|
21
|
+
taxService: {
|
|
22
|
+
calculateUnitAmounts: jest.fn(async (input: { amount: number; taxRate?: number | string | null }) => {
|
|
23
|
+
const rate = typeof input.taxRate === 'number' ? input.taxRate : Number(input.taxRate ?? 0)
|
|
24
|
+
const gross = input.amount
|
|
25
|
+
const net = rate > 0 ? gross / (1 + rate / 100) : gross
|
|
26
|
+
return {
|
|
27
|
+
netAmount: Math.round(net * 10000) / 10000,
|
|
28
|
+
grossAmount: Math.round(gross * 10000) / 10000,
|
|
29
|
+
taxAmount: Math.round((gross - net) * 10000) / 10000,
|
|
30
|
+
taxRate: taxRateOut,
|
|
31
|
+
}
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
__resetInvoiceStatusCacheForTests()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('createTopupReceipt — VAT at top-up (amount is gross)', () => {
|
|
42
|
+
it('splits 100 gross @ 23% into net + VAT and posts paid', async () => {
|
|
43
|
+
const em = createPrepaidMockEm()
|
|
44
|
+
const deps = makeDeps(23)
|
|
45
|
+
const receipt = await createTopupReceipt(em as never, deps, {
|
|
46
|
+
account: ACCOUNT,
|
|
47
|
+
topupId: 'topup-1',
|
|
48
|
+
paymentId: 'pay-1',
|
|
49
|
+
amount: '100.0000',
|
|
50
|
+
taxRate: 23,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(receipt.invoiceNumber).toBe('FV/2026/06/0042')
|
|
54
|
+
expect(receipt.grossAmount).toBe('100.0000')
|
|
55
|
+
expect(receipt.netAmount).toBe('81.3008')
|
|
56
|
+
expect(receipt.taxAmount).toBe('18.6992')
|
|
57
|
+
expect(receipt.taxRate).toBe(23)
|
|
58
|
+
|
|
59
|
+
// The invoice was persisted as paid with a single top-up line.
|
|
60
|
+
const invoice = em.persisted.find((e) => (e as { status?: string }).status === 'paid') as Record<
|
|
61
|
+
string,
|
|
62
|
+
unknown
|
|
63
|
+
>
|
|
64
|
+
expect(invoice).toBeTruthy()
|
|
65
|
+
expect(invoice.paidTotalAmount).toBe('100.0000')
|
|
66
|
+
expect(invoice.outstandingAmount).toBe('0')
|
|
67
|
+
expect((invoice.metadata as Record<string, unknown>).kind).toBe('prepaid_topup')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('0% VAT → net == gross, tax 0', async () => {
|
|
71
|
+
const em = createPrepaidMockEm()
|
|
72
|
+
const deps = makeDeps(null)
|
|
73
|
+
const receipt = await createTopupReceipt(em as never, deps, {
|
|
74
|
+
account: ACCOUNT,
|
|
75
|
+
topupId: 'topup-2',
|
|
76
|
+
paymentId: 'pay-2',
|
|
77
|
+
amount: '50.0000',
|
|
78
|
+
taxRate: 0,
|
|
79
|
+
})
|
|
80
|
+
expect(receipt.netAmount).toBe('50.0000')
|
|
81
|
+
expect(receipt.grossAmount).toBe('50.0000')
|
|
82
|
+
expect(receipt.taxAmount).toBe('0.0000')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -77,8 +77,8 @@ describe('billing setup — contract', () => {
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
describe('billing acl — surface', () => {
|
|
80
|
-
it('exports exactly
|
|
81
|
-
expect(features).toHaveLength(
|
|
80
|
+
it('exports exactly 19 features (12 Phase 0 + 7 SPEC-002 prepaid)', () => {
|
|
81
|
+
expect(features).toHaveLength(19)
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
it('all feature IDs are prefixed `billing.` (no leaking module boundary)', () => {
|
|
@@ -101,7 +101,33 @@ describe('billing acl — surface', () => {
|
|
|
101
101
|
'billing.invoice.post',
|
|
102
102
|
'billing.invoice.edit_draft',
|
|
103
103
|
'billing.invoice.view',
|
|
104
|
+
// SPEC-002 prepaid
|
|
105
|
+
'billing.balance.view',
|
|
106
|
+
'billing.balance.adjust',
|
|
107
|
+
'billing.topup.create',
|
|
108
|
+
'billing.topup.view',
|
|
109
|
+
'billing.statement.view',
|
|
110
|
+
'billing.statement.generate',
|
|
111
|
+
'billing.credit.view',
|
|
104
112
|
])('declares feature %s', (id) => {
|
|
105
113
|
expect(featureIds.has(id)).toBe(true)
|
|
106
114
|
})
|
|
107
115
|
})
|
|
116
|
+
|
|
117
|
+
describe('billing setup — prepaid read grants (SPEC-002)', () => {
|
|
118
|
+
it('billing_finance_user can read prepaid balances, top-ups, statements, credit', () => {
|
|
119
|
+
const grants = setup.defaultRoleFeatures?.billing_finance_user ?? []
|
|
120
|
+
expect(grants).toEqual(
|
|
121
|
+
expect.arrayContaining([
|
|
122
|
+
'billing.balance.view',
|
|
123
|
+
'billing.credit.view',
|
|
124
|
+
'billing.topup.view',
|
|
125
|
+
'billing.statement.view',
|
|
126
|
+
]),
|
|
127
|
+
)
|
|
128
|
+
// …but cannot adjust balances or initiate top-ups (read-only persona)
|
|
129
|
+
expect(grants).not.toContain('billing.balance.adjust')
|
|
130
|
+
expect(grants).not.toContain('billing.topup.create')
|
|
131
|
+
expect(grants).not.toContain('billing.statement.generate')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -11,6 +11,14 @@ export const features = [
|
|
|
11
11
|
{ id: 'billing.invoice.post', title: 'Post Draft Invoice', module: 'billing' },
|
|
12
12
|
{ id: 'billing.invoice.edit_draft', title: 'Edit Draft Invoice', module: 'billing' },
|
|
13
13
|
{ id: 'billing.invoice.view', title: 'View Billing Invoices', module: 'billing' },
|
|
14
|
+
// ─── Prepaid balance billing (SPEC-002) ──────────────────────
|
|
15
|
+
{ id: 'billing.balance.view', title: 'View Balance & Transactions', module: 'billing' },
|
|
16
|
+
{ id: 'billing.balance.adjust', title: 'Adjust Balance (manual credit/debit)', module: 'billing' },
|
|
17
|
+
{ id: 'billing.topup.create', title: 'Initiate Account Top-up', module: 'billing' },
|
|
18
|
+
{ id: 'billing.topup.view', title: 'View Account Top-ups', module: 'billing' },
|
|
19
|
+
{ id: 'billing.statement.view', title: 'View Consumption Statements', module: 'billing' },
|
|
20
|
+
{ id: 'billing.statement.generate', title: 'Generate Consumption Statement', module: 'billing' },
|
|
21
|
+
{ id: 'billing.credit.view', title: 'View Account Credit Status', module: 'billing' },
|
|
14
22
|
] as const
|
|
15
23
|
|
|
16
24
|
export default features
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { BillingAccount } from '../../../../data/entities'
|
|
6
|
+
import { getBalanceSnapshot } from '../../../../lib/accountStatus'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `GET /api/billing/accounts/[id]/balance` — current prepaid balance, status,
|
|
10
|
+
* and resolved low-balance threshold (SPEC-002). Read-only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const metadata = {
|
|
14
|
+
GET: { requireAuth: true, requireFeatures: ['billing.balance.view'] },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function GET(
|
|
18
|
+
req: Request,
|
|
19
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
20
|
+
) {
|
|
21
|
+
let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
|
|
22
|
+
try {
|
|
23
|
+
container = await createRequestContainer()
|
|
24
|
+
const auth = await getAuthFromRequest(req)
|
|
25
|
+
if (!auth || !auth.tenantId) {
|
|
26
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
27
|
+
}
|
|
28
|
+
const organizationId = (auth as { orgId?: string }).orgId ?? null
|
|
29
|
+
if (!organizationId) {
|
|
30
|
+
return NextResponse.json({ error: 'Organization scope required' }, { status: 403 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { id: accountId } = await params
|
|
34
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
35
|
+
const account = await em.findOne(BillingAccount, {
|
|
36
|
+
id: accountId,
|
|
37
|
+
tenantId: auth.tenantId,
|
|
38
|
+
organizationId,
|
|
39
|
+
deletedAt: null,
|
|
40
|
+
})
|
|
41
|
+
if (!account) {
|
|
42
|
+
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
|
43
|
+
}
|
|
44
|
+
if (account.billingMode !== 'prepaid') {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: 'Account is not prepaid', code: 'billing.account.not_prepaid' },
|
|
47
|
+
{ status: 409 },
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const snapshot = await getBalanceSnapshot(em, account)
|
|
52
|
+
return NextResponse.json(snapshot)
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('[billing/accounts/balance.GET]', err)
|
|
55
|
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const openApi = {
|
|
60
|
+
methods: {
|
|
61
|
+
GET: {
|
|
62
|
+
tags: ['Billing'],
|
|
63
|
+
summary: 'Get a prepaid account balance + status',
|
|
64
|
+
parameters: [
|
|
65
|
+
{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
|
|
66
|
+
],
|
|
67
|
+
description:
|
|
68
|
+
'Returns the current running balance, `balanceStatus` (ok/low/exhausted), ' +
|
|
69
|
+
'the resolved low-balance threshold, the currency, and the last-movement ' +
|
|
70
|
+
'timestamp for a prepaid account. 409 (`billing.account.not_prepaid`) for ' +
|
|
71
|
+
'postpaid accounts.',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { BillingAccount } from '../../../../data/entities'
|
|
6
|
+
import { getCreditSnapshot } from '../../../../lib/accountStatus'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `GET /api/billing/accounts/[id]/credit-status` — credit limit / used /
|
|
10
|
+
* available / status for BOTH modes (SPEC-002 P6). Report-only — billing never
|
|
11
|
+
* blocks; the quote→order flow (e.g. a CPQ credit guard) decides what to do.
|
|
12
|
+
*
|
|
13
|
+
* - prepaid: credit_used = max(0, −balance)
|
|
14
|
+
* - postpaid: credit_used = Σ posted-unpaid invoice outstanding (core/sales)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const metadata = {
|
|
18
|
+
GET: { requireAuth: true, requireFeatures: ['billing.credit.view'] },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function GET(
|
|
22
|
+
req: Request,
|
|
23
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
24
|
+
) {
|
|
25
|
+
let container: Awaited<ReturnType<typeof createRequestContainer>> | null = null
|
|
26
|
+
try {
|
|
27
|
+
container = await createRequestContainer()
|
|
28
|
+
const auth = await getAuthFromRequest(req)
|
|
29
|
+
if (!auth || !auth.tenantId) {
|
|
30
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
31
|
+
}
|
|
32
|
+
const organizationId = (auth as { orgId?: string }).orgId ?? null
|
|
33
|
+
if (!organizationId) {
|
|
34
|
+
return NextResponse.json({ error: 'Organization scope required' }, { status: 403 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { id: accountId } = await params
|
|
38
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
39
|
+
const account = await em.findOne(BillingAccount, {
|
|
40
|
+
id: accountId,
|
|
41
|
+
tenantId: auth.tenantId,
|
|
42
|
+
organizationId,
|
|
43
|
+
deletedAt: null,
|
|
44
|
+
})
|
|
45
|
+
if (!account) {
|
|
46
|
+
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const snapshot = await getCreditSnapshot(em, account)
|
|
50
|
+
return NextResponse.json(snapshot)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error('[billing/accounts/credit-status.GET]', err)
|
|
53
|
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const openApi = {
|
|
58
|
+
methods: {
|
|
59
|
+
GET: {
|
|
60
|
+
tags: ['Billing'],
|
|
61
|
+
summary: 'Get an account credit status (both modes)',
|
|
62
|
+
parameters: [
|
|
63
|
+
{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
|
|
64
|
+
],
|
|
65
|
+
description:
|
|
66
|
+
'Returns `{ mode, creditLimit, creditUsed, availableCredit, creditStatus, ' +
|
|
67
|
+
'currencyCode }`. Prepaid creditUsed = max(0,−balance); postpaid = Σ ' +
|
|
68
|
+
'posted-unpaid invoice outstanding. Report-only — billing never blocks.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { POST as topupsPost } from '../../../topups/route'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `POST /api/billing/accounts/{id}/topups` (SPEC-002 P2).
|
|
5
|
+
*
|
|
6
|
+
* Spec-faithful nested path. Injects the path `id` as `billAccountId` into the
|
|
7
|
+
* body and delegates to the flat `POST /api/billing/topups` handler so all the
|
|
8
|
+
* RBAC / command-bus / side-effect plumbing lives in exactly one place.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
POST: { requireAuth: true, requireFeatures: ['billing.topup.create'] },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function POST(
|
|
16
|
+
req: Request,
|
|
17
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
18
|
+
): Promise<Response> {
|
|
19
|
+
const { id } = await params
|
|
20
|
+
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>
|
|
21
|
+
const merged = { ...body, billAccountId: id }
|
|
22
|
+
// Rebuild headers WITHOUT the stale content-length (the body changed); keep
|
|
23
|
+
// auth (cookies / Authorization) intact and force JSON content-type.
|
|
24
|
+
const headers = new Headers(req.headers)
|
|
25
|
+
headers.delete('content-length')
|
|
26
|
+
headers.set('content-type', 'application/json')
|
|
27
|
+
const forwarded = new Request(req.url, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers,
|
|
30
|
+
body: JSON.stringify(merged),
|
|
31
|
+
})
|
|
32
|
+
return topupsPost(forwarded)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const openApi = {
|
|
36
|
+
methods: {
|
|
37
|
+
POST: {
|
|
38
|
+
tags: ['Billing'],
|
|
39
|
+
summary: 'Initiate a prepaid top-up for an account',
|
|
40
|
+
parameters: [
|
|
41
|
+
{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } },
|
|
42
|
+
],
|
|
43
|
+
description:
|
|
44
|
+
'Registers a BillingTopup (pending) and opens a payment session. The ' +
|
|
45
|
+
'path `id` is the prepaid account; the balance is credited on capture. ' +
|
|
46
|
+
'Body: `{ amount, providerKey?, successUrl?, cancelUrl?, sourceRef? }`.',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
@@ -43,6 +43,8 @@ const billingAccountListItemSchema = z.object({
|
|
|
43
43
|
invoice_address: z.record(z.string(), z.unknown()),
|
|
44
44
|
next_bill_date: z.string(),
|
|
45
45
|
last_bill_date: z.string().nullable(),
|
|
46
|
+
billing_mode: z.string(),
|
|
47
|
+
credit_limit: z.string(),
|
|
46
48
|
is_active: z.boolean(),
|
|
47
49
|
organization_id: z.string().uuid(),
|
|
48
50
|
tenant_id: z.string().uuid(),
|
|
@@ -79,6 +81,8 @@ const crud = makeCrudRoute({
|
|
|
79
81
|
'invoice_address',
|
|
80
82
|
'next_bill_date',
|
|
81
83
|
'last_bill_date',
|
|
84
|
+
'billing_mode',
|
|
85
|
+
'credit_limit',
|
|
82
86
|
'is_active',
|
|
83
87
|
'organization_id',
|
|
84
88
|
'tenant_id',
|
|
@@ -107,6 +111,9 @@ const crud = makeCrudRoute({
|
|
|
107
111
|
if (typeof query.billCycle === 'string' && query.billCycle) {
|
|
108
112
|
filters.bill_cycle = { $eq: query.billCycle }
|
|
109
113
|
}
|
|
114
|
+
if (typeof query.billingMode === 'string' && query.billingMode) {
|
|
115
|
+
filters.billing_mode = { $eq: query.billingMode }
|
|
116
|
+
}
|
|
110
117
|
if (typeof query.isActive === 'boolean') {
|
|
111
118
|
filters.is_active = { $eq: query.isActive }
|
|
112
119
|
}
|
|
@@ -4,8 +4,12 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import {
|
|
6
6
|
BillingAccount,
|
|
7
|
+
BillingAccountBalance,
|
|
8
|
+
BillingAccountTransaction,
|
|
7
9
|
BillingAccountUsage,
|
|
8
10
|
BillingItem,
|
|
11
|
+
BillingStatement,
|
|
12
|
+
BillingTopup,
|
|
9
13
|
BillRunOutcome,
|
|
10
14
|
DraftInvoiceEdit,
|
|
11
15
|
} from '../../../../data/entities'
|
|
@@ -83,7 +87,7 @@ export async function GET(
|
|
|
83
87
|
?? defaultSince()
|
|
84
88
|
const until = parseDate(url.searchParams.get('until')) ?? new Date()
|
|
85
89
|
|
|
86
|
-
const [items, usage, outcomes] = await Promise.all([
|
|
90
|
+
const [items, usage, outcomes, balance, transactions, topups, statements] = await Promise.all([
|
|
87
91
|
// Items are bounded by integration volume (typically <100 per
|
|
88
92
|
// account); no time-window needed — they often outlive a single
|
|
89
93
|
// export window and the operator needs to see active items
|
|
@@ -113,6 +117,41 @@ export async function GET(
|
|
|
113
117
|
} as never,
|
|
114
118
|
{ limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
|
|
115
119
|
),
|
|
120
|
+
// SPEC-002 prepaid data — the balance row + the windowed money trail.
|
|
121
|
+
em.findOne(BillingAccountBalance, {
|
|
122
|
+
tenantId,
|
|
123
|
+
billAccountId: account.id,
|
|
124
|
+
} as never),
|
|
125
|
+
em.find(
|
|
126
|
+
BillingAccountTransaction,
|
|
127
|
+
{
|
|
128
|
+
tenantId,
|
|
129
|
+
organizationId,
|
|
130
|
+
billAccountId: account.id,
|
|
131
|
+
createdAt: { $gte: since, $lte: until },
|
|
132
|
+
} as never,
|
|
133
|
+
{ limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
|
|
134
|
+
),
|
|
135
|
+
em.find(
|
|
136
|
+
BillingTopup,
|
|
137
|
+
{
|
|
138
|
+
tenantId,
|
|
139
|
+
organizationId,
|
|
140
|
+
billAccountId: account.id,
|
|
141
|
+
createdAt: { $gte: since, $lte: until },
|
|
142
|
+
} as never,
|
|
143
|
+
{ limit: EXPORT_HARD_LIMIT, orderBy: { createdAt: 'DESC' } },
|
|
144
|
+
),
|
|
145
|
+
em.find(
|
|
146
|
+
BillingStatement,
|
|
147
|
+
{
|
|
148
|
+
tenantId,
|
|
149
|
+
organizationId,
|
|
150
|
+
billAccountId: account.id,
|
|
151
|
+
periodEnd: { $gte: since, $lte: until },
|
|
152
|
+
} as never,
|
|
153
|
+
{ limit: EXPORT_HARD_LIMIT, orderBy: { periodEnd: 'DESC' } },
|
|
154
|
+
),
|
|
116
155
|
])
|
|
117
156
|
|
|
118
157
|
// Invoices created in the window. The `metadata->>'bill_account_id'`
|
|
@@ -150,7 +189,8 @@ export async function GET(
|
|
|
150
189
|
|
|
151
190
|
return NextResponse.json({
|
|
152
191
|
exportedAt: new Date().toISOString(),
|
|
153
|
-
|
|
192
|
+
// v2 adds prepaid balance / transactions / top-ups / statements (SPEC-002).
|
|
193
|
+
schemaVersion: 2,
|
|
154
194
|
tenantId,
|
|
155
195
|
organizationId,
|
|
156
196
|
window: {
|
|
@@ -164,6 +204,11 @@ export async function GET(
|
|
|
164
204
|
runOutcomes: outcomes,
|
|
165
205
|
invoices: invoiceRows,
|
|
166
206
|
draftInvoiceEdits: draftEdits,
|
|
207
|
+
// SPEC-002 prepaid section.
|
|
208
|
+
balance,
|
|
209
|
+
transactions,
|
|
210
|
+
topups,
|
|
211
|
+
statements,
|
|
167
212
|
})
|
|
168
213
|
} catch (err) {
|
|
169
214
|
console.error('[billing/export/account.GET]', err)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
3
|
+
import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import { BillingStatement } from '../../../data/entities'
|
|
6
|
+
import { billingEntityIds } from '../../../data/entityIds'
|
|
7
|
+
import { billingStatementGenerateSchema } from '../../../data/validators'
|
|
8
|
+
import { createBillingCrudOpenApi } from '../../openapi'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `POST /api/billing/statements/generate` (SPEC-002 P3) — manual statement
|
|
12
|
+
* generation for a prepaid account. Reuses the Bill Run engine scoped to the
|
|
13
|
+
* one account (same recurring-charge + anti-duplicate machinery).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const routeMetadata = {
|
|
17
|
+
POST: { requireAuth: true, requireFeatures: ['billing.statement.generate'] },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const metadata = routeMetadata
|
|
21
|
+
|
|
22
|
+
const generateResponseSchema = z.object({
|
|
23
|
+
statementId: z.string().uuid().nullable(),
|
|
24
|
+
billRunId: z.string().uuid(),
|
|
25
|
+
status: z.string(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const rawBodySchema = z.object({}).passthrough()
|
|
29
|
+
|
|
30
|
+
const crud = makeCrudRoute({
|
|
31
|
+
metadata: routeMetadata,
|
|
32
|
+
orm: {
|
|
33
|
+
entity: BillingStatement,
|
|
34
|
+
idField: 'id',
|
|
35
|
+
orgField: 'organizationId',
|
|
36
|
+
tenantField: 'tenantId',
|
|
37
|
+
},
|
|
38
|
+
list: {
|
|
39
|
+
schema: z.object({}).passthrough(),
|
|
40
|
+
entityId: billingEntityIds.statement,
|
|
41
|
+
fields: ['id'],
|
|
42
|
+
buildFilters: async () => ({}),
|
|
43
|
+
},
|
|
44
|
+
actions: {
|
|
45
|
+
create: {
|
|
46
|
+
commandId: 'billing.statements.generate',
|
|
47
|
+
schema: rawBodySchema,
|
|
48
|
+
mapInput: async ({ raw, ctx }) => {
|
|
49
|
+
const { translate } = await resolveTranslations()
|
|
50
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
51
|
+
return billingStatementGenerateSchema.parse(scoped)
|
|
52
|
+
},
|
|
53
|
+
response: ({ result }) => ({
|
|
54
|
+
statementId: result?.statementId ?? null,
|
|
55
|
+
billRunId: result?.billRunId ?? null,
|
|
56
|
+
status: result?.status ?? null,
|
|
57
|
+
}),
|
|
58
|
+
status: 201,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const POST = crud.POST
|
|
64
|
+
|
|
65
|
+
export const openApi = createBillingCrudOpenApi({
|
|
66
|
+
resourceName: 'BillingStatementGenerate',
|
|
67
|
+
pluralName: 'BillingStatementGenerations',
|
|
68
|
+
querySchema: z.object({}).passthrough(),
|
|
69
|
+
listResponseSchema: z.object({ items: z.array(z.unknown()) }),
|
|
70
|
+
create: {
|
|
71
|
+
schema: billingStatementGenerateSchema,
|
|
72
|
+
responseSchema: generateResponseSchema,
|
|
73
|
+
description:
|
|
74
|
+
'Generates a consumption statement for a prepaid account by running the ' +
|
|
75
|
+
'period close (recurring charges → balance + statement) scoped to that ' +
|
|
76
|
+
'account. Returns the statement id (null when the account is not due).',
|
|
77
|
+
},
|
|
78
|
+
})
|