@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,255 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { AwilixContainer } from 'awilix'
|
|
4
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
5
|
+
import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
|
+
import { BillingAccount, BillingItem } from '../../data/entities'
|
|
8
|
+
import { billingEntityIds } from '../../data/entityIds'
|
|
9
|
+
import {
|
|
10
|
+
billingItemCreateSchema,
|
|
11
|
+
billingItemListQuerySchema,
|
|
12
|
+
billingItemUpdateSchema,
|
|
13
|
+
} from '../../data/validators'
|
|
14
|
+
import {
|
|
15
|
+
createBillingCrudOpenApi,
|
|
16
|
+
createPagedListResponseSchema,
|
|
17
|
+
defaultOkResponseSchema,
|
|
18
|
+
} from '../openapi'
|
|
19
|
+
|
|
20
|
+
// ─── Auth / RBAC ─────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const routeMetadata = {
|
|
23
|
+
GET: { requireAuth: true, requireFeatures: ['billing.item.view'] },
|
|
24
|
+
POST: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
|
|
25
|
+
PUT: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
|
|
26
|
+
DELETE: { requireAuth: true, requireFeatures: ['billing.item.manage'] },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const metadata = routeMetadata
|
|
30
|
+
|
|
31
|
+
// The list endpoint projects raw column names — snake_case.
|
|
32
|
+
const billingItemListItemSchema = z.object({
|
|
33
|
+
id: z.string().uuid(),
|
|
34
|
+
bill_account_id: z.string().uuid(),
|
|
35
|
+
bill_account_name: z.string().nullable(),
|
|
36
|
+
type: z.string(),
|
|
37
|
+
bill_start_date: z.string(),
|
|
38
|
+
bill_end_date: z.string().nullable(),
|
|
39
|
+
description: z.string(),
|
|
40
|
+
rate_json: z.record(z.string(), z.unknown()),
|
|
41
|
+
uom_code: z.string().nullable(),
|
|
42
|
+
subscription_id: z.string().nullable(),
|
|
43
|
+
subscription_item_id: z.string().nullable(),
|
|
44
|
+
source_ref: z.string().nullable(),
|
|
45
|
+
currency_mismatch: z.boolean(),
|
|
46
|
+
billed_to_date: z.string().nullable(),
|
|
47
|
+
is_active: z.boolean(),
|
|
48
|
+
organization_id: z.string().uuid(),
|
|
49
|
+
tenant_id: z.string().uuid(),
|
|
50
|
+
created_at: z.string(),
|
|
51
|
+
updated_at: z.string(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Response for create includes `deduplicated` so integrators can tell
|
|
55
|
+
// whether their POST resulted in a new row or hit the idempotency
|
|
56
|
+
// short-circuit on `source_ref`.
|
|
57
|
+
const itemCreateResponseSchema = z.object({
|
|
58
|
+
id: z.string().uuid().nullable(),
|
|
59
|
+
deduplicated: z.boolean(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const rawBodySchema = z.object({}).passthrough()
|
|
63
|
+
|
|
64
|
+
const crud = makeCrudRoute({
|
|
65
|
+
metadata: routeMetadata,
|
|
66
|
+
orm: {
|
|
67
|
+
entity: BillingItem,
|
|
68
|
+
idField: 'id',
|
|
69
|
+
orgField: 'organizationId',
|
|
70
|
+
tenantField: 'tenantId',
|
|
71
|
+
softDeleteField: 'deletedAt',
|
|
72
|
+
},
|
|
73
|
+
list: {
|
|
74
|
+
schema: billingItemListQuerySchema,
|
|
75
|
+
entityId: billingEntityIds.item,
|
|
76
|
+
fields: [
|
|
77
|
+
'id',
|
|
78
|
+
'bill_account_id',
|
|
79
|
+
'type',
|
|
80
|
+
'bill_start_date',
|
|
81
|
+
'bill_end_date',
|
|
82
|
+
'description',
|
|
83
|
+
'rate_json',
|
|
84
|
+
'uom_code',
|
|
85
|
+
'subscription_id',
|
|
86
|
+
'subscription_item_id',
|
|
87
|
+
'source_ref',
|
|
88
|
+
'currency_mismatch',
|
|
89
|
+
'billed_to_date',
|
|
90
|
+
'is_active',
|
|
91
|
+
'organization_id',
|
|
92
|
+
'tenant_id',
|
|
93
|
+
'created_at',
|
|
94
|
+
'updated_at',
|
|
95
|
+
],
|
|
96
|
+
sortFieldMap: {
|
|
97
|
+
createdAt: 'created_at',
|
|
98
|
+
updatedAt: 'updated_at',
|
|
99
|
+
billStartDate: 'bill_start_date',
|
|
100
|
+
billEndDate: 'bill_end_date',
|
|
101
|
+
},
|
|
102
|
+
buildFilters: async (query) => {
|
|
103
|
+
const filters: Record<string, unknown> = {}
|
|
104
|
+
// `?id=<uuid>` narrows the list to one row — the detail page
|
|
105
|
+
// reads a single item through this filter.
|
|
106
|
+
if (typeof query.id === 'string' && query.id) {
|
|
107
|
+
filters.id = { $eq: query.id }
|
|
108
|
+
}
|
|
109
|
+
if (typeof query.search === 'string' && query.search.trim()) {
|
|
110
|
+
const pattern = `%${query.search.trim().replace(/[%_]/g, (m) => `\\${m}`)}%`
|
|
111
|
+
filters.description = { $ilike: pattern }
|
|
112
|
+
}
|
|
113
|
+
if (typeof query.billAccountId === 'string' && query.billAccountId) {
|
|
114
|
+
filters.bill_account_id = { $eq: query.billAccountId }
|
|
115
|
+
}
|
|
116
|
+
if (typeof query.type === 'string' && query.type) {
|
|
117
|
+
filters.type = { $eq: query.type }
|
|
118
|
+
}
|
|
119
|
+
if (typeof query.subscriptionId === 'string' && query.subscriptionId) {
|
|
120
|
+
filters.subscription_id = { $eq: query.subscriptionId }
|
|
121
|
+
}
|
|
122
|
+
if (typeof query.isActive === 'boolean') {
|
|
123
|
+
filters.is_active = { $eq: query.isActive }
|
|
124
|
+
}
|
|
125
|
+
// `activeAt=<date>` returns items whose validity window covers
|
|
126
|
+
// the supplied date (Bill Run engine uses the same predicate
|
|
127
|
+
// shape internally — see spec "Mid-cycle recurring items").
|
|
128
|
+
if (query.activeAt instanceof Date) {
|
|
129
|
+
filters.bill_start_date = { $lte: query.activeAt }
|
|
130
|
+
filters.$or = [
|
|
131
|
+
{ bill_end_date: null },
|
|
132
|
+
{ bill_end_date: { $gte: query.activeAt } },
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
return filters
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
actions: {
|
|
139
|
+
create: {
|
|
140
|
+
commandId: 'billing.items.create',
|
|
141
|
+
schema: rawBodySchema,
|
|
142
|
+
mapInput: async ({ raw, ctx }) => {
|
|
143
|
+
const { translate } = await resolveTranslations()
|
|
144
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
145
|
+
return billingItemCreateSchema.parse(scoped)
|
|
146
|
+
},
|
|
147
|
+
response: ({ result }) => ({
|
|
148
|
+
id: result?.id ?? null,
|
|
149
|
+
deduplicated: Boolean(result?.deduplicated),
|
|
150
|
+
}),
|
|
151
|
+
// Idempotent create returns 200 (vs 201). The spec mandates
|
|
152
|
+
// duplicate POST returns the existing row, never creates a
|
|
153
|
+
// duplicate — keeping the success status consistent makes
|
|
154
|
+
// client-side handling trivial.
|
|
155
|
+
status: 200,
|
|
156
|
+
},
|
|
157
|
+
update: {
|
|
158
|
+
commandId: 'billing.items.update',
|
|
159
|
+
schema: rawBodySchema,
|
|
160
|
+
mapInput: async ({ raw, ctx }) => {
|
|
161
|
+
const { translate } = await resolveTranslations()
|
|
162
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
163
|
+
return billingItemUpdateSchema.parse(scoped)
|
|
164
|
+
},
|
|
165
|
+
response: () => ({ ok: true }),
|
|
166
|
+
},
|
|
167
|
+
delete: {
|
|
168
|
+
commandId: 'billing.items.delete',
|
|
169
|
+
schema: rawBodySchema,
|
|
170
|
+
mapInput: async ({ raw, ctx }) => {
|
|
171
|
+
const { translate } = await resolveTranslations()
|
|
172
|
+
// The factory hands delete a `{ body, query }` envelope — the
|
|
173
|
+
// id may arrive in either, depending on the caller.
|
|
174
|
+
const envelope = (raw ?? {}) as {
|
|
175
|
+
body?: Record<string, unknown>
|
|
176
|
+
query?: Record<string, unknown>
|
|
177
|
+
}
|
|
178
|
+
const body = envelope.body ?? {}
|
|
179
|
+
const query = envelope.query ?? {}
|
|
180
|
+
const scoped = withScopedPayload(body, ctx, translate)
|
|
181
|
+
const id =
|
|
182
|
+
(typeof body.id === 'string' && body.id) ||
|
|
183
|
+
(typeof query.id === 'string' && query.id) ||
|
|
184
|
+
null
|
|
185
|
+
if (!id) {
|
|
186
|
+
throw new Error('id is required')
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
id,
|
|
190
|
+
tenantId: (scoped as { tenantId?: string }).tenantId,
|
|
191
|
+
organizationId: (scoped as { organizationId?: string }).organizationId,
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
response: () => ({ ok: true }),
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
hooks: {
|
|
198
|
+
// Items reference their account by FK id only; the list + detail UI
|
|
199
|
+
// wants the human-readable account name. One bounded query
|
|
200
|
+
// (≤ pageSize ids) resolves it after the page is fetched.
|
|
201
|
+
afterList: async (payload: unknown, ctx: unknown) => {
|
|
202
|
+
const items = ((payload as { items?: unknown[] } | null)?.items ?? []) as Array<
|
|
203
|
+
Record<string, unknown>
|
|
204
|
+
>
|
|
205
|
+
const accountIds = Array.from(
|
|
206
|
+
new Set(
|
|
207
|
+
items
|
|
208
|
+
.map((row) => row.bill_account_id)
|
|
209
|
+
.filter((v): v is string => typeof v === 'string' && v.length > 0),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
if (accountIds.length === 0) return
|
|
213
|
+
const em = (ctx as { container: AwilixContainer }).container.resolve<EntityManager>('em')
|
|
214
|
+
const accounts = await em.find(BillingAccount, { id: { $in: accountIds } })
|
|
215
|
+
const nameById = new Map(
|
|
216
|
+
accounts.map((a) => [String(a.id), a.name] as [string, unknown]),
|
|
217
|
+
)
|
|
218
|
+
for (const row of items) {
|
|
219
|
+
row.bill_account_name = nameById.get(String(row.bill_account_id)) ?? null
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const { POST, PUT, DELETE } = crud
|
|
226
|
+
export { POST, PUT, DELETE }
|
|
227
|
+
export const GET = crud.GET
|
|
228
|
+
|
|
229
|
+
export const openApi = createBillingCrudOpenApi({
|
|
230
|
+
resourceName: 'BillingItem',
|
|
231
|
+
pluralName: 'BillingItems',
|
|
232
|
+
querySchema: billingItemListQuerySchema,
|
|
233
|
+
listResponseSchema: createPagedListResponseSchema(billingItemListItemSchema),
|
|
234
|
+
create: {
|
|
235
|
+
schema: billingItemCreateSchema,
|
|
236
|
+
responseSchema: itemCreateResponseSchema,
|
|
237
|
+
description:
|
|
238
|
+
'Creates a billing item. Idempotent via `sourceRef`: re-POSTing the same ' +
|
|
239
|
+
'`(tenantId, billAccountId, sourceRef)` returns the existing row with ' +
|
|
240
|
+
'`deduplicated: true` instead of creating a duplicate. The unique index ' +
|
|
241
|
+
'on `(tenant_id, bill_account_id, source_ref)` is the correctness gate.',
|
|
242
|
+
},
|
|
243
|
+
update: {
|
|
244
|
+
schema: billingItemUpdateSchema,
|
|
245
|
+
responseSchema: defaultOkResponseSchema,
|
|
246
|
+
description:
|
|
247
|
+
'Updates a billing item. `type` and `sourceRef` are immutable — to change ' +
|
|
248
|
+
'either, soft-delete the item and create a new one.',
|
|
249
|
+
},
|
|
250
|
+
del: {
|
|
251
|
+
schema: z.object({ id: z.string().uuid() }),
|
|
252
|
+
responseSchema: defaultOkResponseSchema,
|
|
253
|
+
description: 'Soft-deletes a billing item.',
|
|
254
|
+
},
|
|
255
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCrudOpenApiFactory,
|
|
3
|
+
createPagedListResponseSchema as createSharedPagedListResponseSchema,
|
|
4
|
+
defaultCreateResponseSchema,
|
|
5
|
+
defaultOkResponseSchema,
|
|
6
|
+
type CrudOpenApiOptions,
|
|
7
|
+
} from '@open-mercato/shared/lib/openapi/crud'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
|
|
9
|
+
import type { ZodTypeAny } from 'zod'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Billing CRUD OpenAPI factory. Every billing API route imports this
|
|
13
|
+
* and calls it with the resource's `create` / `update` / `del` schemas
|
|
14
|
+
* — the framework picks up the resulting `openApi` export and merges
|
|
15
|
+
* it into the global API documentation bundle.
|
|
16
|
+
*/
|
|
17
|
+
const buildBillingCrudOpenApi = createCrudOpenApiFactory({
|
|
18
|
+
defaultTag: 'Billing',
|
|
19
|
+
defaultCreateResponseSchema,
|
|
20
|
+
defaultOkResponseSchema,
|
|
21
|
+
makeListDescription: ({ pluralLower }) =>
|
|
22
|
+
`Returns a paginated collection of ${pluralLower} scoped to the authenticated organization.`,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export function createBillingCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {
|
|
26
|
+
return buildBillingCrudOpenApi(options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPagedListResponseSchema(itemSchema: ZodTypeAny) {
|
|
30
|
+
return createSharedPagedListResponseSchema(itemSchema, { paginationMetaOptional: true })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { defaultCreateResponseSchema, defaultOkResponseSchema }
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import type { AwilixContainer } from 'awilix'
|
|
4
|
+
import { SalesInvoice } from '@open-mercato/core/modules/sales/data/entities'
|
|
5
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
6
|
+
import { BillingAccount, BillRunOutcome } from '../../data/entities'
|
|
7
|
+
import { billingEntityIds } from '../../data/entityIds'
|
|
8
|
+
import {
|
|
9
|
+
BILL_RUN_OUTCOME_STATUSES,
|
|
10
|
+
} from '../../data/validators'
|
|
11
|
+
import { createBillingCrudOpenApi, createPagedListResponseSchema } from '../openapi'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `GET /api/billing/run-outcomes` — list per-account outcomes,
|
|
15
|
+
* typically filtered by `billRunId` to populate the "show me what
|
|
16
|
+
* happened in this run" detail panel.
|
|
17
|
+
*
|
|
18
|
+
* Read-only; outcomes are written by the engine itself, never by API
|
|
19
|
+
* clients.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const routeMetadata = {
|
|
23
|
+
GET: { requireAuth: true, requireFeatures: ['billing.run.view'] },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const metadata = routeMetadata
|
|
27
|
+
|
|
28
|
+
const listQuerySchema = z
|
|
29
|
+
.object({
|
|
30
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
31
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(50),
|
|
32
|
+
sortField: z.string().optional(),
|
|
33
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
34
|
+
billRunId: z.string().uuid().optional(),
|
|
35
|
+
billAccountId: z.string().uuid().optional(),
|
|
36
|
+
status: z.enum(BILL_RUN_OUTCOME_STATUSES).optional(),
|
|
37
|
+
})
|
|
38
|
+
.passthrough()
|
|
39
|
+
|
|
40
|
+
const billRunOutcomeListItemSchema = z.object({
|
|
41
|
+
id: z.string().uuid(),
|
|
42
|
+
billRunId: z.string().uuid(),
|
|
43
|
+
billAccountId: z.string().uuid(),
|
|
44
|
+
status: z.string(),
|
|
45
|
+
errorMessage: z.string().nullable(),
|
|
46
|
+
warnings: z.record(z.string(), z.unknown()).nullable(),
|
|
47
|
+
draftInvoiceId: z.string().uuid().nullable(),
|
|
48
|
+
bill_account_name: z.string().nullable(),
|
|
49
|
+
invoice_number: z.string().nullable(),
|
|
50
|
+
organizationId: z.string().uuid(),
|
|
51
|
+
tenantId: z.string().uuid(),
|
|
52
|
+
createdAt: z.string(),
|
|
53
|
+
updatedAt: z.string(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const crud = makeCrudRoute({
|
|
57
|
+
metadata: routeMetadata,
|
|
58
|
+
orm: {
|
|
59
|
+
entity: BillRunOutcome,
|
|
60
|
+
idField: 'id',
|
|
61
|
+
orgField: 'organizationId',
|
|
62
|
+
tenantField: 'tenantId',
|
|
63
|
+
softDeleteField: 'deletedAt',
|
|
64
|
+
},
|
|
65
|
+
list: {
|
|
66
|
+
schema: listQuerySchema,
|
|
67
|
+
entityId: billingEntityIds.runOutcome,
|
|
68
|
+
fields: [
|
|
69
|
+
'id',
|
|
70
|
+
'bill_run_id',
|
|
71
|
+
'bill_account_id',
|
|
72
|
+
'status',
|
|
73
|
+
'error_message',
|
|
74
|
+
'warnings',
|
|
75
|
+
'draft_invoice_id',
|
|
76
|
+
'organization_id',
|
|
77
|
+
'tenant_id',
|
|
78
|
+
'created_at',
|
|
79
|
+
'updated_at',
|
|
80
|
+
],
|
|
81
|
+
sortFieldMap: {
|
|
82
|
+
createdAt: 'created_at',
|
|
83
|
+
},
|
|
84
|
+
buildFilters: async (query) => {
|
|
85
|
+
const filters: Record<string, unknown> = {}
|
|
86
|
+
if (typeof query.billRunId === 'string') filters.bill_run_id = { $eq: query.billRunId }
|
|
87
|
+
if (typeof query.billAccountId === 'string') {
|
|
88
|
+
filters.bill_account_id = { $eq: query.billAccountId }
|
|
89
|
+
}
|
|
90
|
+
if (typeof query.status === 'string') filters.status = { $eq: query.status }
|
|
91
|
+
return filters
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
hooks: {
|
|
95
|
+
// Outcomes reference account + draft invoice by FK id only. The
|
|
96
|
+
// run detail UI wants names — resolve both in two bounded queries.
|
|
97
|
+
afterList: async (payload: unknown, ctx: unknown) => {
|
|
98
|
+
const items = ((payload as { items?: unknown[] } | null)?.items ?? []) as Array<
|
|
99
|
+
Record<string, unknown>
|
|
100
|
+
>
|
|
101
|
+
if (items.length === 0) return
|
|
102
|
+
const em = (ctx as { container: AwilixContainer }).container.resolve<EntityManager>('em')
|
|
103
|
+
const accountIds = Array.from(
|
|
104
|
+
new Set(
|
|
105
|
+
items
|
|
106
|
+
.map((row) => row.bill_account_id)
|
|
107
|
+
.filter((v): v is string => typeof v === 'string' && v.length > 0),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
const invoiceIds = Array.from(
|
|
111
|
+
new Set(
|
|
112
|
+
items
|
|
113
|
+
.map((row) => row.draft_invoice_id)
|
|
114
|
+
.filter((v): v is string => typeof v === 'string' && v.length > 0),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
const [accounts, invoices] = await Promise.all([
|
|
118
|
+
accountIds.length
|
|
119
|
+
? em.find(BillingAccount, { id: { $in: accountIds } })
|
|
120
|
+
: [],
|
|
121
|
+
invoiceIds.length
|
|
122
|
+
? em.find(SalesInvoice, { id: { $in: invoiceIds } })
|
|
123
|
+
: [],
|
|
124
|
+
])
|
|
125
|
+
const accountName = new Map(
|
|
126
|
+
accounts.map((a) => [String(a.id), a.name] as [string, unknown]),
|
|
127
|
+
)
|
|
128
|
+
const invoiceNumber = new Map(
|
|
129
|
+
invoices.map((i) => [String(i.id), i.invoiceNumber] as [string, unknown]),
|
|
130
|
+
)
|
|
131
|
+
for (const row of items) {
|
|
132
|
+
row.bill_account_name = accountName.get(String(row.bill_account_id)) ?? null
|
|
133
|
+
row.invoice_number =
|
|
134
|
+
typeof row.draft_invoice_id === 'string'
|
|
135
|
+
? invoiceNumber.get(row.draft_invoice_id) ?? null
|
|
136
|
+
: null
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
export const GET = crud.GET
|
|
143
|
+
|
|
144
|
+
export const openApi = createBillingCrudOpenApi({
|
|
145
|
+
resourceName: 'BillRunOutcome',
|
|
146
|
+
pluralName: 'BillRunOutcomes',
|
|
147
|
+
querySchema: listQuerySchema,
|
|
148
|
+
listResponseSchema: createPagedListResponseSchema(billRunOutcomeListItemSchema),
|
|
149
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
3
|
+
import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import { BillRun } from '../../../data/entities'
|
|
6
|
+
import { billingEntityIds } from '../../../data/entityIds'
|
|
7
|
+
import { billingRunRetryFailedSchema } from '../../../data/validators'
|
|
8
|
+
import { createBillingCrudOpenApi } from '../../openapi'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `POST /api/billing/runs/retry-failed`
|
|
12
|
+
*
|
|
13
|
+
* Creates a new Bill Run scoped to the failed-account ids from a parent
|
|
14
|
+
* run, with `parent_run_id` set on the new run so the retry chain is
|
|
15
|
+
* traceable. Returns the new run's id + status + outcomes count.
|
|
16
|
+
*
|
|
17
|
+
* Implemented as a "create-only" `makeCrudRoute` so the framework's
|
|
18
|
+
* RBAC + OpenAPI + command-bus plumbing stays consistent with the rest
|
|
19
|
+
* of the billing surface. The list/get half of the factory is never
|
|
20
|
+
* exercised on this URL — only POST is exported.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const routeMetadata = {
|
|
24
|
+
POST: { requireAuth: true, requireFeatures: ['billing.run.trigger'] },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const metadata = routeMetadata
|
|
28
|
+
|
|
29
|
+
const retryResponseSchema = z.object({
|
|
30
|
+
billRunId: z.string().uuid(),
|
|
31
|
+
status: z.string(),
|
|
32
|
+
outcomesCount: z.number().int().nonnegative(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const rawBodySchema = z.object({}).passthrough()
|
|
36
|
+
|
|
37
|
+
const crud = makeCrudRoute({
|
|
38
|
+
metadata: routeMetadata,
|
|
39
|
+
orm: {
|
|
40
|
+
entity: BillRun,
|
|
41
|
+
idField: 'id',
|
|
42
|
+
orgField: 'organizationId',
|
|
43
|
+
tenantField: 'tenantId',
|
|
44
|
+
softDeleteField: 'deletedAt',
|
|
45
|
+
},
|
|
46
|
+
// No `list` — POST-only route. Keep a stub so the factory type-checks.
|
|
47
|
+
list: {
|
|
48
|
+
schema: z.object({}).passthrough(),
|
|
49
|
+
entityId: billingEntityIds.run,
|
|
50
|
+
fields: ['id'],
|
|
51
|
+
buildFilters: async () => ({}),
|
|
52
|
+
},
|
|
53
|
+
actions: {
|
|
54
|
+
create: {
|
|
55
|
+
commandId: 'billing.runs.retry_failed',
|
|
56
|
+
schema: rawBodySchema,
|
|
57
|
+
mapInput: async ({ raw, ctx }) => {
|
|
58
|
+
const { translate } = await resolveTranslations()
|
|
59
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
60
|
+
return billingRunRetryFailedSchema.parse(scoped)
|
|
61
|
+
},
|
|
62
|
+
response: ({ result }) => ({
|
|
63
|
+
billRunId: result?.billRunId ?? null,
|
|
64
|
+
status: result?.status ?? null,
|
|
65
|
+
outcomesCount: result?.outcomesCount ?? 0,
|
|
66
|
+
}),
|
|
67
|
+
status: 201,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const POST = crud.POST
|
|
73
|
+
|
|
74
|
+
export const openApi = createBillingCrudOpenApi({
|
|
75
|
+
resourceName: 'BillRunRetry',
|
|
76
|
+
pluralName: 'BillRunRetries',
|
|
77
|
+
querySchema: z.object({}).passthrough(),
|
|
78
|
+
listResponseSchema: z.object({ items: z.array(z.unknown()) }),
|
|
79
|
+
create: {
|
|
80
|
+
schema: billingRunRetryFailedSchema,
|
|
81
|
+
responseSchema: retryResponseSchema,
|
|
82
|
+
description:
|
|
83
|
+
'Re-runs the failed accounts of an earlier Bill Run. The new run ' +
|
|
84
|
+
'inherits the parent run\'s `asOfDate` and `catchUp` flag; its ' +
|
|
85
|
+
'`parent_run_id` points back at the source. Returns 409 if there ' +
|
|
86
|
+
'are no failed outcomes or if another run is in flight.',
|
|
87
|
+
},
|
|
88
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
3
|
+
import { withScopedPayload } from '@open-mercato/shared/lib/api/scoped'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import { BillRun } from '../../data/entities'
|
|
6
|
+
import { billingEntityIds } from '../../data/entityIds'
|
|
7
|
+
import {
|
|
8
|
+
billingRunListQuerySchema,
|
|
9
|
+
billingRunTriggerSchema,
|
|
10
|
+
} from '../../data/validators'
|
|
11
|
+
import { createBillingCrudOpenApi, createPagedListResponseSchema } from '../openapi'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `/api/billing/runs` — GET (history list) + POST (manual trigger).
|
|
15
|
+
*
|
|
16
|
+
* The triggered-by enum gate:
|
|
17
|
+
* - `trigger` feature is required for `real` mode (which mutates
|
|
18
|
+
* production state).
|
|
19
|
+
* - `dry_run` feature alone is enough for `mode=dry` (finance auditor
|
|
20
|
+
* persona). The route-level RBAC check on POST requires `trigger`,
|
|
21
|
+
* so dry-run callers must hold both `dry_run` AND `trigger` here —
|
|
22
|
+
* a deliberate tightening over what the spec persona table suggests.
|
|
23
|
+
*
|
|
24
|
+
* If we ever want auditors to drive dry-runs without holding `trigger`,
|
|
25
|
+
* we split into two routes (POST `/runs` for real, POST `/runs/dry` for
|
|
26
|
+
* dry). Postponed until an operator actually asks.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const routeMetadata = {
|
|
30
|
+
GET: { requireAuth: true, requireFeatures: ['billing.run.view'] },
|
|
31
|
+
POST: { requireAuth: true, requireFeatures: ['billing.run.trigger'] },
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const metadata = routeMetadata
|
|
35
|
+
|
|
36
|
+
// The list endpoint projects raw column names — snake_case.
|
|
37
|
+
const billRunListItemSchema = z.object({
|
|
38
|
+
id: z.string().uuid(),
|
|
39
|
+
triggered_by: z.string(),
|
|
40
|
+
parent_run_id: z.string().uuid().nullable(),
|
|
41
|
+
dry_run: z.boolean(),
|
|
42
|
+
test_mode: z.boolean(),
|
|
43
|
+
catch_up: z.boolean(),
|
|
44
|
+
scoped_account_ids: z.array(z.string().uuid()).nullable(),
|
|
45
|
+
as_of_date: z.string(),
|
|
46
|
+
started_at: z.string().nullable(),
|
|
47
|
+
finished_at: z.string().nullable(),
|
|
48
|
+
status: z.string(),
|
|
49
|
+
summary: z.record(z.string(), z.unknown()).nullable(),
|
|
50
|
+
organization_id: z.string().uuid(),
|
|
51
|
+
tenant_id: z.string().uuid(),
|
|
52
|
+
created_at: z.string(),
|
|
53
|
+
updated_at: z.string(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const triggerResponseSchema = z.object({
|
|
57
|
+
billRunId: z.string().uuid(),
|
|
58
|
+
status: z.string(),
|
|
59
|
+
outcomesCount: z.number().int().nonnegative(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const rawBodySchema = z.object({}).passthrough()
|
|
63
|
+
|
|
64
|
+
const crud = makeCrudRoute({
|
|
65
|
+
metadata: routeMetadata,
|
|
66
|
+
orm: {
|
|
67
|
+
entity: BillRun,
|
|
68
|
+
idField: 'id',
|
|
69
|
+
orgField: 'organizationId',
|
|
70
|
+
tenantField: 'tenantId',
|
|
71
|
+
softDeleteField: 'deletedAt',
|
|
72
|
+
},
|
|
73
|
+
list: {
|
|
74
|
+
schema: billingRunListQuerySchema,
|
|
75
|
+
entityId: billingEntityIds.run,
|
|
76
|
+
fields: [
|
|
77
|
+
'id',
|
|
78
|
+
'triggered_by',
|
|
79
|
+
'parent_run_id',
|
|
80
|
+
'dry_run',
|
|
81
|
+
'test_mode',
|
|
82
|
+
'catch_up',
|
|
83
|
+
'scoped_account_ids',
|
|
84
|
+
'as_of_date',
|
|
85
|
+
'started_at',
|
|
86
|
+
'finished_at',
|
|
87
|
+
'status',
|
|
88
|
+
'summary',
|
|
89
|
+
'organization_id',
|
|
90
|
+
'tenant_id',
|
|
91
|
+
'created_at',
|
|
92
|
+
'updated_at',
|
|
93
|
+
],
|
|
94
|
+
sortFieldMap: {
|
|
95
|
+
startedAt: 'started_at',
|
|
96
|
+
finishedAt: 'finished_at',
|
|
97
|
+
asOfDate: 'as_of_date',
|
|
98
|
+
createdAt: 'created_at',
|
|
99
|
+
},
|
|
100
|
+
buildFilters: async (query) => {
|
|
101
|
+
const filters: Record<string, unknown> = {}
|
|
102
|
+
// `?id=<uuid>` narrows the list to one row — the detail page
|
|
103
|
+
// reads a single Bill Run through this filter.
|
|
104
|
+
if (typeof query.id === 'string' && query.id) {
|
|
105
|
+
filters.id = { $eq: query.id }
|
|
106
|
+
}
|
|
107
|
+
// Run ids are UUIDs — search matches a pasted id exactly.
|
|
108
|
+
if (typeof query.search === 'string' && /^[0-9a-fA-F-]{36}$/.test(query.search.trim())) {
|
|
109
|
+
filters.id = { $eq: query.search.trim() }
|
|
110
|
+
}
|
|
111
|
+
if (typeof query.status === 'string') filters.status = { $eq: query.status }
|
|
112
|
+
if (typeof query.triggeredBy === 'string') filters.triggered_by = { $eq: query.triggeredBy }
|
|
113
|
+
if (typeof query.parentRunId === 'string') filters.parent_run_id = { $eq: query.parentRunId }
|
|
114
|
+
if (query.asOfDateFrom instanceof Date) {
|
|
115
|
+
filters.as_of_date = Object.assign(
|
|
116
|
+
(filters.as_of_date as Record<string, unknown>) ?? {},
|
|
117
|
+
{ $gte: query.asOfDateFrom },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
if (query.asOfDateTo instanceof Date) {
|
|
121
|
+
filters.as_of_date = Object.assign(
|
|
122
|
+
(filters.as_of_date as Record<string, unknown>) ?? {},
|
|
123
|
+
{ $lte: query.asOfDateTo },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return filters
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
actions: {
|
|
130
|
+
create: {
|
|
131
|
+
commandId: 'billing.runs.trigger',
|
|
132
|
+
schema: rawBodySchema,
|
|
133
|
+
mapInput: async ({ raw, ctx }) => {
|
|
134
|
+
const { translate } = await resolveTranslations()
|
|
135
|
+
const scoped = withScopedPayload(raw ?? {}, ctx, translate)
|
|
136
|
+
return billingRunTriggerSchema.parse(scoped)
|
|
137
|
+
},
|
|
138
|
+
response: ({ result }) => ({
|
|
139
|
+
billRunId: result?.billRunId ?? null,
|
|
140
|
+
status: result?.status ?? null,
|
|
141
|
+
outcomesCount: result?.outcomesCount ?? 0,
|
|
142
|
+
}),
|
|
143
|
+
status: 201,
|
|
144
|
+
},
|
|
145
|
+
// Runs are append-only — no update / delete on this surface. A
|
|
146
|
+
// failed run is corrected by triggering a `retry-failed` (separate
|
|
147
|
+
// route).
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const { POST } = crud
|
|
152
|
+
export { POST }
|
|
153
|
+
export const GET = crud.GET
|
|
154
|
+
|
|
155
|
+
export const openApi = createBillingCrudOpenApi({
|
|
156
|
+
resourceName: 'BillRun',
|
|
157
|
+
pluralName: 'BillRuns',
|
|
158
|
+
querySchema: billingRunListQuerySchema,
|
|
159
|
+
listResponseSchema: createPagedListResponseSchema(billRunListItemSchema),
|
|
160
|
+
create: {
|
|
161
|
+
schema: billingRunTriggerSchema,
|
|
162
|
+
responseSchema: triggerResponseSchema,
|
|
163
|
+
description:
|
|
164
|
+
'Triggers a Bill Run. `mode=real` (default) persists drafts and ' +
|
|
165
|
+
'advances state; `mode=test` persists test-flagged drafts without ' +
|
|
166
|
+
'advancing state; `mode=dry` computes everything in memory and ' +
|
|
167
|
+
'persists nothing. Returns HTTP 409 (`billing.run.lock_busy`) when ' +
|
|
168
|
+
'another run is in flight for the same tenant.',
|
|
169
|
+
},
|
|
170
|
+
})
|