@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,153 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
SalesInvoice,
|
|
4
|
+
SalesInvoiceLine,
|
|
5
|
+
} from '@open-mercato/core/modules/sales/data/entities'
|
|
6
|
+
import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
|
|
7
|
+
import type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'
|
|
8
|
+
import { resolveInvoiceStatusEntryId } from './invoiceStatus'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates the fiscal VAT receipt for a captured prepaid top-up (SPEC-002 P2).
|
|
12
|
+
*
|
|
13
|
+
* Deliberate divergence from the postpaid draft→post gate: a captured top-up
|
|
14
|
+
* is a completed, already-paid transaction with nothing to review, so the
|
|
15
|
+
* receipt is created **posted + paid** in one shot (the gate exists for
|
|
16
|
+
* outbound bills, not money already received — mirrors the xd-249 "deviations"
|
|
17
|
+
* convention).
|
|
18
|
+
*
|
|
19
|
+
* **VAT at top-up.** The top-up `amount` is the GROSS the customer paid (= the
|
|
20
|
+
* balance credited). The core tax service splits it into net + VAT at the
|
|
21
|
+
* configured rate. Gap-free numbering reuses `salesDocumentNumberGenerator`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type TopupReceiptParams = {
|
|
25
|
+
account: {
|
|
26
|
+
id: string
|
|
27
|
+
organizationId: string
|
|
28
|
+
tenantId: string
|
|
29
|
+
currencyCode: string
|
|
30
|
+
}
|
|
31
|
+
topupId: string
|
|
32
|
+
paymentId: string
|
|
33
|
+
/** Gross amount paid (numeric(18,4) string). */
|
|
34
|
+
amount: string
|
|
35
|
+
/** VAT percent (e.g. 23). 0 = no VAT. */
|
|
36
|
+
taxRate: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TopupReceiptDeps = {
|
|
40
|
+
numberGenerator: Pick<SalesDocumentNumberGenerator, 'generate'>
|
|
41
|
+
taxService: TaxCalculationService
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TopupReceipt = {
|
|
45
|
+
invoiceId: string
|
|
46
|
+
invoiceNumber: string
|
|
47
|
+
netAmount: string
|
|
48
|
+
taxAmount: string
|
|
49
|
+
grossAmount: string
|
|
50
|
+
taxRate: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fmt4(value: number): string {
|
|
54
|
+
return (Number.isFinite(value) ? value : 0).toFixed(4)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function createTopupReceipt(
|
|
58
|
+
em: EntityManager,
|
|
59
|
+
deps: TopupReceiptDeps,
|
|
60
|
+
params: TopupReceiptParams,
|
|
61
|
+
): Promise<TopupReceipt> {
|
|
62
|
+
const { account } = params
|
|
63
|
+
|
|
64
|
+
// VAT split — the top-up amount is GROSS (what was paid = balance credited).
|
|
65
|
+
const tax = await deps.taxService.calculateUnitAmounts({
|
|
66
|
+
amount: Number.parseFloat(params.amount),
|
|
67
|
+
mode: 'gross',
|
|
68
|
+
organizationId: account.organizationId,
|
|
69
|
+
tenantId: account.tenantId,
|
|
70
|
+
taxRate: params.taxRate,
|
|
71
|
+
})
|
|
72
|
+
const net = fmt4(tax.netAmount)
|
|
73
|
+
const gross = fmt4(tax.grossAmount)
|
|
74
|
+
const taxAmount = fmt4(tax.taxAmount)
|
|
75
|
+
const effectiveRate = tax.taxRate ?? 0
|
|
76
|
+
|
|
77
|
+
const generated = await deps.numberGenerator.generate({
|
|
78
|
+
kind: 'invoice',
|
|
79
|
+
organizationId: account.organizationId,
|
|
80
|
+
tenantId: account.tenantId,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Posted+paid in one shot — resolve the `paid` status entry directly.
|
|
84
|
+
const paidStatusEntryId = await resolveInvoiceStatusEntryId(
|
|
85
|
+
em,
|
|
86
|
+
account.tenantId,
|
|
87
|
+
account.organizationId,
|
|
88
|
+
'paid',
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const now = new Date()
|
|
92
|
+
const invoice = em.create(SalesInvoice, {
|
|
93
|
+
organizationId: account.organizationId,
|
|
94
|
+
tenantId: account.tenantId,
|
|
95
|
+
invoiceNumber: generated.number,
|
|
96
|
+
statusEntryId: paidStatusEntryId,
|
|
97
|
+
status: 'paid',
|
|
98
|
+
issueDate: now,
|
|
99
|
+
currencyCode: account.currencyCode,
|
|
100
|
+
subtotalNetAmount: net,
|
|
101
|
+
subtotalGrossAmount: gross,
|
|
102
|
+
discountTotalAmount: '0',
|
|
103
|
+
taxTotalAmount: taxAmount,
|
|
104
|
+
grandTotalNetAmount: net,
|
|
105
|
+
grandTotalGrossAmount: gross,
|
|
106
|
+
paidTotalAmount: gross,
|
|
107
|
+
outstandingAmount: '0',
|
|
108
|
+
metadata: {
|
|
109
|
+
kind: 'prepaid_topup',
|
|
110
|
+
bill_account_id: account.id,
|
|
111
|
+
topup_id: params.topupId,
|
|
112
|
+
payment_id: params.paymentId,
|
|
113
|
+
},
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
})
|
|
117
|
+
em.persist(invoice)
|
|
118
|
+
|
|
119
|
+
const line = em.create(SalesInvoiceLine, {
|
|
120
|
+
invoice,
|
|
121
|
+
organizationId: account.organizationId,
|
|
122
|
+
tenantId: account.tenantId,
|
|
123
|
+
lineNumber: 1,
|
|
124
|
+
kind: 'service',
|
|
125
|
+
description: 'Account top-up',
|
|
126
|
+
quantity: '1',
|
|
127
|
+
normalizedQuantity: '1',
|
|
128
|
+
currencyCode: account.currencyCode,
|
|
129
|
+
unitPriceNet: net,
|
|
130
|
+
unitPriceGross: gross,
|
|
131
|
+
discountAmount: '0',
|
|
132
|
+
discountPercent: '0',
|
|
133
|
+
taxRate: fmt4(effectiveRate),
|
|
134
|
+
taxAmount,
|
|
135
|
+
totalNetAmount: net,
|
|
136
|
+
totalGrossAmount: gross,
|
|
137
|
+
metadata: {
|
|
138
|
+
kind: 'prepaid_topup',
|
|
139
|
+
topup_id: params.topupId,
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
em.persist(line)
|
|
143
|
+
await em.flush()
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
invoiceId: invoice.id,
|
|
147
|
+
invoiceNumber: generated.number,
|
|
148
|
+
netAmount: net,
|
|
149
|
+
taxAmount,
|
|
150
|
+
grossAmount: gross,
|
|
151
|
+
taxRate: effectiveRate,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { BillingTopup } from '../data/entities'
|
|
3
|
+
import type { TopupStatus } from '../data/validators'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transition a registered top-up to a terminal non-captured status
|
|
7
|
+
* (failed / cancelled / expired) in response to the sibling gateway events.
|
|
8
|
+
*
|
|
9
|
+
* Finding a BillingTopup by `payment_id` IS the guard: a non-top-up payment
|
|
10
|
+
* (e.g. a postpaid invoice, whose paymentId is the invoice id) matches no
|
|
11
|
+
* BillingTopup, so this is a silent no-op for everyone else. A captured
|
|
12
|
+
* top-up is never downgraded.
|
|
13
|
+
*/
|
|
14
|
+
export async function transitionTopupByPaymentId(
|
|
15
|
+
em: EntityManager,
|
|
16
|
+
scope: { paymentId: string; tenantId: string; organizationId: string },
|
|
17
|
+
toStatus: Extract<TopupStatus, 'failed' | 'cancelled' | 'expired'>,
|
|
18
|
+
): Promise<BillingTopup | null> {
|
|
19
|
+
const topup = await em.findOne(BillingTopup, {
|
|
20
|
+
paymentId: scope.paymentId,
|
|
21
|
+
tenantId: scope.tenantId,
|
|
22
|
+
organizationId: scope.organizationId,
|
|
23
|
+
deletedAt: null,
|
|
24
|
+
})
|
|
25
|
+
if (!topup) return null
|
|
26
|
+
if (topup.status === 'captured' || topup.status === toStatus) return topup
|
|
27
|
+
topup.status = toStatus
|
|
28
|
+
topup.updatedAt = new Date()
|
|
29
|
+
await em.flush()
|
|
30
|
+
return topup
|
|
31
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prepaid balance billing schema (SPEC-002).
|
|
5
|
+
*
|
|
6
|
+
* Additive only — extends `billing_accounts` + `billing_run_outcomes` and
|
|
7
|
+
* adds the four new prepaid tables. Postpaid accounts are unaffected
|
|
8
|
+
* (`billing_mode` defaults to 'postpaid', `credit_limit` to 0).
|
|
9
|
+
*
|
|
10
|
+
* Hand-written to match `data/entities.ts` 1:1 (index/constraint names mirror
|
|
11
|
+
* the decorator names so a later `yarn mercato db generate` diffs clean). The
|
|
12
|
+
* statements here are pure `create table` / `alter table … add column` —
|
|
13
|
+
* never destructive to existing billing data.
|
|
14
|
+
*/
|
|
15
|
+
export class Migration20260619000000_billing_prepaid extends Migration {
|
|
16
|
+
async up(): Promise<void> {
|
|
17
|
+
// ─── billing_accounts: billing_mode + credit_limit ───────
|
|
18
|
+
this.addSql(
|
|
19
|
+
`alter table "billing_accounts" add column "billing_mode" text not null default 'postpaid';`,
|
|
20
|
+
)
|
|
21
|
+
this.addSql(
|
|
22
|
+
`alter table "billing_accounts" add column "credit_limit" numeric(18, 4) not null default 0;`,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// ─── billing_run_outcomes: statement_id (prepaid analogue) ─
|
|
26
|
+
this.addSql(`alter table "billing_run_outcomes" add column "statement_id" uuid null;`)
|
|
27
|
+
|
|
28
|
+
// ─── billing_account_balances ────────────────────────────
|
|
29
|
+
this.addSql(`create table "billing_account_balances" (
|
|
30
|
+
"id" uuid not null default gen_random_uuid(),
|
|
31
|
+
"organization_id" uuid not null,
|
|
32
|
+
"tenant_id" uuid not null,
|
|
33
|
+
"bill_account_id" uuid not null,
|
|
34
|
+
"currency_code" text not null,
|
|
35
|
+
"balance" numeric(18, 4) not null default 0,
|
|
36
|
+
"low_balance_threshold" numeric(18, 4) null,
|
|
37
|
+
"last_movement_at" timestamptz null,
|
|
38
|
+
"created_at" timestamptz not null,
|
|
39
|
+
"updated_at" timestamptz not null,
|
|
40
|
+
constraint "billing_account_balances_pkey" primary key ("id")
|
|
41
|
+
);`)
|
|
42
|
+
this.addSql(`alter table "billing_account_balances" add constraint "billing_account_balances_account_unique" unique ("tenant_id", "bill_account_id");`)
|
|
43
|
+
this.addSql(`create index "billing_account_balances_balance_idx" on "billing_account_balances" ("organization_id", "tenant_id", "balance");`)
|
|
44
|
+
|
|
45
|
+
// ─── billing_account_transactions ────────────────────────
|
|
46
|
+
this.addSql(`create table "billing_account_transactions" (
|
|
47
|
+
"id" uuid not null default gen_random_uuid(),
|
|
48
|
+
"organization_id" uuid not null,
|
|
49
|
+
"tenant_id" uuid not null,
|
|
50
|
+
"bill_account_id" uuid not null,
|
|
51
|
+
"type" text not null,
|
|
52
|
+
"amount" numeric(18, 4) not null,
|
|
53
|
+
"currency_code" text not null,
|
|
54
|
+
"balance_after" numeric(18, 4) not null,
|
|
55
|
+
"usage_id" uuid null,
|
|
56
|
+
"billing_item_id" uuid null,
|
|
57
|
+
"topup_id" uuid null,
|
|
58
|
+
"statement_id" uuid null,
|
|
59
|
+
"description" text not null,
|
|
60
|
+
"source_ref" text null,
|
|
61
|
+
"metadata" jsonb null,
|
|
62
|
+
"user_id" uuid null,
|
|
63
|
+
"created_at" timestamptz not null,
|
|
64
|
+
"deleted_at" timestamptz null,
|
|
65
|
+
constraint "billing_account_transactions_pkey" primary key ("id")
|
|
66
|
+
);`)
|
|
67
|
+
this.addSql(`alter table "billing_account_transactions" add constraint "billing_account_transactions_source_ref_unique" unique ("tenant_id", "bill_account_id", "source_ref");`)
|
|
68
|
+
this.addSql(`create index "billing_account_transactions_history_idx" on "billing_account_transactions" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
|
|
69
|
+
this.addSql(`create index "billing_account_transactions_account_type_idx" on "billing_account_transactions" ("bill_account_id", "type", "created_at");`)
|
|
70
|
+
|
|
71
|
+
// ─── billing_topups ──────────────────────────────────────
|
|
72
|
+
this.addSql(`create table "billing_topups" (
|
|
73
|
+
"id" uuid not null default gen_random_uuid(),
|
|
74
|
+
"organization_id" uuid not null,
|
|
75
|
+
"tenant_id" uuid not null,
|
|
76
|
+
"bill_account_id" uuid not null,
|
|
77
|
+
"status" text not null,
|
|
78
|
+
"amount" numeric(18, 4) not null,
|
|
79
|
+
"currency_code" text not null,
|
|
80
|
+
"provider_key" text not null,
|
|
81
|
+
"payment_id" uuid not null,
|
|
82
|
+
"gateway_transaction_id" uuid null,
|
|
83
|
+
"transaction_id" uuid null,
|
|
84
|
+
"receipt_invoice_id" uuid null,
|
|
85
|
+
"source_ref" text null,
|
|
86
|
+
"metadata" jsonb null,
|
|
87
|
+
"created_at" timestamptz not null,
|
|
88
|
+
"updated_at" timestamptz not null,
|
|
89
|
+
"deleted_at" timestamptz null,
|
|
90
|
+
constraint "billing_topups_pkey" primary key ("id")
|
|
91
|
+
);`)
|
|
92
|
+
this.addSql(`alter table "billing_topups" add constraint "billing_topups_payment_id_unique" unique ("tenant_id", "payment_id");`)
|
|
93
|
+
this.addSql(`create index "billing_topups_account_history_idx" on "billing_topups" ("organization_id", "tenant_id", "bill_account_id", "created_at");`)
|
|
94
|
+
this.addSql(`create index "billing_topups_status_idx" on "billing_topups" ("tenant_id", "status");`)
|
|
95
|
+
|
|
96
|
+
// ─── billing_statements ──────────────────────────────────
|
|
97
|
+
this.addSql(`create table "billing_statements" (
|
|
98
|
+
"id" uuid not null default gen_random_uuid(),
|
|
99
|
+
"organization_id" uuid not null,
|
|
100
|
+
"tenant_id" uuid not null,
|
|
101
|
+
"bill_account_id" uuid not null,
|
|
102
|
+
"bill_run_id" uuid null,
|
|
103
|
+
"period_start" date not null,
|
|
104
|
+
"period_end" date not null,
|
|
105
|
+
"currency_code" text not null,
|
|
106
|
+
"opening_balance" numeric(18, 4) not null,
|
|
107
|
+
"total_topups" numeric(18, 4) not null,
|
|
108
|
+
"total_usage" numeric(18, 4) not null,
|
|
109
|
+
"total_recurring" numeric(18, 4) not null,
|
|
110
|
+
"total_adjustments" numeric(18, 4) not null default 0,
|
|
111
|
+
"closing_balance" numeric(18, 4) not null,
|
|
112
|
+
"debit_breakdown" jsonb not null,
|
|
113
|
+
"status" text not null,
|
|
114
|
+
"created_at" timestamptz not null,
|
|
115
|
+
"updated_at" timestamptz not null,
|
|
116
|
+
constraint "billing_statements_pkey" primary key ("id")
|
|
117
|
+
);`)
|
|
118
|
+
this.addSql(`alter table "billing_statements" add constraint "billing_statements_period_unique" unique ("tenant_id", "bill_account_id", "period_start", "period_end");`)
|
|
119
|
+
this.addSql(`create index "billing_statements_account_period_idx" on "billing_statements" ("organization_id", "tenant_id", "bill_account_id", "period_end");`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async down(): Promise<void> {
|
|
123
|
+
this.addSql(`drop table if exists "billing_statements" cascade;`)
|
|
124
|
+
this.addSql(`drop table if exists "billing_topups" cascade;`)
|
|
125
|
+
this.addSql(`drop table if exists "billing_account_transactions" cascade;`)
|
|
126
|
+
this.addSql(`drop table if exists "billing_account_balances" cascade;`)
|
|
127
|
+
this.addSql(`alter table "billing_run_outcomes" drop column if exists "statement_id";`)
|
|
128
|
+
this.addSql(`alter table "billing_accounts" drop column if exists "credit_limit";`)
|
|
129
|
+
this.addSql(`alter table "billing_accounts" drop column if exists "billing_mode";`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -200,6 +200,45 @@ async function seedBillingConfigs(em: EntityManager): Promise<void> {
|
|
|
200
200
|
name: 'invoice_number.reset_cycle',
|
|
201
201
|
value: 'yearly',
|
|
202
202
|
})
|
|
203
|
+
|
|
204
|
+
// ─── Prepaid balance billing (SPEC-002) ──────────────────────
|
|
205
|
+
// Fallback low-balance threshold when a prepaid account leaves its own
|
|
206
|
+
// `low_balance_threshold` null. 0 = off (status never flips to `low`
|
|
207
|
+
// until an operator sets a per-account threshold).
|
|
208
|
+
await ensureModuleConfig(em, {
|
|
209
|
+
moduleId: 'billing',
|
|
210
|
+
name: 'prepaid.low_balance_threshold_default',
|
|
211
|
+
value: 0,
|
|
212
|
+
})
|
|
213
|
+
// Default gateway provider key for top-up sessions.
|
|
214
|
+
await ensureModuleConfig(em, {
|
|
215
|
+
moduleId: 'billing',
|
|
216
|
+
name: 'prepaid.topup_provider',
|
|
217
|
+
value: 'stripe',
|
|
218
|
+
})
|
|
219
|
+
// Master switch for period statements.
|
|
220
|
+
await ensureModuleConfig(em, {
|
|
221
|
+
moduleId: 'billing',
|
|
222
|
+
name: 'prepaid.statement_enabled',
|
|
223
|
+
value: true,
|
|
224
|
+
})
|
|
225
|
+
// VAT rate (percent) applied to top-up receipts. The top-up `amount` is
|
|
226
|
+
// treated as GROSS (what the customer pays = what the balance is credited);
|
|
227
|
+
// the core tax service computes net + VAT from this rate. 0 = no VAT until
|
|
228
|
+
// an operator sets the jurisdiction's rate. (Deviation from xd-249, which
|
|
229
|
+
// never resolves a rate — see CHANGELOG.)
|
|
230
|
+
await ensureModuleConfig(em, {
|
|
231
|
+
moduleId: 'billing',
|
|
232
|
+
name: 'prepaid.topup_tax_rate',
|
|
233
|
+
value: 0,
|
|
234
|
+
})
|
|
235
|
+
// Fallback buffer for the `near_limit` credit status. 0 = warn only at the
|
|
236
|
+
// limit itself.
|
|
237
|
+
await ensureModuleConfig(em, {
|
|
238
|
+
moduleId: 'billing',
|
|
239
|
+
name: 'credit.near_limit_buffer_default',
|
|
240
|
+
value: 0,
|
|
241
|
+
})
|
|
203
242
|
}
|
|
204
243
|
|
|
205
244
|
// Derived from `acl.ts` so a new feature added there automatically lands
|
|
@@ -224,6 +263,12 @@ export const setup: ModuleSetupConfig = {
|
|
|
224
263
|
'billing.run.view',
|
|
225
264
|
'billing.run.dry_run',
|
|
226
265
|
'billing.invoice.view',
|
|
266
|
+
// Prepaid read-only (SPEC-002): balances, transactions, top-ups,
|
|
267
|
+
// statements, credit status — no adjust / no top-up initiation.
|
|
268
|
+
'billing.balance.view',
|
|
269
|
+
'billing.credit.view',
|
|
270
|
+
'billing.topup.view',
|
|
271
|
+
'billing.statement.view',
|
|
227
272
|
],
|
|
228
273
|
|
|
229
274
|
// Narrow scope for external usage-ingestion API keys.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { transitionTopupByPaymentId } from '../lib/topupTransition'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscriber: `payment_gateways.payment.cancelled` → BillingTopup → cancelled.
|
|
7
|
+
* No-op for any non-top-up payment. No balance movement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const metadata = {
|
|
11
|
+
event: 'payment_gateways.payment.cancelled',
|
|
12
|
+
persistent: true,
|
|
13
|
+
id: 'billing-prepaid-topup-cancelled',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
|
|
17
|
+
|
|
18
|
+
export default async function handle(payload: Payload): Promise<void> {
|
|
19
|
+
if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
|
|
20
|
+
const container = await createRequestContainer()
|
|
21
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
22
|
+
await transitionTopupByPaymentId(
|
|
23
|
+
em,
|
|
24
|
+
{
|
|
25
|
+
paymentId: payload.paymentId,
|
|
26
|
+
tenantId: payload.tenantId,
|
|
27
|
+
organizationId: payload.organizationId,
|
|
28
|
+
},
|
|
29
|
+
'cancelled',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
|
|
4
|
+
import type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'
|
|
5
|
+
import { BillingAccount, BillingTopup } from '../data/entities'
|
|
6
|
+
import { emitBillingEvent } from '../events'
|
|
7
|
+
import { captureTopup } from '../lib/topupCapture'
|
|
8
|
+
import { getTopupTaxRate } from '../lib/prepaidConfig'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Subscriber: `payment_gateways.payment.captured` → prepaid top-up captured.
|
|
12
|
+
*
|
|
13
|
+
* Coexists with `payment-captured-to-paid.ts`: that subscriber matches
|
|
14
|
+
* `paymentId == invoiceId` (postpaid), while a top-up's `paymentId` is a
|
|
15
|
+
* minted UUID that only ever matches a `BillingTopup`. Finding the
|
|
16
|
+
* BillingTopup IS the guard — for any non-top-up payment this is a silent
|
|
17
|
+
* no-op.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent: a re-fired capture on an already-captured top-up is a no-op
|
|
20
|
+
* (the gateway delivers webhooks at-least-once).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const metadata = {
|
|
24
|
+
event: 'payment_gateways.payment.captured',
|
|
25
|
+
persistent: true,
|
|
26
|
+
id: 'billing-prepaid-topup-captured',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type CapturedPayload = {
|
|
30
|
+
paymentId?: string
|
|
31
|
+
transactionId?: string
|
|
32
|
+
organizationId?: string
|
|
33
|
+
tenantId?: string
|
|
34
|
+
providerKey?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default async function handle(payload: CapturedPayload): Promise<void> {
|
|
38
|
+
if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const container = await createRequestContainer()
|
|
43
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
44
|
+
|
|
45
|
+
const topup = await em.findOne(BillingTopup, {
|
|
46
|
+
paymentId: payload.paymentId,
|
|
47
|
+
tenantId: payload.tenantId,
|
|
48
|
+
organizationId: payload.organizationId,
|
|
49
|
+
deletedAt: null,
|
|
50
|
+
})
|
|
51
|
+
if (!topup) {
|
|
52
|
+
// Not a prepaid top-up — other subscribers own this captured payment.
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (topup.status === 'captured') {
|
|
56
|
+
return // idempotent re-fire
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const account = await em.findOne(BillingAccount, {
|
|
60
|
+
id: topup.billAccountId,
|
|
61
|
+
tenantId: payload.tenantId,
|
|
62
|
+
organizationId: payload.organizationId,
|
|
63
|
+
deletedAt: null,
|
|
64
|
+
})
|
|
65
|
+
if (!account) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const taxRate = await getTopupTaxRate(em)
|
|
70
|
+
const numberGenerator = container.resolve(
|
|
71
|
+
'salesDocumentNumberGenerator',
|
|
72
|
+
) as SalesDocumentNumberGenerator
|
|
73
|
+
const taxService = container.resolve('taxCalculationService') as TaxCalculationService
|
|
74
|
+
|
|
75
|
+
const result = await captureTopup(
|
|
76
|
+
em,
|
|
77
|
+
{ numberGenerator, taxService },
|
|
78
|
+
{
|
|
79
|
+
topup,
|
|
80
|
+
account: {
|
|
81
|
+
id: account.id,
|
|
82
|
+
organizationId: account.organizationId,
|
|
83
|
+
tenantId: account.tenantId,
|
|
84
|
+
currencyCode: account.currencyCode,
|
|
85
|
+
},
|
|
86
|
+
taxRate,
|
|
87
|
+
gatewayTransactionId: payload.transactionId ?? null,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (result.status !== 'captured') return
|
|
92
|
+
|
|
93
|
+
// Side effects fire AFTER the capture transaction commits.
|
|
94
|
+
await emitBillingEvent('billing.balance.topped_up', {
|
|
95
|
+
billAccountId: account.id,
|
|
96
|
+
tenantId: account.tenantId,
|
|
97
|
+
organizationId: account.organizationId,
|
|
98
|
+
topupId: topup.id,
|
|
99
|
+
amount: topup.amount,
|
|
100
|
+
currencyCode: account.currencyCode,
|
|
101
|
+
balance: result.balanceAfter ?? null,
|
|
102
|
+
transactionId: result.transactionId ?? null,
|
|
103
|
+
receiptInvoiceId: result.receiptInvoiceId ?? null,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Reuse the postpaid receipt path so the existing mailer / accounting bridge
|
|
107
|
+
// sends the top-up VAT receipt.
|
|
108
|
+
if (result.receiptInvoiceId) {
|
|
109
|
+
await emitBillingEvent('billing.invoice.posted', {
|
|
110
|
+
invoiceId: result.receiptInvoiceId,
|
|
111
|
+
invoiceNumber: result.invoiceNumber ?? null,
|
|
112
|
+
tenantId: account.tenantId,
|
|
113
|
+
organizationId: account.organizationId,
|
|
114
|
+
grandTotalGross: result.grossAmount ?? null,
|
|
115
|
+
currencyCode: account.currencyCode,
|
|
116
|
+
billAccountId: account.id,
|
|
117
|
+
kind: 'prepaid_topup',
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { transitionTopupByPaymentId } from '../lib/topupTransition'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscriber: `payment_gateways.session.expired` → BillingTopup → expired.
|
|
7
|
+
* No-op for any non-top-up session. No balance movement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const metadata = {
|
|
11
|
+
event: 'payment_gateways.session.expired',
|
|
12
|
+
persistent: true,
|
|
13
|
+
id: 'billing-prepaid-topup-expired',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
|
|
17
|
+
|
|
18
|
+
export default async function handle(payload: Payload): Promise<void> {
|
|
19
|
+
if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
|
|
20
|
+
const container = await createRequestContainer()
|
|
21
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
22
|
+
await transitionTopupByPaymentId(
|
|
23
|
+
em,
|
|
24
|
+
{
|
|
25
|
+
paymentId: payload.paymentId,
|
|
26
|
+
tenantId: payload.tenantId,
|
|
27
|
+
organizationId: payload.organizationId,
|
|
28
|
+
},
|
|
29
|
+
'expired',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { transitionTopupByPaymentId } from '../lib/topupTransition'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscriber: `payment_gateways.payment.failed` → BillingTopup → failed.
|
|
7
|
+
* No-op for any non-top-up payment (no matching BillingTopup). No balance
|
|
8
|
+
* movement.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
event: 'payment_gateways.payment.failed',
|
|
13
|
+
persistent: true,
|
|
14
|
+
id: 'billing-prepaid-topup-failed',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Payload = { paymentId?: string; tenantId?: string; organizationId?: string }
|
|
18
|
+
|
|
19
|
+
export default async function handle(payload: Payload): Promise<void> {
|
|
20
|
+
if (!payload?.paymentId || !payload.tenantId || !payload.organizationId) return
|
|
21
|
+
const container = await createRequestContainer()
|
|
22
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
23
|
+
await transitionTopupByPaymentId(
|
|
24
|
+
em,
|
|
25
|
+
{
|
|
26
|
+
paymentId: payload.paymentId,
|
|
27
|
+
tenantId: payload.tenantId,
|
|
28
|
+
organizationId: payload.organizationId,
|
|
29
|
+
},
|
|
30
|
+
'failed',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { BillingAccountBalance } from '../data/entities'
|
|
4
|
+
import { reconcileAccountBalance } from '../lib/balanceLedger'
|
|
5
|
+
|
|
6
|
+
export const metadata: WorkerMeta = {
|
|
7
|
+
queue: 'billing-reconcile-prepaid-balances',
|
|
8
|
+
id: 'billing:reconcile-prepaid-balances',
|
|
9
|
+
concurrency: 1,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type HandlerContext = JobContext & {
|
|
13
|
+
resolve: <T = unknown>(name: string) => T
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reconciliation guard (SPEC-002 P5, optional).
|
|
18
|
+
*
|
|
19
|
+
* Periodically asserts the ledger invariant per prepaid account:
|
|
20
|
+
* `cachedBalance == SUM(transactions.amount) == latest.balance_after`.
|
|
21
|
+
*
|
|
22
|
+
* The atomic `UPDATE … RETURNING` + paired transaction insert keeps this true
|
|
23
|
+
* by construction, so a healthy system always reconciles. This worker is a
|
|
24
|
+
* cheap backstop against any future non-atomic write slipping in — it logs a
|
|
25
|
+
* warning per drifted account (the operator-facing alert) and never mutates
|
|
26
|
+
* the balance (a fix is a deliberate operator action, not an automatic one).
|
|
27
|
+
*/
|
|
28
|
+
const SCAN_LIMIT = 10_000
|
|
29
|
+
|
|
30
|
+
export default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {
|
|
31
|
+
const em = ctx.resolve<EntityManager>('em')
|
|
32
|
+
const rows = await em.find(
|
|
33
|
+
BillingAccountBalance,
|
|
34
|
+
{} as never,
|
|
35
|
+
{ limit: SCAN_LIMIT, orderBy: { updatedAt: 'DESC' } },
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
let checked = 0
|
|
39
|
+
let drifted = 0
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
const result = await reconcileAccountBalance(em.fork(), {
|
|
42
|
+
tenantId: row.tenantId,
|
|
43
|
+
organizationId: row.organizationId,
|
|
44
|
+
billAccountId: row.billAccountId,
|
|
45
|
+
})
|
|
46
|
+
checked += 1
|
|
47
|
+
if (!result.reconciled) {
|
|
48
|
+
drifted += 1
|
|
49
|
+
console.warn('[billing/reconcile] balance drift detected', {
|
|
50
|
+
billAccountId: row.billAccountId,
|
|
51
|
+
tenantId: row.tenantId,
|
|
52
|
+
organizationId: row.organizationId,
|
|
53
|
+
cachedBalance: result.cachedBalance,
|
|
54
|
+
sumOfAmounts: result.sumOfAmounts,
|
|
55
|
+
latestBalanceAfter: result.latestBalanceAfter,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (rows.length === SCAN_LIMIT) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[billing/reconcile] scan hit the ${SCAN_LIMIT}-row cap — some accounts were not checked this run`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
if (drifted > 0) {
|
|
66
|
+
console.warn(`[billing/reconcile] ${drifted}/${checked} prepaid accounts drifted`)
|
|
67
|
+
}
|
|
68
|
+
}
|