@dainamite/billing 0.17.0 → 0.19.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 +99 -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 +9 -9
- package/src/modules/billing/__tests__/commands/invoices.test.ts +3 -3
- package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
- package/src/modules/billing/__tests__/commands/transactions.test.ts +107 -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
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from '@open-mercato/shared/lib/commands/scope'
|
|
9
9
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
10
|
import { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
11
|
-
import { DraftInvoiceEdit } from '../data/entities'
|
|
12
|
-
import { emitBillingEvent } from '../events'
|
|
11
|
+
import { BillingAccount, DraftInvoiceEdit } from '../data/entities'
|
|
12
|
+
import { emitBillingEvent, type BillingEventId } from '../events'
|
|
13
|
+
import { buildPostpaidCreditCrossingEvents } from '../lib/accountStatus'
|
|
13
14
|
import {
|
|
14
15
|
billingInvoiceAddLineSchema,
|
|
15
16
|
billingInvoiceEditLineSchema,
|
|
@@ -144,6 +145,29 @@ const postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> =
|
|
|
144
145
|
})
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
// SPEC-002 P6: a newly-posted invoice raises the account's outstanding
|
|
149
|
+
// exposure — emit billing.credit.over_limit on the upward crossing. Billing
|
|
150
|
+
// never blocks; the consuming flow decides what to do.
|
|
151
|
+
const billAccountId = (meta as { bill_account_id?: string }).bill_account_id
|
|
152
|
+
if (billAccountId) {
|
|
153
|
+
const account = await em.findOne(BillingAccount, {
|
|
154
|
+
id: billAccountId,
|
|
155
|
+
tenantId: parsed.tenantId,
|
|
156
|
+
organizationId: parsed.organizationId,
|
|
157
|
+
deletedAt: null,
|
|
158
|
+
})
|
|
159
|
+
if (account) {
|
|
160
|
+
const creditEvents = await buildPostpaidCreditCrossingEvents(
|
|
161
|
+
em,
|
|
162
|
+
account,
|
|
163
|
+
invoice.outstandingAmount,
|
|
164
|
+
)
|
|
165
|
+
for (const ev of creditEvents) {
|
|
166
|
+
await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
147
171
|
return {
|
|
148
172
|
invoiceId: invoice.id,
|
|
149
173
|
invoiceNumber: invoice.invoiceNumber,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
3
|
+
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
4
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
5
|
+
import {
|
|
6
|
+
ensureOrganizationScope,
|
|
7
|
+
ensureTenantScope,
|
|
8
|
+
} from '@open-mercato/shared/lib/commands/scope'
|
|
9
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import { BillingAccount } from '../data/entities'
|
|
11
|
+
import {
|
|
12
|
+
billingStatementGenerateSchema,
|
|
13
|
+
type BillingStatementGenerateInput,
|
|
14
|
+
} from '../data/validators'
|
|
15
|
+
import { runBillRun } from '../lib/billRunEngine'
|
|
16
|
+
import { TenantLockBusyError } from '../lib/tenantLock'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manual statement generation (SPEC-002 P3).
|
|
20
|
+
*
|
|
21
|
+
* Reuses the Bill Run engine scoped to a single prepaid account — so the same
|
|
22
|
+
* recurring-charge + statement + anti-duplicate machinery runs, with the same
|
|
23
|
+
* tenant lock. If the account is due (`next_bill_date <= asOfDate`) it produces
|
|
24
|
+
* one statement (and advances the cycle); otherwise nothing is generated.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
28
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getContainer(ctx: CommandRuntimeContext): AwilixContainer {
|
|
32
|
+
return ctx.container as AwilixContainer
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type GenerateStatementResult = {
|
|
36
|
+
statementId: string | null
|
|
37
|
+
billRunId: string
|
|
38
|
+
status: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const generateStatementCommand: CommandHandler<
|
|
42
|
+
BillingStatementGenerateInput,
|
|
43
|
+
GenerateStatementResult
|
|
44
|
+
> = {
|
|
45
|
+
id: 'billing.statements.generate',
|
|
46
|
+
|
|
47
|
+
async execute(rawInput, ctx) {
|
|
48
|
+
const parsed = billingStatementGenerateSchema.parse(rawInput)
|
|
49
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
50
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
51
|
+
|
|
52
|
+
const em = getEm(ctx)
|
|
53
|
+
const account = await em.findOne(BillingAccount, {
|
|
54
|
+
id: parsed.billAccountId,
|
|
55
|
+
tenantId: parsed.tenantId,
|
|
56
|
+
organizationId: parsed.organizationId,
|
|
57
|
+
deletedAt: null,
|
|
58
|
+
})
|
|
59
|
+
if (!account) {
|
|
60
|
+
throw new CrudHttpError(404, { error: 'Billing account not found' })
|
|
61
|
+
}
|
|
62
|
+
if (account.billingMode !== 'prepaid') {
|
|
63
|
+
throw new CrudHttpError(409, {
|
|
64
|
+
error: 'Statements are only generated for prepaid accounts',
|
|
65
|
+
code: 'billing.account.not_prepaid',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const asOfDate = parsed.asOfDate ?? new Date()
|
|
70
|
+
try {
|
|
71
|
+
const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
|
|
72
|
+
tenantId: parsed.tenantId,
|
|
73
|
+
organizationId: parsed.organizationId,
|
|
74
|
+
asOfDate,
|
|
75
|
+
triggeredBy: 'manual',
|
|
76
|
+
mode: 'real',
|
|
77
|
+
scopedAccountIds: [account.id],
|
|
78
|
+
})
|
|
79
|
+
const outcome = outcomes.find((o) => o.billAccountId === account.id)
|
|
80
|
+
return {
|
|
81
|
+
statementId: outcome?.statementId ?? null,
|
|
82
|
+
billRunId: billRun.id,
|
|
83
|
+
status: outcome?.status ?? billRun.status,
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof TenantLockBusyError) {
|
|
87
|
+
throw new CrudHttpError(409, {
|
|
88
|
+
error: 'Another Bill Run is already in progress for this tenant',
|
|
89
|
+
code: 'billing.run.lock_busy',
|
|
90
|
+
tenantId: err.tenantId,
|
|
91
|
+
lockName: err.lockName,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
throw err
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
registerCommand(generateStatementCommand)
|
|
100
|
+
|
|
101
|
+
export { generateStatementCommand }
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
4
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
5
|
+
import {
|
|
6
|
+
ensureOrganizationScope,
|
|
7
|
+
ensureTenantScope,
|
|
8
|
+
} from '@open-mercato/shared/lib/commands/scope'
|
|
9
|
+
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
10
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
11
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
12
|
+
import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
|
|
13
|
+
import { BillingAccount, BillingTopup } from '../data/entities'
|
|
14
|
+
import { billingEntityIds } from '../data/entityIds'
|
|
15
|
+
import {
|
|
16
|
+
billingTopupCreateSchema,
|
|
17
|
+
type BillingTopupCreateInput,
|
|
18
|
+
} from '../data/validators'
|
|
19
|
+
import { formatMoney } from '../lib/money'
|
|
20
|
+
import { getTopupProvider } from '../lib/prepaidConfig'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Top-up initiation (SPEC-002 P2).
|
|
24
|
+
*
|
|
25
|
+
* Registers a first-class `BillingTopup` (status=pending), then opens a
|
|
26
|
+
* payment session via `core/payment_gateways`. The balance is NOT credited
|
|
27
|
+
* here — that happens on the `payment_gateways.payment.captured` event (see
|
|
28
|
+
* subscribers/prepaid-topup-captured.ts), so the money trail is always
|
|
29
|
+
* explainable: a top-up only credits the balance once the gateway confirms
|
|
30
|
+
* capture.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const topupIndexer = { entityType: billingEntityIds.topup } as const
|
|
34
|
+
const topupEvents = { module: 'billing', entity: 'topup', persistent: true } as const
|
|
35
|
+
|
|
36
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
37
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
|
|
41
|
+
return ctx.container.resolve('dataEngine') as DataEngine
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type CreateTopupResult = {
|
|
45
|
+
topupId: string
|
|
46
|
+
paymentId: string
|
|
47
|
+
status: string
|
|
48
|
+
transactionId: string | null
|
|
49
|
+
redirectUrl: string | null
|
|
50
|
+
clientSecret: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createTopupCommand: CommandHandler<BillingTopupCreateInput, CreateTopupResult> = {
|
|
54
|
+
id: 'billing.topups.create',
|
|
55
|
+
|
|
56
|
+
async execute(rawInput, ctx) {
|
|
57
|
+
const parsed = billingTopupCreateSchema.parse(rawInput)
|
|
58
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
59
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
60
|
+
|
|
61
|
+
const em = getEm(ctx)
|
|
62
|
+
const account = await em.findOne(BillingAccount, {
|
|
63
|
+
id: parsed.billAccountId,
|
|
64
|
+
tenantId: parsed.tenantId,
|
|
65
|
+
organizationId: parsed.organizationId,
|
|
66
|
+
deletedAt: null,
|
|
67
|
+
})
|
|
68
|
+
if (!account) {
|
|
69
|
+
throw new CrudHttpError(404, { error: 'Billing account not found' })
|
|
70
|
+
}
|
|
71
|
+
if (account.billingMode !== 'prepaid') {
|
|
72
|
+
throw new CrudHttpError(409, {
|
|
73
|
+
error: 'Top-ups are only available on prepaid accounts',
|
|
74
|
+
code: 'billing.account.not_prepaid',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Idempotency on the initiating request: a retried POST with the same
|
|
79
|
+
// sourceRef returns the prior (still-pending) top-up + its session links
|
|
80
|
+
// rather than opening a second session.
|
|
81
|
+
if (parsed.sourceRef) {
|
|
82
|
+
const existing = await em.findOne(BillingTopup, {
|
|
83
|
+
tenantId: parsed.tenantId,
|
|
84
|
+
billAccountId: account.id,
|
|
85
|
+
sourceRef: parsed.sourceRef,
|
|
86
|
+
deletedAt: null,
|
|
87
|
+
})
|
|
88
|
+
if (existing) {
|
|
89
|
+
const meta = (existing.metadata as Record<string, unknown> | null) ?? {}
|
|
90
|
+
return {
|
|
91
|
+
topupId: existing.id,
|
|
92
|
+
paymentId: existing.paymentId,
|
|
93
|
+
status: existing.status,
|
|
94
|
+
transactionId: existing.gatewayTransactionId ?? null,
|
|
95
|
+
redirectUrl: (meta.redirect_url as string | null) ?? null,
|
|
96
|
+
clientSecret: (meta.client_secret as string | null) ?? null,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const providerKey = parsed.providerKey ?? (await getTopupProvider(em))
|
|
102
|
+
const paymentId = randomUUID()
|
|
103
|
+
const now = new Date()
|
|
104
|
+
const topup = em.create(BillingTopup, {
|
|
105
|
+
organizationId: parsed.organizationId,
|
|
106
|
+
tenantId: parsed.tenantId,
|
|
107
|
+
billAccountId: account.id,
|
|
108
|
+
status: 'pending',
|
|
109
|
+
amount: formatMoney(parsed.amount),
|
|
110
|
+
currencyCode: account.currencyCode,
|
|
111
|
+
providerKey,
|
|
112
|
+
paymentId,
|
|
113
|
+
sourceRef: parsed.sourceRef ?? null,
|
|
114
|
+
metadata: {
|
|
115
|
+
success_url: parsed.successUrl ?? null,
|
|
116
|
+
cancel_url: parsed.cancelUrl ?? null,
|
|
117
|
+
},
|
|
118
|
+
createdAt: now,
|
|
119
|
+
updatedAt: now,
|
|
120
|
+
})
|
|
121
|
+
em.persist(topup)
|
|
122
|
+
await em.flush()
|
|
123
|
+
|
|
124
|
+
// Open the gateway session. captureMethod 'automatic' so a successful
|
|
125
|
+
// checkout fires `payment.captured` directly (our capture subscriber
|
|
126
|
+
// credits the balance + issues the receipt).
|
|
127
|
+
const gateway = ctx.container.resolve('paymentGatewayService') as PaymentGatewayService
|
|
128
|
+
const { transaction, session } = await gateway.createPaymentSession({
|
|
129
|
+
providerKey,
|
|
130
|
+
paymentId,
|
|
131
|
+
amount: parsed.amount,
|
|
132
|
+
currencyCode: account.currencyCode,
|
|
133
|
+
captureMethod: 'automatic',
|
|
134
|
+
successUrl: parsed.successUrl,
|
|
135
|
+
cancelUrl: parsed.cancelUrl,
|
|
136
|
+
metadata: {
|
|
137
|
+
kind: 'prepaid_topup',
|
|
138
|
+
billAccountId: account.id,
|
|
139
|
+
topupId: topup.id,
|
|
140
|
+
},
|
|
141
|
+
organizationId: parsed.organizationId,
|
|
142
|
+
tenantId: parsed.tenantId,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
topup.gatewayTransactionId = transaction.id
|
|
146
|
+
topup.metadata = {
|
|
147
|
+
...(topup.metadata as Record<string, unknown>),
|
|
148
|
+
redirect_url: session.redirectUrl ?? null,
|
|
149
|
+
client_secret: session.clientSecret ?? null,
|
|
150
|
+
session_id: session.sessionId ?? null,
|
|
151
|
+
}
|
|
152
|
+
topup.updatedAt = new Date()
|
|
153
|
+
await em.flush()
|
|
154
|
+
|
|
155
|
+
await emitCrudSideEffects({
|
|
156
|
+
dataEngine: getDataEngine(ctx),
|
|
157
|
+
action: 'created',
|
|
158
|
+
entity: topup,
|
|
159
|
+
identifiers: {
|
|
160
|
+
id: topup.id,
|
|
161
|
+
tenantId: topup.tenantId,
|
|
162
|
+
organizationId: topup.organizationId,
|
|
163
|
+
},
|
|
164
|
+
indexer: topupIndexer,
|
|
165
|
+
events: topupEvents,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
topupId: topup.id,
|
|
170
|
+
paymentId,
|
|
171
|
+
status: topup.status,
|
|
172
|
+
transactionId: transaction.id,
|
|
173
|
+
redirectUrl: session.redirectUrl ?? null,
|
|
174
|
+
clientSecret: session.clientSecret ?? null,
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registerCommand(createTopupCommand)
|
|
180
|
+
|
|
181
|
+
export { createTopupCommand }
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
3
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
4
|
+
import {
|
|
5
|
+
ensureOrganizationScope,
|
|
6
|
+
ensureTenantScope,
|
|
7
|
+
} from '@open-mercato/shared/lib/commands/scope'
|
|
8
|
+
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
9
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
11
|
+
import { BillingAccount, BillingAccountBalance } from '../data/entities'
|
|
12
|
+
import { billingEntityIds } from '../data/entityIds'
|
|
13
|
+
import {
|
|
14
|
+
billingTransactionAdjustSchema,
|
|
15
|
+
type BillingTransactionAdjustInput,
|
|
16
|
+
type BalanceStatus,
|
|
17
|
+
} from '../data/validators'
|
|
18
|
+
import { applyBalanceMovement } from '../lib/balanceLedger'
|
|
19
|
+
import { buildBalanceCrossingEvents } from '../lib/balanceEvents'
|
|
20
|
+
import { computeBalanceStatus, resolveLowBalanceThreshold } from '../lib/balanceStatus'
|
|
21
|
+
import { formatMoney } from '../lib/money'
|
|
22
|
+
import {
|
|
23
|
+
getLowBalanceThresholdDefault,
|
|
24
|
+
getNearLimitBufferDefault,
|
|
25
|
+
} from '../lib/prepaidConfig'
|
|
26
|
+
import { emitBillingEvent, type BillingEventId } from '../events'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Manual balance adjustment (SPEC-002 P5).
|
|
30
|
+
*
|
|
31
|
+
* Operator-driven credit/debit (`type='adjustment'`) audited via `user_id`.
|
|
32
|
+
* Non-fiscal by default — an adjustment moves already-taxed balance, so no
|
|
33
|
+
* receipt is issued (Open Question #3; can be revisited per jurisdiction).
|
|
34
|
+
* Goes through the same atomic ledger primitive as every other movement, so
|
|
35
|
+
* the reconciliation invariant still holds.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const transactionIndexer = { entityType: billingEntityIds.transaction } as const
|
|
39
|
+
const transactionEvents = { module: 'billing', entity: 'transaction', persistent: true } as const
|
|
40
|
+
|
|
41
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
42
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
|
|
46
|
+
return ctx.container.resolve('dataEngine') as DataEngine
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type AdjustResult = {
|
|
50
|
+
transactionId: string
|
|
51
|
+
balance: string
|
|
52
|
+
balanceStatus: BalanceStatus
|
|
53
|
+
deduplicated: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const adjustTransactionCommand: CommandHandler<BillingTransactionAdjustInput, AdjustResult> = {
|
|
57
|
+
id: 'billing.transactions.adjust',
|
|
58
|
+
|
|
59
|
+
async execute(rawInput, ctx) {
|
|
60
|
+
const parsed = billingTransactionAdjustSchema.parse(rawInput)
|
|
61
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
62
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
63
|
+
|
|
64
|
+
const em = getEm(ctx)
|
|
65
|
+
const account = await em.findOne(BillingAccount, {
|
|
66
|
+
id: parsed.billAccountId,
|
|
67
|
+
tenantId: parsed.tenantId,
|
|
68
|
+
organizationId: parsed.organizationId,
|
|
69
|
+
deletedAt: null,
|
|
70
|
+
})
|
|
71
|
+
if (!account) {
|
|
72
|
+
throw new CrudHttpError(404, { error: 'Billing account not found' })
|
|
73
|
+
}
|
|
74
|
+
if (account.billingMode !== 'prepaid') {
|
|
75
|
+
throw new CrudHttpError(409, {
|
|
76
|
+
error: 'Balance adjustments are only available on prepaid accounts',
|
|
77
|
+
code: 'billing.account.not_prepaid',
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const balanceRow = await em.findOne(BillingAccountBalance, {
|
|
82
|
+
tenantId: parsed.tenantId,
|
|
83
|
+
billAccountId: account.id,
|
|
84
|
+
})
|
|
85
|
+
const threshold = resolveLowBalanceThreshold(
|
|
86
|
+
balanceRow?.lowBalanceThreshold ?? null,
|
|
87
|
+
await getLowBalanceThresholdDefault(em),
|
|
88
|
+
)
|
|
89
|
+
const nearLimitBuffer = await getNearLimitBufferDefault(em)
|
|
90
|
+
const userId = (ctx.auth?.sub as string | undefined) ?? null
|
|
91
|
+
|
|
92
|
+
const outcome = await em.transactional(async (tem) =>
|
|
93
|
+
applyBalanceMovement(tem as EntityManager, {
|
|
94
|
+
organizationId: parsed.organizationId,
|
|
95
|
+
tenantId: parsed.tenantId,
|
|
96
|
+
billAccountId: account.id,
|
|
97
|
+
currencyCode: account.currencyCode,
|
|
98
|
+
type: 'adjustment',
|
|
99
|
+
amount: formatMoney(parsed.amount),
|
|
100
|
+
description: parsed.description,
|
|
101
|
+
sourceRef: parsed.sourceRef ?? null,
|
|
102
|
+
userId,
|
|
103
|
+
metadata: { manual: true },
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
// Index the transaction + emit threshold-crossing events after commit.
|
|
108
|
+
const created = outcome.transaction
|
|
109
|
+
await emitCrudSideEffects({
|
|
110
|
+
dataEngine: getDataEngine(ctx),
|
|
111
|
+
action: 'created',
|
|
112
|
+
entity: created,
|
|
113
|
+
identifiers: {
|
|
114
|
+
id: created.id,
|
|
115
|
+
tenantId: created.tenantId,
|
|
116
|
+
organizationId: created.organizationId,
|
|
117
|
+
},
|
|
118
|
+
indexer: transactionIndexer,
|
|
119
|
+
events: transactionEvents,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (!outcome.deduplicated) {
|
|
123
|
+
const events = buildBalanceCrossingEvents({
|
|
124
|
+
scope: {
|
|
125
|
+
billAccountId: account.id,
|
|
126
|
+
tenantId: parsed.tenantId,
|
|
127
|
+
organizationId: parsed.organizationId,
|
|
128
|
+
currencyCode: account.currencyCode,
|
|
129
|
+
},
|
|
130
|
+
balanceBefore: outcome.balanceBefore,
|
|
131
|
+
balanceAfter: outcome.balanceAfter,
|
|
132
|
+
threshold,
|
|
133
|
+
creditLimit: account.creditLimit ?? '0',
|
|
134
|
+
nearLimitBuffer,
|
|
135
|
+
})
|
|
136
|
+
for (const ev of events) {
|
|
137
|
+
await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
transactionId: created.id,
|
|
143
|
+
balance: outcome.balanceAfter,
|
|
144
|
+
balanceStatus: computeBalanceStatus(outcome.balanceAfter, threshold),
|
|
145
|
+
deduplicated: outcome.deduplicated,
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
registerCommand(adjustTransactionCommand)
|
|
151
|
+
|
|
152
|
+
export { adjustTransactionCommand }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core'
|
|
2
3
|
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
|
|
3
4
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
4
5
|
import {
|
|
@@ -15,6 +16,11 @@ import {
|
|
|
15
16
|
billingUsageCreateSchema,
|
|
16
17
|
type BillingUsageCreateInput,
|
|
17
18
|
} from '../data/validators'
|
|
19
|
+
import {
|
|
20
|
+
consumePrepaidUsage,
|
|
21
|
+
type PrepaidConsumeResult,
|
|
22
|
+
} from '../lib/prepaidConsume'
|
|
23
|
+
import { emitBillingEvent, type BillingEventId } from '../events'
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* Usage ingest — append-only.
|
|
@@ -41,10 +47,20 @@ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
|
|
|
41
47
|
return ctx.container.resolve('dataEngine') as DataEngine
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
export type CreateUsageResult = {
|
|
51
|
+
id: string
|
|
52
|
+
deduplicated: boolean
|
|
53
|
+
// Prepaid-only extras (SPEC-002) — present only for prepaid accounts.
|
|
54
|
+
ratedAmount?: string
|
|
55
|
+
currencyCode?: string
|
|
56
|
+
balance?: string
|
|
57
|
+
balanceStatus?: PrepaidConsumeResult['balanceStatus']
|
|
58
|
+
creditStatus?: PrepaidConsumeResult['creditStatus']
|
|
59
|
+
tierBreakdown?: PrepaidConsumeResult['tierBreakdown']
|
|
60
|
+
warning?: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const createUsageCommand: CommandHandler<BillingUsageCreateInput, CreateUsageResult> = {
|
|
48
64
|
id: 'billing.usage.create',
|
|
49
65
|
|
|
50
66
|
async execute(rawInput, ctx) {
|
|
@@ -63,6 +79,11 @@ const createUsageCommand: CommandHandler<
|
|
|
63
79
|
throw new CrudHttpError(404, { error: 'Billing account not found' })
|
|
64
80
|
}
|
|
65
81
|
|
|
82
|
+
// ─── Prepaid: rate + atomic debit + balance, all in one tx ────
|
|
83
|
+
if (account.billingMode === 'prepaid') {
|
|
84
|
+
return executePrepaidConsume(ctx, em, account, parsed)
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
if (parsed.sourceRef) {
|
|
67
88
|
const existing = await findBySourceRef(em, BillingAccountUsage, {
|
|
68
89
|
tenantId: parsed.tenantId,
|
|
@@ -113,6 +134,95 @@ const createUsageCommand: CommandHandler<
|
|
|
113
134
|
},
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Prepaid consume: rate + atomic debit + balance + transaction in one DB
|
|
139
|
+
* transaction, then index + emit domain events AFTER the commit.
|
|
140
|
+
*/
|
|
141
|
+
async function executePrepaidConsume(
|
|
142
|
+
ctx: CommandRuntimeContext,
|
|
143
|
+
em: EntityManager,
|
|
144
|
+
account: BillingAccount,
|
|
145
|
+
parsed: BillingUsageCreateInput,
|
|
146
|
+
): Promise<CreateUsageResult> {
|
|
147
|
+
const input = {
|
|
148
|
+
organizationId: parsed.organizationId,
|
|
149
|
+
tenantId: parsed.tenantId,
|
|
150
|
+
billAccountId: parsed.billAccountId,
|
|
151
|
+
uomCode: parsed.uomCode,
|
|
152
|
+
quantity: parsed.quantity,
|
|
153
|
+
periodStart: parsed.periodStart,
|
|
154
|
+
periodEnd: parsed.periodEnd,
|
|
155
|
+
lineDescription: parsed.lineDescription ?? null,
|
|
156
|
+
sourceRef: parsed.sourceRef ?? null,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let outcome
|
|
160
|
+
try {
|
|
161
|
+
outcome = await em.transactional(async (tem) =>
|
|
162
|
+
consumePrepaidUsage(tem as EntityManager, account, input),
|
|
163
|
+
)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// Concurrency: a simultaneous upload with the same sourceRef won the
|
|
166
|
+
// unique-constraint race and committed first, so our tx rolled back (no
|
|
167
|
+
// double-debit). Retry once on a fresh em — the dedup pre-check now finds
|
|
168
|
+
// the winner's record and returns its balance_after (idempotent 200).
|
|
169
|
+
if (parsed.sourceRef && err instanceof UniqueConstraintViolationException) {
|
|
170
|
+
outcome = await em.fork().transactional(async (tem) =>
|
|
171
|
+
consumePrepaidUsage(tem as EntityManager, account, input),
|
|
172
|
+
)
|
|
173
|
+
} else {
|
|
174
|
+
throw err
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Index the new usage record (parity with the postpaid path) — only when a
|
|
179
|
+
// fresh record was actually written (a dedup hit re-reads the prior one).
|
|
180
|
+
if (!outcome.result.deduplicated) {
|
|
181
|
+
const usage = await em.findOne(BillingAccountUsage, {
|
|
182
|
+
id: outcome.result.id,
|
|
183
|
+
tenantId: parsed.tenantId,
|
|
184
|
+
organizationId: parsed.organizationId,
|
|
185
|
+
})
|
|
186
|
+
if (usage) {
|
|
187
|
+
await emitCrudSideEffects({
|
|
188
|
+
dataEngine: getDataEngine(ctx),
|
|
189
|
+
action: 'created',
|
|
190
|
+
entity: usage,
|
|
191
|
+
identifiers: {
|
|
192
|
+
id: usage.id,
|
|
193
|
+
tenantId: usage.tenantId,
|
|
194
|
+
organizationId: usage.organizationId,
|
|
195
|
+
},
|
|
196
|
+
indexer: usageIndexer,
|
|
197
|
+
events: usageEvents,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Domain events fire AFTER the DB commit (consistent with the post command).
|
|
203
|
+
// `billing.usage.rated` fires on EVERY upload — emit it ephemerally (a live
|
|
204
|
+
// signal, excluded from triggers) so the high-throughput consume path does
|
|
205
|
+
// not write a queue row per call. The meaningful crossing events
|
|
206
|
+
// (low/exhausted/credit) stay persistent so async consumers never miss them.
|
|
207
|
+
for (const ev of outcome.events) {
|
|
208
|
+
const persistent = ev.id !== 'billing.usage.rated'
|
|
209
|
+
await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const r = outcome.result
|
|
213
|
+
return {
|
|
214
|
+
id: r.id,
|
|
215
|
+
deduplicated: r.deduplicated,
|
|
216
|
+
ratedAmount: r.ratedAmount,
|
|
217
|
+
currencyCode: r.currencyCode,
|
|
218
|
+
balance: r.balance,
|
|
219
|
+
balanceStatus: r.balanceStatus,
|
|
220
|
+
creditStatus: r.creditStatus,
|
|
221
|
+
tierBreakdown: r.tierBreakdown,
|
|
222
|
+
warning: r.warning,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
116
226
|
registerCommand(createUsageCommand)
|
|
117
227
|
|
|
118
228
|
export { createUsageCommand }
|