@dainamite/billing 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +658 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/modules/billing/acl.js +20 -0
- package/dist/modules/billing/acl.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +182 -0
- package/dist/modules/billing/api/accounts/route.js.map +7 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
- package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
- package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/post/route.js +74 -0
- package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/route.js +109 -0
- package/dist/modules/billing/api/invoices/route.js.map +7 -0
- package/dist/modules/billing/api/items/bulk/route.js +74 -0
- package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
- package/dist/modules/billing/api/items/route.js +224 -0
- package/dist/modules/billing/api/items/route.js.map +7 -0
- package/dist/modules/billing/api/openapi.js +25 -0
- package/dist/modules/billing/api/openapi.js.map +7 -0
- package/dist/modules/billing/api/run-outcomes/route.js +122 -0
- package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
- package/dist/modules/billing/api/runs/route.js +143 -0
- package/dist/modules/billing/api/runs/route.js.map +7 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +138 -0
- package/dist/modules/billing/api/usage/route.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
- package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
- package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
- package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.js +208 -0
- package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.js +341 -0
- package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
- package/dist/modules/billing/ce.js +56 -0
- package/dist/modules/billing/ce.js.map +7 -0
- package/dist/modules/billing/cli.js +67 -0
- package/dist/modules/billing/cli.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +157 -0
- package/dist/modules/billing/commands/accounts.js.map +7 -0
- package/dist/modules/billing/commands/index.js +6 -0
- package/dist/modules/billing/commands/index.js.map +7 -0
- package/dist/modules/billing/commands/invoices.js +401 -0
- package/dist/modules/billing/commands/invoices.js.map +7 -0
- package/dist/modules/billing/commands/items.js +317 -0
- package/dist/modules/billing/commands/items.js.map +7 -0
- package/dist/modules/billing/commands/runs.js +123 -0
- package/dist/modules/billing/commands/runs.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +88 -0
- package/dist/modules/billing/commands/usage.js.map +7 -0
- package/dist/modules/billing/components/AccountForm.js +420 -0
- package/dist/modules/billing/components/AccountForm.js.map +7 -0
- package/dist/modules/billing/components/DetailFields.js +37 -0
- package/dist/modules/billing/components/DetailFields.js.map +7 -0
- package/dist/modules/billing/components/ItemForm.js +440 -0
- package/dist/modules/billing/components/ItemForm.js.map +7 -0
- package/dist/modules/billing/components/LineFormDialog.js +195 -0
- package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
- package/dist/modules/billing/components/UsageForm.js +262 -0
- package/dist/modules/billing/components/UsageForm.js.map +7 -0
- package/dist/modules/billing/data/entities.js +433 -0
- package/dist/modules/billing/data/entities.js.map +7 -0
- package/dist/modules/billing/data/entityIds.js +12 -0
- package/dist/modules/billing/data/entityIds.js.map +7 -0
- package/dist/modules/billing/data/validators.js +396 -0
- package/dist/modules/billing/data/validators.js.map +7 -0
- package/dist/modules/billing/di.js +6 -0
- package/dist/modules/billing/di.js.map +7 -0
- package/dist/modules/billing/events.js +35 -0
- package/dist/modules/billing/events.js.map +7 -0
- package/dist/modules/billing/i18n/en.json +336 -0
- package/dist/modules/billing/i18n/pl.json +336 -0
- package/dist/modules/billing/index.js +17 -0
- package/dist/modules/billing/index.js.map +7 -0
- package/dist/modules/billing/lib/billPeriod.js +101 -0
- package/dist/modules/billing/lib/billPeriod.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +256 -0
- package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
- package/dist/modules/billing/lib/billRunLabel.js +8 -0
- package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
- package/dist/modules/billing/lib/idempotency.js +13 -0
- package/dist/modules/billing/lib/idempotency.js.map +7 -0
- package/dist/modules/billing/lib/invoiceStatus.js +38 -0
- package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
- package/dist/modules/billing/lib/invoiceWriter.js +153 -0
- package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
- package/dist/modules/billing/lib/itemSelector.js +77 -0
- package/dist/modules/billing/lib/itemSelector.js.map +7 -0
- package/dist/modules/billing/lib/seeds/demo.js +130 -0
- package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
- package/dist/modules/billing/lib/tenantLock.js +32 -0
- package/dist/modules/billing/lib/tenantLock.js.map +7 -0
- package/dist/modules/billing/lib/usageRater.js +150 -0
- package/dist/modules/billing/lib/usageRater.js.map +7 -0
- package/dist/modules/billing/lib/usageRunner.js +82 -0
- package/dist/modules/billing/lib/usageRunner.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
- package/dist/modules/billing/setup.js +190 -0
- package/dist/modules/billing/setup.js.map +7 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
- package/dist/modules/billing/translations.js +5 -0
- package/dist/modules/billing/translations.js.map +7 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
- package/package.json +148 -0
- package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
- package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
- package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
- package/src/modules/billing/__integration__/meta.ts +4 -0
- package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
- package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
- package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
- package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
- package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
- package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
- package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
- package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
- package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
- package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
- package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
- package/src/modules/billing/__tests__/setup.test.ts +107 -0
- package/src/modules/billing/__tests__/validators.test.ts +594 -0
- package/src/modules/billing/acl.ts +16 -0
- package/src/modules/billing/api/accounts/route.ts +198 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
- package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
- package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
- package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
- package/src/modules/billing/api/invoices/post/route.ts +97 -0
- package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
- package/src/modules/billing/api/invoices/route.ts +167 -0
- package/src/modules/billing/api/items/bulk/route.ts +92 -0
- package/src/modules/billing/api/items/route.ts +255 -0
- package/src/modules/billing/api/openapi.ts +33 -0
- package/src/modules/billing/api/run-outcomes/route.ts +149 -0
- package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
- package/src/modules/billing/api/runs/route.ts +170 -0
- package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
- package/src/modules/billing/api/usage/route.ts +148 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
- package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
- package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
- package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
- package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
- package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
- package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/items/page.tsx +272 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
- package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
- package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
- package/src/modules/billing/ce.ts +74 -0
- package/src/modules/billing/cli.ts +89 -0
- package/src/modules/billing/commands/accounts.ts +205 -0
- package/src/modules/billing/commands/index.ts +13 -0
- package/src/modules/billing/commands/invoices.ts +542 -0
- package/src/modules/billing/commands/items.ts +438 -0
- package/src/modules/billing/commands/runs.ts +166 -0
- package/src/modules/billing/commands/usage.ts +118 -0
- package/src/modules/billing/components/AccountForm.tsx +517 -0
- package/src/modules/billing/components/DetailFields.tsx +60 -0
- package/src/modules/billing/components/ItemForm.tsx +558 -0
- package/src/modules/billing/components/LineFormDialog.tsx +253 -0
- package/src/modules/billing/components/UsageForm.tsx +326 -0
- package/src/modules/billing/data/entities.ts +533 -0
- package/src/modules/billing/data/entityIds.ts +25 -0
- package/src/modules/billing/data/validators.ts +562 -0
- package/src/modules/billing/di.ts +14 -0
- package/src/modules/billing/events.ts +47 -0
- package/src/modules/billing/i18n/en.json +336 -0
- package/src/modules/billing/i18n/pl.json +336 -0
- package/src/modules/billing/index.ts +22 -0
- package/src/modules/billing/lib/billPeriod.ts +181 -0
- package/src/modules/billing/lib/billRunEngine.ts +416 -0
- package/src/modules/billing/lib/billRunLabel.ts +14 -0
- package/src/modules/billing/lib/idempotency.ts +46 -0
- package/src/modules/billing/lib/invoiceStatus.ts +61 -0
- package/src/modules/billing/lib/invoiceWriter.ts +260 -0
- package/src/modules/billing/lib/itemSelector.ts +154 -0
- package/src/modules/billing/lib/seeds/demo.ts +199 -0
- package/src/modules/billing/lib/tenantLock.ts +87 -0
- package/src/modules/billing/lib/usageRater.ts +250 -0
- package/src/modules/billing/lib/usageRunner.ts +189 -0
- package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
- package/src/modules/billing/setup.ts +252 -0
- package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
- package/src/modules/billing/translations.ts +9 -0
- package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
|
@@ -0,0 +1,438 @@
|
|
|
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 {
|
|
9
|
+
emitCrudSideEffects,
|
|
10
|
+
requireId,
|
|
11
|
+
} from '@open-mercato/shared/lib/commands/helpers'
|
|
12
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
13
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
14
|
+
import { BillingAccount, BillingItem } from '../data/entities'
|
|
15
|
+
import { billingEntityIds } from '../data/entityIds'
|
|
16
|
+
import { findBySourceRef } from '../lib/idempotency'
|
|
17
|
+
import {
|
|
18
|
+
billingItemBulkCreateSchema,
|
|
19
|
+
billingItemCreateSchema,
|
|
20
|
+
billingItemDeleteSchema,
|
|
21
|
+
billingItemUpdateSchema,
|
|
22
|
+
validateRateJson,
|
|
23
|
+
type BillingItemBulkCreateInput,
|
|
24
|
+
type BillingItemCreateInput,
|
|
25
|
+
type BillingItemDeleteInput,
|
|
26
|
+
type BillingItemUpdateInput,
|
|
27
|
+
} from '../data/validators'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* BillingItem writes — create / update / soft-delete.
|
|
31
|
+
*
|
|
32
|
+
* Create is idempotent via `source_ref`: if the integrator POSTs the
|
|
33
|
+
* same `(tenant_id, bill_account_id, source_ref)` twice, the second
|
|
34
|
+
* call returns the existing item with `deduplicated: true`. The
|
|
35
|
+
* underlying unique index is the correctness gate; this pre-check keeps
|
|
36
|
+
* the happy path single-round-trip and the response body informative.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const itemIndexer = { entityType: billingEntityIds.item } as const
|
|
40
|
+
const itemEvents = { module: 'billing', entity: 'item', persistent: true } as const
|
|
41
|
+
|
|
42
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
43
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
|
|
47
|
+
return ctx.container.resolve('dataEngine') as DataEngine
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function assertAccountInScope(
|
|
51
|
+
em: EntityManager,
|
|
52
|
+
params: { tenantId: string; organizationId: string; billAccountId: string },
|
|
53
|
+
): Promise<BillingAccount> {
|
|
54
|
+
const account = await em.findOne(BillingAccount, {
|
|
55
|
+
id: params.billAccountId,
|
|
56
|
+
tenantId: params.tenantId,
|
|
57
|
+
organizationId: params.organizationId,
|
|
58
|
+
deletedAt: null,
|
|
59
|
+
})
|
|
60
|
+
if (!account) {
|
|
61
|
+
throw new CrudHttpError(404, { error: 'Billing account not found' })
|
|
62
|
+
}
|
|
63
|
+
return account
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Create (idempotent) ─────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const createItemCommand: CommandHandler<
|
|
69
|
+
BillingItemCreateInput,
|
|
70
|
+
{ id: string; deduplicated: boolean }
|
|
71
|
+
> = {
|
|
72
|
+
id: 'billing.items.create',
|
|
73
|
+
|
|
74
|
+
async execute(rawInput, ctx) {
|
|
75
|
+
const parsed = billingItemCreateSchema.parse(rawInput)
|
|
76
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
77
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
78
|
+
|
|
79
|
+
const em = getEm(ctx)
|
|
80
|
+
const account = await assertAccountInScope(em, parsed)
|
|
81
|
+
|
|
82
|
+
// Idempotency: if `source_ref` provided and matches an existing
|
|
83
|
+
// (non-deleted) row, return that row. The unique index is the
|
|
84
|
+
// final correctness gate — this is the happy-path early return.
|
|
85
|
+
if (parsed.sourceRef) {
|
|
86
|
+
const existing = await findBySourceRef(em, BillingItem, {
|
|
87
|
+
tenantId: parsed.tenantId,
|
|
88
|
+
billAccountId: parsed.billAccountId,
|
|
89
|
+
sourceRef: parsed.sourceRef,
|
|
90
|
+
})
|
|
91
|
+
if (existing) {
|
|
92
|
+
return { id: existing.id, deduplicated: true }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Re-validate the rate_json shape against the resolved type to
|
|
97
|
+
// future-proof against schema drift between the API surface and
|
|
98
|
+
// the persistence layer.
|
|
99
|
+
validateRateJson(parsed.type, parsed.rateJson)
|
|
100
|
+
|
|
101
|
+
const now = new Date()
|
|
102
|
+
const entity = em.create(BillingItem, {
|
|
103
|
+
organizationId: parsed.organizationId,
|
|
104
|
+
tenantId: parsed.tenantId,
|
|
105
|
+
billAccountId: account.id,
|
|
106
|
+
type: parsed.type,
|
|
107
|
+
billStartDate: parsed.billStartDate,
|
|
108
|
+
billEndDate: parsed.billEndDate ?? null,
|
|
109
|
+
description: parsed.description,
|
|
110
|
+
rateJson: parsed.rateJson as Record<string, unknown>,
|
|
111
|
+
uomCode: parsed.uomCode ?? null,
|
|
112
|
+
subscriptionId: parsed.subscriptionId ?? null,
|
|
113
|
+
subscriptionItemId: parsed.subscriptionItemId ?? null,
|
|
114
|
+
sourceRef: parsed.sourceRef ?? null,
|
|
115
|
+
// `currency_mismatch` is set by Phase 2 / connectors when the
|
|
116
|
+
// posted value's currency differs from the account's. Stays
|
|
117
|
+
// false on create (operator opts in to flagging).
|
|
118
|
+
currencyMismatch: false,
|
|
119
|
+
// `billed_to_date` is set by the Bill Run engine on the first
|
|
120
|
+
// real-run that emits this item. Always null on create.
|
|
121
|
+
billedToDate: null,
|
|
122
|
+
isActive: true,
|
|
123
|
+
createdAt: now,
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
})
|
|
126
|
+
em.persist(entity)
|
|
127
|
+
await em.flush()
|
|
128
|
+
|
|
129
|
+
await emitCrudSideEffects({
|
|
130
|
+
dataEngine: getDataEngine(ctx),
|
|
131
|
+
action: 'created',
|
|
132
|
+
entity,
|
|
133
|
+
identifiers: {
|
|
134
|
+
id: entity.id,
|
|
135
|
+
tenantId: entity.tenantId,
|
|
136
|
+
organizationId: entity.organizationId,
|
|
137
|
+
},
|
|
138
|
+
indexer: itemIndexer,
|
|
139
|
+
events: itemEvents,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return { id: entity.id, deduplicated: false }
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Update ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const updateItemCommand: CommandHandler<BillingItemUpdateInput, { id: string }> = {
|
|
149
|
+
id: 'billing.items.update',
|
|
150
|
+
|
|
151
|
+
async execute(rawInput, ctx) {
|
|
152
|
+
const parsed = billingItemUpdateSchema.parse(rawInput)
|
|
153
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
154
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
155
|
+
|
|
156
|
+
const em = getEm(ctx)
|
|
157
|
+
const record = await em.findOne(BillingItem, {
|
|
158
|
+
id: parsed.id,
|
|
159
|
+
tenantId: parsed.tenantId,
|
|
160
|
+
organizationId: parsed.organizationId,
|
|
161
|
+
deletedAt: null,
|
|
162
|
+
})
|
|
163
|
+
if (!record) {
|
|
164
|
+
throw new CrudHttpError(404, { error: 'Billing item not found' })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (parsed.billStartDate !== undefined) record.billStartDate = parsed.billStartDate
|
|
168
|
+
if (parsed.billEndDate !== undefined) record.billEndDate = parsed.billEndDate
|
|
169
|
+
if (parsed.description !== undefined) record.description = parsed.description
|
|
170
|
+
if (parsed.rateJson !== undefined) {
|
|
171
|
+
// Re-validate against the current (immutable) type before persist.
|
|
172
|
+
validateRateJson(record.type as 'one_time' | 'recurring' | 'usage', parsed.rateJson)
|
|
173
|
+
record.rateJson = parsed.rateJson as Record<string, unknown>
|
|
174
|
+
}
|
|
175
|
+
if (parsed.uomCode !== undefined) record.uomCode = parsed.uomCode
|
|
176
|
+
if (parsed.subscriptionId !== undefined) record.subscriptionId = parsed.subscriptionId
|
|
177
|
+
if (parsed.subscriptionItemId !== undefined) {
|
|
178
|
+
record.subscriptionItemId = parsed.subscriptionItemId
|
|
179
|
+
}
|
|
180
|
+
if (parsed.isActive !== undefined) record.isActive = parsed.isActive
|
|
181
|
+
|
|
182
|
+
record.updatedAt = new Date()
|
|
183
|
+
await em.flush()
|
|
184
|
+
|
|
185
|
+
await emitCrudSideEffects({
|
|
186
|
+
dataEngine: getDataEngine(ctx),
|
|
187
|
+
action: 'updated',
|
|
188
|
+
entity: record,
|
|
189
|
+
identifiers: {
|
|
190
|
+
id: record.id,
|
|
191
|
+
tenantId: record.tenantId,
|
|
192
|
+
organizationId: record.organizationId,
|
|
193
|
+
},
|
|
194
|
+
indexer: itemIndexer,
|
|
195
|
+
events: itemEvents,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return { id: record.id }
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Delete (soft) ───────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const deleteItemCommand: CommandHandler<BillingItemDeleteInput, { id: string }> = {
|
|
205
|
+
id: 'billing.items.delete',
|
|
206
|
+
|
|
207
|
+
async execute(rawInput, ctx) {
|
|
208
|
+
const parsed = billingItemDeleteSchema.parse(rawInput)
|
|
209
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
210
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
211
|
+
const id = requireId(parsed)
|
|
212
|
+
|
|
213
|
+
const em = getEm(ctx)
|
|
214
|
+
const record = await em.findOne(BillingItem, {
|
|
215
|
+
id,
|
|
216
|
+
tenantId: parsed.tenantId,
|
|
217
|
+
organizationId: parsed.organizationId,
|
|
218
|
+
deletedAt: null,
|
|
219
|
+
})
|
|
220
|
+
if (!record) {
|
|
221
|
+
throw new CrudHttpError(404, { error: 'Billing item not found' })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
record.deletedAt = new Date()
|
|
225
|
+
record.updatedAt = record.deletedAt
|
|
226
|
+
await em.flush()
|
|
227
|
+
|
|
228
|
+
await emitCrudSideEffects({
|
|
229
|
+
dataEngine: getDataEngine(ctx),
|
|
230
|
+
action: 'deleted',
|
|
231
|
+
entity: record,
|
|
232
|
+
identifiers: {
|
|
233
|
+
id: record.id,
|
|
234
|
+
tenantId: record.tenantId,
|
|
235
|
+
organizationId: record.organizationId,
|
|
236
|
+
},
|
|
237
|
+
indexer: itemIndexer,
|
|
238
|
+
events: itemEvents,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
return { id: record.id }
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Bulk create (idempotent, batched) ───────────────────────────
|
|
246
|
+
//
|
|
247
|
+
// Collapses N `billing.items.create` round-trips into one command.
|
|
248
|
+
// The CPQ connector activating a large subscription is the primary
|
|
249
|
+
// caller — it used to fire one create per charge.
|
|
250
|
+
//
|
|
251
|
+
// Idempotency is still per-`source_ref` but the existence check is a
|
|
252
|
+
// SINGLE query over all source_refs in the batch, instead of one
|
|
253
|
+
// `findOne` per item. Inserts are one `em.flush()` for the whole
|
|
254
|
+
// batch. So a 200-charge activation goes from ~200 round-trips +
|
|
255
|
+
// 200 flushes to ~3 queries + 1 flush.
|
|
256
|
+
|
|
257
|
+
type BulkCreateResultEntry = {
|
|
258
|
+
sourceRef: string | null
|
|
259
|
+
id: string
|
|
260
|
+
deduplicated: boolean
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const bulkCreateItemsCommand: CommandHandler<
|
|
264
|
+
BillingItemBulkCreateInput,
|
|
265
|
+
{ created: number; deduplicated: number; items: BulkCreateResultEntry[] }
|
|
266
|
+
> = {
|
|
267
|
+
id: 'billing.items.bulk_create',
|
|
268
|
+
|
|
269
|
+
async execute(rawInput, ctx) {
|
|
270
|
+
const parsed = billingItemBulkCreateSchema.parse(rawInput)
|
|
271
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
272
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
273
|
+
|
|
274
|
+
const em = getEm(ctx)
|
|
275
|
+
|
|
276
|
+
// Validate every referenced account exists + is in scope. One
|
|
277
|
+
// `$in` query for the distinct account ids, not one per item.
|
|
278
|
+
const accountIds = Array.from(new Set(parsed.items.map((i) => i.billAccountId)))
|
|
279
|
+
const accounts = await em.find(BillingAccount, {
|
|
280
|
+
id: { $in: accountIds },
|
|
281
|
+
tenantId: parsed.tenantId,
|
|
282
|
+
organizationId: parsed.organizationId,
|
|
283
|
+
deletedAt: null,
|
|
284
|
+
} as never)
|
|
285
|
+
const knownAccountIds = new Set(accounts.map((a) => a.id))
|
|
286
|
+
const missingAccount = accountIds.find((id) => !knownAccountIds.has(id))
|
|
287
|
+
if (missingAccount) {
|
|
288
|
+
throw new CrudHttpError(404, {
|
|
289
|
+
error: 'Billing account not found',
|
|
290
|
+
billAccountId: missingAccount,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Batched idempotency check — one query over every source_ref in
|
|
295
|
+
// the batch. `source_ref` is unique per `(tenant, bill_account,
|
|
296
|
+
// source_ref)`, so we key the existing-set on that triple.
|
|
297
|
+
const sourceRefs = parsed.items
|
|
298
|
+
.map((i) => i.sourceRef)
|
|
299
|
+
.filter((ref): ref is string => typeof ref === 'string' && ref.length > 0)
|
|
300
|
+
const existingBySourceKey = new Map<string, string>()
|
|
301
|
+
if (sourceRefs.length > 0) {
|
|
302
|
+
const existing = await em.find(BillingItem, {
|
|
303
|
+
tenantId: parsed.tenantId,
|
|
304
|
+
organizationId: parsed.organizationId,
|
|
305
|
+
billAccountId: { $in: accountIds },
|
|
306
|
+
sourceRef: { $in: Array.from(new Set(sourceRefs)) },
|
|
307
|
+
deletedAt: null,
|
|
308
|
+
} as never)
|
|
309
|
+
for (const row of existing) {
|
|
310
|
+
if (row.sourceRef) {
|
|
311
|
+
existingBySourceKey.set(`${row.billAccountId}::${row.sourceRef}`, row.id)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const now = new Date()
|
|
317
|
+
const results: BulkCreateResultEntry[] = []
|
|
318
|
+
const createdEntities: BillingItem[] = []
|
|
319
|
+
// Result entries whose `id` is only known after the flush — the
|
|
320
|
+
// BillingItem PK is a DB-generated UUID, unassigned until then.
|
|
321
|
+
// Covers freshly-created rows and in-batch dedup rows that point
|
|
322
|
+
// at a sibling created earlier in the same payload.
|
|
323
|
+
const deferredIdLinks: Array<{ result: BulkCreateResultEntry; entity: BillingItem }> = []
|
|
324
|
+
|
|
325
|
+
// Track source_refs created within THIS batch so a payload that
|
|
326
|
+
// repeats the same source_ref twice dedups against itself (the
|
|
327
|
+
// unique index would otherwise blow up the whole flush).
|
|
328
|
+
const seenInBatch = new Set<string>()
|
|
329
|
+
|
|
330
|
+
for (const entry of parsed.items) {
|
|
331
|
+
const sourceKey = entry.sourceRef
|
|
332
|
+
? `${entry.billAccountId}::${entry.sourceRef}`
|
|
333
|
+
: null
|
|
334
|
+
|
|
335
|
+
if (sourceKey) {
|
|
336
|
+
const existingId = existingBySourceKey.get(sourceKey)
|
|
337
|
+
if (existingId) {
|
|
338
|
+
results.push({ sourceRef: entry.sourceRef ?? null, id: existingId, deduplicated: true })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (seenInBatch.has(sourceKey)) {
|
|
342
|
+
// Duplicate source_ref inside the same payload — first wins,
|
|
343
|
+
// the repeat is reported deduplicated against the first.
|
|
344
|
+
const firstCreated = createdEntities.find(
|
|
345
|
+
(e) => e.billAccountId === entry.billAccountId && e.sourceRef === entry.sourceRef,
|
|
346
|
+
)
|
|
347
|
+
const dedupResult: BulkCreateResultEntry = {
|
|
348
|
+
sourceRef: entry.sourceRef ?? null,
|
|
349
|
+
id: '',
|
|
350
|
+
deduplicated: true,
|
|
351
|
+
}
|
|
352
|
+
results.push(dedupResult)
|
|
353
|
+
if (firstCreated) deferredIdLinks.push({ result: dedupResult, entity: firstCreated })
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
seenInBatch.add(sourceKey)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
validateRateJson(entry.type, entry.rateJson)
|
|
360
|
+
const item = em.create(BillingItem, {
|
|
361
|
+
organizationId: parsed.organizationId,
|
|
362
|
+
tenantId: parsed.tenantId,
|
|
363
|
+
billAccountId: entry.billAccountId,
|
|
364
|
+
type: entry.type,
|
|
365
|
+
billStartDate: entry.billStartDate,
|
|
366
|
+
billEndDate: entry.billEndDate ?? null,
|
|
367
|
+
description: entry.description,
|
|
368
|
+
rateJson: entry.rateJson as Record<string, unknown>,
|
|
369
|
+
uomCode: entry.uomCode ?? null,
|
|
370
|
+
subscriptionId: entry.subscriptionId ?? null,
|
|
371
|
+
subscriptionItemId: entry.subscriptionItemId ?? null,
|
|
372
|
+
sourceRef: entry.sourceRef ?? null,
|
|
373
|
+
currencyMismatch: false,
|
|
374
|
+
billedToDate: null,
|
|
375
|
+
isActive: true,
|
|
376
|
+
createdAt: now,
|
|
377
|
+
updatedAt: now,
|
|
378
|
+
})
|
|
379
|
+
em.persist(item)
|
|
380
|
+
createdEntities.push(item)
|
|
381
|
+
const createdResult: BulkCreateResultEntry = {
|
|
382
|
+
sourceRef: entry.sourceRef ?? null,
|
|
383
|
+
id: '',
|
|
384
|
+
deduplicated: false,
|
|
385
|
+
}
|
|
386
|
+
results.push(createdResult)
|
|
387
|
+
deferredIdLinks.push({ result: createdResult, entity: item })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Single flush for the whole batch.
|
|
391
|
+
if (createdEntities.length > 0) {
|
|
392
|
+
await em.flush()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The BillingItem UUID PK is assigned by the flush — backfill it
|
|
396
|
+
// into the result entries now that the value exists.
|
|
397
|
+
for (const link of deferredIdLinks) {
|
|
398
|
+
link.result.id = link.entity.id
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Side effects after the commit. Emitting per-entity keeps the
|
|
402
|
+
// query-index + event contract identical to single-create — the
|
|
403
|
+
// engine / search index can't tell a bulk insert from N singles.
|
|
404
|
+
const dataEngine = getDataEngine(ctx)
|
|
405
|
+
for (const item of createdEntities) {
|
|
406
|
+
await emitCrudSideEffects({
|
|
407
|
+
dataEngine,
|
|
408
|
+
action: 'created',
|
|
409
|
+
entity: item,
|
|
410
|
+
identifiers: {
|
|
411
|
+
id: item.id,
|
|
412
|
+
tenantId: item.tenantId,
|
|
413
|
+
organizationId: item.organizationId,
|
|
414
|
+
},
|
|
415
|
+
indexer: itemIndexer,
|
|
416
|
+
events: itemEvents,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
created: createdEntities.length,
|
|
422
|
+
deduplicated: results.filter((r) => r.deduplicated).length,
|
|
423
|
+
items: results,
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
registerCommand(createItemCommand)
|
|
429
|
+
registerCommand(updateItemCommand)
|
|
430
|
+
registerCommand(deleteItemCommand)
|
|
431
|
+
registerCommand(bulkCreateItemsCommand)
|
|
432
|
+
|
|
433
|
+
export {
|
|
434
|
+
createItemCommand,
|
|
435
|
+
updateItemCommand,
|
|
436
|
+
deleteItemCommand,
|
|
437
|
+
bulkCreateItemsCommand,
|
|
438
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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 { BillRun, BillRunOutcome } from '../data/entities'
|
|
11
|
+
import {
|
|
12
|
+
billingRunRetryFailedSchema,
|
|
13
|
+
billingRunTriggerSchema,
|
|
14
|
+
type BillingRunRetryFailedInput,
|
|
15
|
+
type BillingRunTriggerInput,
|
|
16
|
+
} from '../data/validators'
|
|
17
|
+
import { runBillRun } from '../lib/billRunEngine'
|
|
18
|
+
import { TenantLockBusyError } from '../lib/tenantLock'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bill Run trigger commands.
|
|
22
|
+
*
|
|
23
|
+
* Both commands wrap `runBillRun(...)` from `lib/billRunEngine`. The
|
|
24
|
+
* lock-busy condition (concurrent run on the same tenant) is mapped to
|
|
25
|
+
* HTTP 409 here so the API surface returns the canonical Conflict
|
|
26
|
+
* response without each route knowing the lock primitive's
|
|
27
|
+
* implementation.
|
|
28
|
+
*
|
|
29
|
+
* `triggeredBy` is intentionally NOT exposed on the input schema —
|
|
30
|
+
* client-driven triggers are always `'manual'`. The scheduler worker
|
|
31
|
+
* (Phase 4) injects `'schedule'` directly through a separate
|
|
32
|
+
* orchestrator path, not through this command.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
36
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getContainer(ctx: CommandRuntimeContext): AwilixContainer {
|
|
40
|
+
return ctx.container as AwilixContainer
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asLockBusyError(err: unknown): err is TenantLockBusyError {
|
|
44
|
+
return err instanceof TenantLockBusyError
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Trigger ─────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
type TriggerResult = {
|
|
50
|
+
billRunId: string
|
|
51
|
+
status: string
|
|
52
|
+
outcomesCount: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const triggerRunCommand: CommandHandler<BillingRunTriggerInput, TriggerResult> = {
|
|
56
|
+
id: 'billing.runs.trigger',
|
|
57
|
+
|
|
58
|
+
async execute(rawInput, ctx) {
|
|
59
|
+
const parsed = billingRunTriggerSchema.parse(rawInput)
|
|
60
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
61
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
62
|
+
|
|
63
|
+
const em = getEm(ctx)
|
|
64
|
+
const asOfDate = parsed.asOfDate ?? new Date()
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
|
|
68
|
+
tenantId: parsed.tenantId,
|
|
69
|
+
organizationId: parsed.organizationId,
|
|
70
|
+
asOfDate,
|
|
71
|
+
triggeredBy: 'manual',
|
|
72
|
+
mode: parsed.mode,
|
|
73
|
+
scopedAccountIds: parsed.scopedAccountIds,
|
|
74
|
+
catchUp: parsed.catchUp,
|
|
75
|
+
})
|
|
76
|
+
return {
|
|
77
|
+
billRunId: billRun.id,
|
|
78
|
+
status: billRun.status,
|
|
79
|
+
outcomesCount: outcomes.length,
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (asLockBusyError(err)) {
|
|
83
|
+
// The route metadata maps `CrudHttpError(409)` to the canonical
|
|
84
|
+
// 409 Conflict response. The error body carries the
|
|
85
|
+
// `error_lock_busy` discriminator so clients can distinguish
|
|
86
|
+
// "lock busy" from other 409 conditions.
|
|
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
|
+
// ─── Retry failed ────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const retryFailedCommand: CommandHandler<BillingRunRetryFailedInput, TriggerResult> = {
|
|
102
|
+
id: 'billing.runs.retry_failed',
|
|
103
|
+
|
|
104
|
+
async execute(rawInput, ctx) {
|
|
105
|
+
const parsed = billingRunRetryFailedSchema.parse(rawInput)
|
|
106
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
107
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
108
|
+
|
|
109
|
+
const em = getEm(ctx)
|
|
110
|
+
const parentRun = await em.findOne(BillRun, {
|
|
111
|
+
id: parsed.billRunId,
|
|
112
|
+
tenantId: parsed.tenantId,
|
|
113
|
+
organizationId: parsed.organizationId,
|
|
114
|
+
deletedAt: null,
|
|
115
|
+
})
|
|
116
|
+
if (!parentRun) {
|
|
117
|
+
throw new CrudHttpError(404, { error: 'Parent Bill Run not found' })
|
|
118
|
+
}
|
|
119
|
+
const failedOutcomes = await em.find(BillRunOutcome, {
|
|
120
|
+
billRunId: parentRun.id,
|
|
121
|
+
tenantId: parsed.tenantId,
|
|
122
|
+
organizationId: parsed.organizationId,
|
|
123
|
+
status: 'failed',
|
|
124
|
+
})
|
|
125
|
+
if (failedOutcomes.length === 0) {
|
|
126
|
+
throw new CrudHttpError(409, {
|
|
127
|
+
error: 'Parent run has no failed outcomes to retry',
|
|
128
|
+
code: 'billing.run.nothing_to_retry',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const scopedAccountIds = Array.from(new Set(failedOutcomes.map((o) => o.billAccountId)))
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
|
|
135
|
+
tenantId: parsed.tenantId,
|
|
136
|
+
organizationId: parsed.organizationId,
|
|
137
|
+
asOfDate: parentRun.asOfDate,
|
|
138
|
+
triggeredBy: 'manual',
|
|
139
|
+
mode: parsed.mode,
|
|
140
|
+
scopedAccountIds,
|
|
141
|
+
parentRunId: parentRun.id,
|
|
142
|
+
catchUp: parentRun.catchUp,
|
|
143
|
+
})
|
|
144
|
+
return {
|
|
145
|
+
billRunId: billRun.id,
|
|
146
|
+
status: billRun.status,
|
|
147
|
+
outcomesCount: outcomes.length,
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (asLockBusyError(err)) {
|
|
151
|
+
throw new CrudHttpError(409, {
|
|
152
|
+
error: 'Another Bill Run is already in progress for this tenant',
|
|
153
|
+
code: 'billing.run.lock_busy',
|
|
154
|
+
tenantId: err.tenantId,
|
|
155
|
+
lockName: err.lockName,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
throw err
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
registerCommand(triggerRunCommand)
|
|
164
|
+
registerCommand(retryFailedCommand)
|
|
165
|
+
|
|
166
|
+
export { triggerRunCommand, retryFailedCommand }
|