@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,260 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
3
|
+
import {
|
|
4
|
+
SalesInvoice,
|
|
5
|
+
SalesInvoiceLine,
|
|
6
|
+
} from '@open-mercato/core/modules/sales/data/entities'
|
|
7
|
+
import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
|
|
8
|
+
import type { BillingAccount, BillingItem } from '../data/entities'
|
|
9
|
+
import type { BillPeriod } from './billPeriod'
|
|
10
|
+
import {
|
|
11
|
+
__resetInvoiceStatusCacheForTests,
|
|
12
|
+
resolveInvoiceStatusEntryId,
|
|
13
|
+
} from './invoiceStatus'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writes a `core/sales` draft invoice for a billing account's bill period.
|
|
17
|
+
*
|
|
18
|
+
* Two notable deviations from the spec, documented in CHANGELOG / spec:
|
|
19
|
+
*
|
|
20
|
+
* 1. `SalesInvoice.invoice_number` is `NOT NULL` in the upstream
|
|
21
|
+
* schema, but the spec says "sequence number assigned at Post."
|
|
22
|
+
* Real-mode invoices get a real number via
|
|
23
|
+
* `salesDocumentNumberGenerator.generate({ kind: 'invoice' })` at
|
|
24
|
+
* *draft create time* — the spec's intent (gap-free numbering via
|
|
25
|
+
* atomic SQL upsert) is preserved; the timing differs because the
|
|
26
|
+
* upstream column is `NOT NULL`. "Post" then just transitions
|
|
27
|
+
* status `draft → posted`.
|
|
28
|
+
*
|
|
29
|
+
* 2. Test-mode invoices use a `TEST-<uuid>` placeholder number (not
|
|
30
|
+
* the real sequence) so test runs do not burn production numbers
|
|
31
|
+
* and can be repeated idempotently.
|
|
32
|
+
*
|
|
33
|
+
* VAT is NOT computed here — `core/sales`'s tax service owns the
|
|
34
|
+
* per-line `tax_rate` / `tax_amount` calculation, and runs on POST.
|
|
35
|
+
* Drafts therefore carry zeroes in the tax columns; the totals reflect
|
|
36
|
+
* net amounts only.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type WriteMode = 'real' | 'test' | 'dry'
|
|
40
|
+
|
|
41
|
+
export type InvoiceLineDescriptor = {
|
|
42
|
+
/** Maps to billing_item_id on the resulting SalesInvoiceLine.metadata. */
|
|
43
|
+
billingItemId: string
|
|
44
|
+
/** From the spec's `metadata.billing_type` for traceability. */
|
|
45
|
+
billingType: 'one_time' | 'recurring' | 'usage'
|
|
46
|
+
description: string
|
|
47
|
+
/** Net unit price (numeric string for SalesInvoiceLine.unit_price_net). */
|
|
48
|
+
unitPriceNet: string
|
|
49
|
+
/** Quantity (string for the numeric(18,4) column). */
|
|
50
|
+
quantity: string
|
|
51
|
+
/** Total net (string). */
|
|
52
|
+
totalNetAmount: string
|
|
53
|
+
/** Optional per-line metadata extension — e.g. usage tier breakdown. */
|
|
54
|
+
metadataExtra?: Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WriteInvoiceParams = {
|
|
58
|
+
mode: WriteMode
|
|
59
|
+
account: BillingAccount
|
|
60
|
+
period: BillPeriod
|
|
61
|
+
billRunId: string
|
|
62
|
+
lines: InvoiceLineDescriptor[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type WrittenInvoice = {
|
|
66
|
+
invoiceId: string | null
|
|
67
|
+
invoiceNumber: string | null
|
|
68
|
+
totalNet: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DRAFT_INVOICE_STATUS_VALUE = 'draft'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Test hook — clears the shared resolver's status cache so the engine
|
|
75
|
+
* test suite stays deterministic across mocked-EM environments.
|
|
76
|
+
*/
|
|
77
|
+
export function __resetInvoiceWriterCacheForTests(): void {
|
|
78
|
+
__resetInvoiceStatusCacheForTests()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function generateTestInvoiceNumber(): string {
|
|
82
|
+
// Match the pattern operators search by: `TEST-` prefix + random
|
|
83
|
+
// suffix so the test wipe-out endpoint can filter via `LIKE 'TEST-%'`
|
|
84
|
+
// (defensive — primary filter is `metadata.test_run=true`).
|
|
85
|
+
const random = Math.floor(Math.random() * 1e9)
|
|
86
|
+
.toString(16)
|
|
87
|
+
.padStart(8, '0')
|
|
88
|
+
return `TEST-${Date.now().toString(16)}-${random}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 2dp half-up rounding that survives binary floating-point edge cases
|
|
93
|
+
* (e.g. `1.005 * 100 === 100.49999...` would otherwise round DOWN to
|
|
94
|
+
* `1.00` under naive `Math.round`). The `+ EPSILON` nudge pushes
|
|
95
|
+
* representations like 100.4999...8 up to the true half-up boundary.
|
|
96
|
+
* Matches the spec's "2 decimal places, half-up" rounding policy.
|
|
97
|
+
*/
|
|
98
|
+
function roundHalfUp2dp(value: number): number {
|
|
99
|
+
return Math.round((value + Number.EPSILON) * 100) / 100
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function format4dp(value: number): string {
|
|
103
|
+
return roundHalfUp2dp(value).toFixed(4)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sumNetTotal(lines: InvoiceLineDescriptor[]): string {
|
|
107
|
+
let total = 0
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
total += Number.parseFloat(line.totalNetAmount)
|
|
110
|
+
}
|
|
111
|
+
return format4dp(total)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function writeDraftInvoice(
|
|
115
|
+
em: EntityManager,
|
|
116
|
+
container: AwilixContainer,
|
|
117
|
+
params: WriteInvoiceParams,
|
|
118
|
+
): Promise<WrittenInvoice> {
|
|
119
|
+
const totalNet = sumNetTotal(params.lines)
|
|
120
|
+
|
|
121
|
+
if (params.mode === 'dry') {
|
|
122
|
+
// Compute totals; persist nothing.
|
|
123
|
+
return { invoiceId: null, invoiceNumber: null, totalNet }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { account, period, billRunId, lines, mode } = params
|
|
127
|
+
|
|
128
|
+
let invoiceNumber: string
|
|
129
|
+
if (mode === 'test') {
|
|
130
|
+
invoiceNumber = generateTestInvoiceNumber()
|
|
131
|
+
} else {
|
|
132
|
+
const numberGenerator = container.resolve(
|
|
133
|
+
'salesDocumentNumberGenerator',
|
|
134
|
+
) as SalesDocumentNumberGenerator
|
|
135
|
+
const generated = await numberGenerator.generate({
|
|
136
|
+
organizationId: account.organizationId,
|
|
137
|
+
tenantId: account.tenantId,
|
|
138
|
+
kind: 'invoice',
|
|
139
|
+
})
|
|
140
|
+
invoiceNumber = generated.number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const statusEntryId = await resolveInvoiceStatusEntryId(
|
|
144
|
+
em,
|
|
145
|
+
account.tenantId,
|
|
146
|
+
account.organizationId,
|
|
147
|
+
'draft',
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const now = new Date()
|
|
151
|
+
const invoice = em.create(SalesInvoice, {
|
|
152
|
+
organizationId: account.organizationId,
|
|
153
|
+
tenantId: account.tenantId,
|
|
154
|
+
invoiceNumber,
|
|
155
|
+
statusEntryId,
|
|
156
|
+
status: DRAFT_INVOICE_STATUS_VALUE,
|
|
157
|
+
issueDate: now,
|
|
158
|
+
currencyCode: account.currencyCode,
|
|
159
|
+
subtotalNetAmount: totalNet,
|
|
160
|
+
subtotalGrossAmount: totalNet, // gross == net for drafts (tax computed on post)
|
|
161
|
+
discountTotalAmount: '0',
|
|
162
|
+
taxTotalAmount: '0',
|
|
163
|
+
grandTotalNetAmount: totalNet,
|
|
164
|
+
grandTotalGrossAmount: totalNet,
|
|
165
|
+
paidTotalAmount: '0',
|
|
166
|
+
outstandingAmount: totalNet,
|
|
167
|
+
metadata: {
|
|
168
|
+
bill_run_id: billRunId,
|
|
169
|
+
bill_account_id: account.id,
|
|
170
|
+
bill_period_start: period.periodStart.toISOString().slice(0, 10),
|
|
171
|
+
bill_period_end: period.periodEnd.toISOString().slice(0, 10),
|
|
172
|
+
test_run: mode === 'test' ? true : undefined,
|
|
173
|
+
},
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
})
|
|
177
|
+
em.persist(invoice)
|
|
178
|
+
|
|
179
|
+
let lineNumber = 1
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
const invoiceLine = em.create(SalesInvoiceLine, {
|
|
182
|
+
invoice,
|
|
183
|
+
organizationId: account.organizationId,
|
|
184
|
+
tenantId: account.tenantId,
|
|
185
|
+
lineNumber: lineNumber++,
|
|
186
|
+
kind: 'product',
|
|
187
|
+
description: line.description,
|
|
188
|
+
quantity: line.quantity,
|
|
189
|
+
normalizedQuantity: line.quantity,
|
|
190
|
+
currencyCode: account.currencyCode,
|
|
191
|
+
unitPriceNet: line.unitPriceNet,
|
|
192
|
+
unitPriceGross: line.unitPriceNet, // gross == net until tax computed on post
|
|
193
|
+
discountAmount: '0',
|
|
194
|
+
discountPercent: '0',
|
|
195
|
+
taxRate: '0',
|
|
196
|
+
taxAmount: '0',
|
|
197
|
+
totalNetAmount: line.totalNetAmount,
|
|
198
|
+
totalGrossAmount: line.totalNetAmount,
|
|
199
|
+
metadata: {
|
|
200
|
+
billing_item_id: line.billingItemId,
|
|
201
|
+
billing_type: line.billingType,
|
|
202
|
+
...(line.metadataExtra ?? {}),
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
em.persist(invoiceLine)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await em.flush()
|
|
209
|
+
|
|
210
|
+
return { invoiceId: invoice.id, invoiceNumber, totalNet }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the InvoiceLineDescriptor list for a single account's eligible
|
|
215
|
+
* items (the result of `itemSelector.selectItemsForPeriod`). One line
|
|
216
|
+
* per item; `usage` items are excluded earlier by the selector.
|
|
217
|
+
*
|
|
218
|
+
* Per-type pricing per the spec's `BillingItem → InvoiceLine mapping`:
|
|
219
|
+
* - `one_time`: unit_price = amount, quantity = 1
|
|
220
|
+
* - `recurring`: unit_price = unit_price, quantity = 1
|
|
221
|
+
* (one full cycle per bill period; catch-up emits separate drafts
|
|
222
|
+
* per missed cycle, never combined)
|
|
223
|
+
*/
|
|
224
|
+
export function buildInvoiceLinesFromItems(
|
|
225
|
+
items: readonly BillingItem[],
|
|
226
|
+
): InvoiceLineDescriptor[] {
|
|
227
|
+
const lines: InvoiceLineDescriptor[] = []
|
|
228
|
+
for (const item of items) {
|
|
229
|
+
if (item.type === 'one_time') {
|
|
230
|
+
const rate = item.rateJson as { amount?: number } | null
|
|
231
|
+
if (!rate || typeof rate.amount !== 'number') continue
|
|
232
|
+
// For one_time the per-unit price IS the total — quantity is 1.
|
|
233
|
+
// Both fields therefore carry the same value, with the 2dp
|
|
234
|
+
// half-up policy applied (unit price + line total are the same
|
|
235
|
+
// numeric column at numeric(18,4)).
|
|
236
|
+
const amount = format4dp(rate.amount)
|
|
237
|
+
lines.push({
|
|
238
|
+
billingItemId: item.id,
|
|
239
|
+
billingType: 'one_time',
|
|
240
|
+
description: item.description,
|
|
241
|
+
unitPriceNet: amount,
|
|
242
|
+
quantity: '1',
|
|
243
|
+
totalNetAmount: amount,
|
|
244
|
+
})
|
|
245
|
+
} else if (item.type === 'recurring') {
|
|
246
|
+
const rate = item.rateJson as { unit_price?: number } | null
|
|
247
|
+
if (!rate || typeof rate.unit_price !== 'number') continue
|
|
248
|
+
const unitPrice = format4dp(rate.unit_price)
|
|
249
|
+
lines.push({
|
|
250
|
+
billingItemId: item.id,
|
|
251
|
+
billingType: 'recurring',
|
|
252
|
+
description: item.description,
|
|
253
|
+
unitPriceNet: unitPrice,
|
|
254
|
+
quantity: '1',
|
|
255
|
+
totalNetAmount: unitPrice,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return lines
|
|
260
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { BillingItem } from '../data/entities'
|
|
2
|
+
import type { BillPeriod } from './billPeriod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decide whether a Billing Item contributes to a given bill period.
|
|
6
|
+
*
|
|
7
|
+
* Two anti-duplicate guards working together (spec "Why two anti-duplicate
|
|
8
|
+
* guards"):
|
|
9
|
+
*
|
|
10
|
+
* 1. `billed_to_date` — per-item watermark. Set after a successful
|
|
11
|
+
* real-mode run that emitted this item; prevents re-billing the
|
|
12
|
+
* same item even if the period boundaries shift.
|
|
13
|
+
* 2. Open-draft check — per-account, handled by the engine before it
|
|
14
|
+
* even invokes this selector (see `billRunEngine`). Not this
|
|
15
|
+
* module's concern.
|
|
16
|
+
*
|
|
17
|
+
* Per-type semantics from the spec:
|
|
18
|
+
*
|
|
19
|
+
* `one_time` — one shot per item, ever. Eligible only if
|
|
20
|
+
* `billed_to_date IS NULL` AND the item's `[bill_start_date,
|
|
21
|
+
* bill_end_date]` window overlaps the bill period.
|
|
22
|
+
*
|
|
23
|
+
* `recurring` — one line per full cycle. Eligible only when:
|
|
24
|
+
* - `bill_start_date <= bill_period_start`
|
|
25
|
+
* - AND (`bill_end_date IS NULL` OR `bill_end_date >= bill_period_end`)
|
|
26
|
+
* - AND (`billed_to_date IS NULL` OR `billed_to_date < bill_period_end`)
|
|
27
|
+
*
|
|
28
|
+
* Mid-cycle starts / cancels are SKIPPED — the integrator is
|
|
29
|
+
* responsible for posting a `one_time` proration line if the
|
|
30
|
+
* partial period should be charged (spec "Mid-cycle recurring
|
|
31
|
+
* items").
|
|
32
|
+
*
|
|
33
|
+
* `usage` — not handled here. Phase 3 owns usage rating end-to-end.
|
|
34
|
+
*
|
|
35
|
+
* All comparisons treat dates as midnight (consistent with `billPeriod`).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type SelectableItem = Pick<
|
|
39
|
+
BillingItem,
|
|
40
|
+
| 'id'
|
|
41
|
+
| 'type'
|
|
42
|
+
| 'billStartDate'
|
|
43
|
+
| 'billEndDate'
|
|
44
|
+
| 'billedToDate'
|
|
45
|
+
| 'description'
|
|
46
|
+
| 'rateJson'
|
|
47
|
+
| 'isActive'
|
|
48
|
+
| 'deletedAt'
|
|
49
|
+
>
|
|
50
|
+
|
|
51
|
+
export type SelectionDecision =
|
|
52
|
+
| { include: true }
|
|
53
|
+
| { include: false; reason: string }
|
|
54
|
+
|
|
55
|
+
function asTime(date: Date | null | undefined): number | null {
|
|
56
|
+
if (!date) return null
|
|
57
|
+
return new Date(date).getTime()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isWithin(
|
|
61
|
+
windowStart: Date,
|
|
62
|
+
windowEnd: Date | null | undefined,
|
|
63
|
+
point: Date,
|
|
64
|
+
): boolean {
|
|
65
|
+
const pointTime = asTime(point)!
|
|
66
|
+
const startTime = asTime(windowStart)!
|
|
67
|
+
if (pointTime < startTime) return false
|
|
68
|
+
const endTime = asTime(windowEnd)
|
|
69
|
+
if (endTime === null) return true
|
|
70
|
+
return pointTime <= endTime
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function overlapsPeriod(
|
|
74
|
+
itemStart: Date,
|
|
75
|
+
itemEnd: Date | null | undefined,
|
|
76
|
+
period: BillPeriod,
|
|
77
|
+
): boolean {
|
|
78
|
+
const itemStartTime = asTime(itemStart)!
|
|
79
|
+
const itemEndTime = asTime(itemEnd)
|
|
80
|
+
const periodStartTime = asTime(period.periodStart)!
|
|
81
|
+
const periodEndTime = asTime(period.periodEnd)!
|
|
82
|
+
if (itemStartTime > periodEndTime) return false
|
|
83
|
+
if (itemEndTime !== null && itemEndTime < periodStartTime) return false
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function shouldIncludeItem(
|
|
88
|
+
item: SelectableItem,
|
|
89
|
+
period: BillPeriod,
|
|
90
|
+
): SelectionDecision {
|
|
91
|
+
if (!item.isActive) return { include: false, reason: 'item-inactive' }
|
|
92
|
+
if (item.deletedAt) return { include: false, reason: 'item-soft-deleted' }
|
|
93
|
+
|
|
94
|
+
switch (item.type) {
|
|
95
|
+
case 'one_time': {
|
|
96
|
+
if (item.billedToDate) {
|
|
97
|
+
return { include: false, reason: 'one_time-already-billed' }
|
|
98
|
+
}
|
|
99
|
+
if (!overlapsPeriod(item.billStartDate, item.billEndDate, period)) {
|
|
100
|
+
return { include: false, reason: 'one_time-window-outside-period' }
|
|
101
|
+
}
|
|
102
|
+
return { include: true }
|
|
103
|
+
}
|
|
104
|
+
case 'recurring': {
|
|
105
|
+
const billedToTime = asTime(item.billedToDate)
|
|
106
|
+
const periodEndTime = asTime(period.periodEnd)!
|
|
107
|
+
if (billedToTime !== null && billedToTime >= periodEndTime) {
|
|
108
|
+
return { include: false, reason: 'recurring-already-billed-through-period' }
|
|
109
|
+
}
|
|
110
|
+
const periodStartTime = asTime(period.periodStart)!
|
|
111
|
+
const itemStartTime = asTime(item.billStartDate)!
|
|
112
|
+
if (itemStartTime > periodStartTime) {
|
|
113
|
+
return { include: false, reason: 'recurring-starts-mid-cycle' }
|
|
114
|
+
}
|
|
115
|
+
const itemEndTime = asTime(item.billEndDate)
|
|
116
|
+
if (itemEndTime !== null && itemEndTime < periodEndTime) {
|
|
117
|
+
return { include: false, reason: 'recurring-ends-mid-cycle' }
|
|
118
|
+
}
|
|
119
|
+
return { include: true }
|
|
120
|
+
}
|
|
121
|
+
case 'usage':
|
|
122
|
+
// Phase 3 routes usage items through the dedicated usage rater.
|
|
123
|
+
// The selector returns "skip" so the engine's recurring/one_time
|
|
124
|
+
// path is unaffected.
|
|
125
|
+
return { include: false, reason: 'usage-handled-by-rater' }
|
|
126
|
+
default:
|
|
127
|
+
return { include: false, reason: `unknown-type:${item.type}` }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pure filter — given the items already loaded for an account, returns
|
|
133
|
+
* those eligible for the bill period together with the decision reason
|
|
134
|
+
* for diagnostics (the engine logs skipped items in the run outcome's
|
|
135
|
+
* warnings on demand).
|
|
136
|
+
*/
|
|
137
|
+
export function selectItemsForPeriod<T extends SelectableItem>(
|
|
138
|
+
items: readonly T[],
|
|
139
|
+
period: BillPeriod,
|
|
140
|
+
): { included: T[]; skipped: Array<{ item: T; reason: string }> } {
|
|
141
|
+
const included: T[] = []
|
|
142
|
+
const skipped: Array<{ item: T; reason: string }> = []
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
const decision = shouldIncludeItem(item, period)
|
|
145
|
+
if (decision.include) {
|
|
146
|
+
included.push(item)
|
|
147
|
+
} else {
|
|
148
|
+
skipped.push({ item, reason: decision.reason })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { included, skipped }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { isWithin, overlapsPeriod }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { BillingAccount, BillingItem, BillingAccountUsage } from '../../data/entities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Self-contained billing test-data seed.
|
|
6
|
+
*
|
|
7
|
+
* Creates a couple of demo billing accounts with recurring + usage items
|
|
8
|
+
* and one unrated usage record, scoped to a single tenant. Everything is
|
|
9
|
+
* idempotent: accounts are matched by name, items/usage by `source_ref`,
|
|
10
|
+
* so re-running is a no-op rather than a duplicate.
|
|
11
|
+
*
|
|
12
|
+
* The accounts are dated so they are immediately billable: `next_bill_date`
|
|
13
|
+
* is the 1st of the current month, which makes a Bill Run with the default
|
|
14
|
+
* (today's) as-of date produce an invoice for the previous month — the usage
|
|
15
|
+
* record is seeded into exactly that period.
|
|
16
|
+
*
|
|
17
|
+
* Drives `mercato billing seed`. Kept free of any customer-specific data so
|
|
18
|
+
* it ships with `@dainamite/billing`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type BillingDemoScope = { tenantId: string; organizationId: string }
|
|
22
|
+
export type BillingDemoResult = {
|
|
23
|
+
accountsCreated: number
|
|
24
|
+
itemsCreated: number
|
|
25
|
+
usageCreated: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Shared prefix for every row this seed owns — the reset path keys off it.
|
|
29
|
+
const SRC = 'demo-billing'
|
|
30
|
+
const ACCOUNT_NAME_PREFIX = 'Demo — '
|
|
31
|
+
|
|
32
|
+
type DemoAccountSpec = {
|
|
33
|
+
name: string
|
|
34
|
+
customerId: string
|
|
35
|
+
currencyCode: string
|
|
36
|
+
invoiceLanguage: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type DemoItemSpec = {
|
|
40
|
+
ref: string
|
|
41
|
+
type: 'recurring' | 'usage'
|
|
42
|
+
description: string
|
|
43
|
+
unitPrice: number
|
|
44
|
+
uomCode?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type DemoUsageSpec = {
|
|
48
|
+
ref: string
|
|
49
|
+
uomCode: string
|
|
50
|
+
quantity: number
|
|
51
|
+
lineDescription: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ACCOUNTS: Array<{
|
|
55
|
+
account: DemoAccountSpec
|
|
56
|
+
items: DemoItemSpec[]
|
|
57
|
+
usage: DemoUsageSpec[]
|
|
58
|
+
}> = [
|
|
59
|
+
{
|
|
60
|
+
account: {
|
|
61
|
+
name: `${ACCOUNT_NAME_PREFIX}Northwind Telecom`,
|
|
62
|
+
customerId: 'demo-cust-northwind',
|
|
63
|
+
currencyCode: 'PLN',
|
|
64
|
+
invoiceLanguage: 'en',
|
|
65
|
+
},
|
|
66
|
+
items: [
|
|
67
|
+
{ ref: `${SRC}-nw-rec`, type: 'recurring', description: 'Platform subscription (monthly)', unitPrice: 199 },
|
|
68
|
+
{ ref: `${SRC}-nw-usg`, type: 'usage', description: 'Data transfer (per GB)', unitPrice: 0.5, uomCode: 'gb' },
|
|
69
|
+
],
|
|
70
|
+
usage: [
|
|
71
|
+
{ ref: `${SRC}-nw-usage`, uomCode: 'gb', quantity: 120, lineDescription: 'Data transfer (prev. month)' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
account: {
|
|
76
|
+
name: `${ACCOUNT_NAME_PREFIX}Globex Cloud`,
|
|
77
|
+
customerId: 'demo-cust-globex',
|
|
78
|
+
currencyCode: 'EUR',
|
|
79
|
+
invoiceLanguage: 'en',
|
|
80
|
+
},
|
|
81
|
+
items: [
|
|
82
|
+
{ ref: `${SRC}-gx-rec`, type: 'recurring', description: 'Cloud plan (monthly)', unitPrice: 49 },
|
|
83
|
+
],
|
|
84
|
+
usage: [],
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** Delete every row this seed owns within the scope (reset before re-seed). */
|
|
89
|
+
export async function resetBillingDemo(em: EntityManager, scope: BillingDemoScope): Promise<void> {
|
|
90
|
+
await em.nativeDelete(BillingAccountUsage, {
|
|
91
|
+
...scope,
|
|
92
|
+
sourceRef: { $like: `${SRC}-%` },
|
|
93
|
+
} as never)
|
|
94
|
+
await em.nativeDelete(BillingItem, {
|
|
95
|
+
...scope,
|
|
96
|
+
sourceRef: { $like: `${SRC}-%` },
|
|
97
|
+
} as never)
|
|
98
|
+
await em.nativeDelete(BillingAccount, {
|
|
99
|
+
...scope,
|
|
100
|
+
name: { $like: `${ACCOUNT_NAME_PREFIX}%` },
|
|
101
|
+
} as never)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function seedBillingDemo(
|
|
105
|
+
em: EntityManager,
|
|
106
|
+
scope: BillingDemoScope,
|
|
107
|
+
options: { reset?: boolean; now?: Date } = {},
|
|
108
|
+
): Promise<BillingDemoResult> {
|
|
109
|
+
if (options.reset) await resetBillingDemo(em, scope)
|
|
110
|
+
|
|
111
|
+
const now = options.now ?? new Date()
|
|
112
|
+
// Bill period the next run will cover, given next_bill = 1st of this month:
|
|
113
|
+
// periodStart = 1st of previous month, periodEnd = last day of previous month.
|
|
114
|
+
// Build at UTC midnight so `date` columns don't drift a day under a positive
|
|
115
|
+
// local UTC offset (local `new Date(y, m, 1)` would persist as the prev day).
|
|
116
|
+
const nextBillDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
|
117
|
+
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
|
|
118
|
+
const prevMonthEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0))
|
|
119
|
+
|
|
120
|
+
const result: BillingDemoResult = { accountsCreated: 0, itemsCreated: 0, usageCreated: 0 }
|
|
121
|
+
|
|
122
|
+
for (const spec of ACCOUNTS) {
|
|
123
|
+
// ── Account (idempotent by name within scope) ──
|
|
124
|
+
let account = await em.findOne(BillingAccount, {
|
|
125
|
+
...scope,
|
|
126
|
+
name: spec.account.name,
|
|
127
|
+
deletedAt: null,
|
|
128
|
+
} as never)
|
|
129
|
+
if (!account) {
|
|
130
|
+
account = em.create(BillingAccount, {
|
|
131
|
+
...scope,
|
|
132
|
+
customerId: spec.account.customerId,
|
|
133
|
+
name: spec.account.name,
|
|
134
|
+
currencyCode: spec.account.currencyCode,
|
|
135
|
+
billCycle: 'monthly',
|
|
136
|
+
billCycleAnchor: '1',
|
|
137
|
+
invoiceEmail: `billing@${spec.account.customerId}.demo`,
|
|
138
|
+
invoiceLanguage: spec.account.invoiceLanguage,
|
|
139
|
+
taxId: null,
|
|
140
|
+
invoiceAddress: { line1: 'ul. Demo 1', city: 'Warszawa', postal_code: '00-001', country: 'PL' },
|
|
141
|
+
nextBillDate,
|
|
142
|
+
lastBillDate: null,
|
|
143
|
+
isActive: true,
|
|
144
|
+
})
|
|
145
|
+
em.persist(account)
|
|
146
|
+
await em.flush() // need the id for child rows
|
|
147
|
+
result.accountsCreated += 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Items (idempotent by source_ref) ──
|
|
151
|
+
for (const item of spec.items) {
|
|
152
|
+
const existing = await em.findOne(BillingItem, { ...scope, sourceRef: item.ref } as never)
|
|
153
|
+
if (existing) continue
|
|
154
|
+
em.persist(
|
|
155
|
+
em.create(BillingItem, {
|
|
156
|
+
...scope,
|
|
157
|
+
billAccountId: account.id,
|
|
158
|
+
type: item.type,
|
|
159
|
+
billStartDate: prevMonthStart,
|
|
160
|
+
billEndDate: null,
|
|
161
|
+
description: item.description,
|
|
162
|
+
rateJson: { unit_price: item.unitPrice },
|
|
163
|
+
uomCode: item.uomCode ?? null,
|
|
164
|
+
subscriptionId: null,
|
|
165
|
+
subscriptionItemId: null,
|
|
166
|
+
sourceRef: item.ref,
|
|
167
|
+
currencyMismatch: false,
|
|
168
|
+
billedToDate: null,
|
|
169
|
+
isActive: true,
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
result.itemsCreated += 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Usage records (idempotent by source_ref) ──
|
|
176
|
+
for (const u of spec.usage) {
|
|
177
|
+
const existing = await em.findOne(BillingAccountUsage, { ...scope, sourceRef: u.ref } as never)
|
|
178
|
+
if (existing) continue
|
|
179
|
+
em.persist(
|
|
180
|
+
em.create(BillingAccountUsage, {
|
|
181
|
+
...scope,
|
|
182
|
+
billAccountId: account.id,
|
|
183
|
+
uomCode: u.uomCode,
|
|
184
|
+
quantity: u.quantity.toFixed(4),
|
|
185
|
+
periodStart: prevMonthStart,
|
|
186
|
+
periodEnd: prevMonthEnd,
|
|
187
|
+
lineDescription: u.lineDescription,
|
|
188
|
+
sourceRef: u.ref,
|
|
189
|
+
ratedInBillRunId: null,
|
|
190
|
+
isActive: true,
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
result.usageCreated += 1
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await em.flush()
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-tenant Postgres transaction-scoped advisory lock primitive.
|
|
5
|
+
*
|
|
6
|
+
* The Bill Run engine (Phase 2) uses this to guarantee at most one
|
|
7
|
+
* concurrently-running run per tenant — the spec requires this so two
|
|
8
|
+
* cron triggers or a "schedule" + "manual" overlap cannot produce
|
|
9
|
+
* duplicate drafts.
|
|
10
|
+
*
|
|
11
|
+
* Why advisory locks (not row locks):
|
|
12
|
+
* - `pg_try_advisory_xact_lock` is non-blocking — we get a clean "busy"
|
|
13
|
+
* signal instead of waiting on a row.
|
|
14
|
+
* - It's released automatically when the transaction ends (commit,
|
|
15
|
+
* rollback, or process death) — so there is no lock-leak path even
|
|
16
|
+
* on `kill -9`. The `BillRun` row's `status='running'` may be left
|
|
17
|
+
* hanging, but the lock itself vanishes; the reaper worker
|
|
18
|
+
* (workers/reap-stale-bill-runs.ts) handles the orphaned row.
|
|
19
|
+
* - It works without a sentinel table — we hash a string key into the
|
|
20
|
+
* bigint key space the advisory-lock API requires.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { withTenantLock, TenantLockBusyError } from '../lib/tenantLock'
|
|
26
|
+
*
|
|
27
|
+
* try {
|
|
28
|
+
* await withTenantLock(em, { tenantId, lockName: 'billing-run' }, async (tem) => {
|
|
29
|
+
* // tem is the transactional EntityManager; lock is held here.
|
|
30
|
+
* // Returns to caller after function resolves and tx commits.
|
|
31
|
+
* })
|
|
32
|
+
* } catch (err) {
|
|
33
|
+
* if (err instanceof TenantLockBusyError) {
|
|
34
|
+
* // HTTP 409 — another run is in progress.
|
|
35
|
+
* }
|
|
36
|
+
* throw err
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Keep work inside the callback short. The advisory lock is released only
|
|
41
|
+
* when the transaction ends; long-running work blocks other triggers for
|
|
42
|
+
* the whole tenant.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export class TenantLockBusyError extends Error {
|
|
46
|
+
readonly tenantId: string
|
|
47
|
+
readonly lockName: string
|
|
48
|
+
|
|
49
|
+
constructor(tenantId: string, lockName: string) {
|
|
50
|
+
super(`Tenant ${tenantId} already holds the billing lock "${lockName}"`)
|
|
51
|
+
this.name = 'TenantLockBusyError'
|
|
52
|
+
this.tenantId = tenantId
|
|
53
|
+
this.lockName = lockName
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const LOCK_KEY_PREFIX = 'dainamite-billing'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the deterministic string key fed into `hashtext()`. Exposed for
|
|
61
|
+
* tests so they can assert lock-key independence across tenants /
|
|
62
|
+
* lockNames without scraping SQL strings.
|
|
63
|
+
*/
|
|
64
|
+
export function buildLockKey(tenantId: string, lockName: string): string {
|
|
65
|
+
return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type AdvisoryLockRow = { acquired: boolean }
|
|
69
|
+
|
|
70
|
+
export async function withTenantLock<T>(
|
|
71
|
+
em: EntityManager,
|
|
72
|
+
params: { tenantId: string; lockName: string },
|
|
73
|
+
fn: (tem: EntityManager) => Promise<T>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
return em.transactional(async (tem) => {
|
|
76
|
+
const key = buildLockKey(params.tenantId, params.lockName)
|
|
77
|
+
const rows = (await tem.execute(
|
|
78
|
+
'SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired',
|
|
79
|
+
[key],
|
|
80
|
+
)) as unknown as AdvisoryLockRow[] | AdvisoryLockRow | undefined
|
|
81
|
+
const first: AdvisoryLockRow | undefined = Array.isArray(rows) ? rows[0] : rows
|
|
82
|
+
if (!first?.acquired) {
|
|
83
|
+
throw new TenantLockBusyError(params.tenantId, params.lockName)
|
|
84
|
+
}
|
|
85
|
+
return fn(tem)
|
|
86
|
+
})
|
|
87
|
+
}
|