@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,542 @@
|
|
|
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 { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
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 { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
11
|
+
import { DraftInvoiceEdit } from '../data/entities'
|
|
12
|
+
import { emitBillingEvent } from '../events'
|
|
13
|
+
import {
|
|
14
|
+
billingInvoiceAddLineSchema,
|
|
15
|
+
billingInvoiceEditLineSchema,
|
|
16
|
+
billingInvoicePostSchema,
|
|
17
|
+
billingInvoiceRemoveLineSchema,
|
|
18
|
+
billingWipeTestInvoicesSchema,
|
|
19
|
+
type BillingInvoiceAddLineInput,
|
|
20
|
+
type BillingInvoiceEditLineInput,
|
|
21
|
+
type BillingInvoicePostInput,
|
|
22
|
+
type BillingInvoiceRemoveLineInput,
|
|
23
|
+
type BillingWipeTestInvoicesInput,
|
|
24
|
+
} from '../data/validators'
|
|
25
|
+
import { resolveInvoiceStatusEntryId } from '../lib/invoiceStatus'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Posts a `core/sales` draft invoice that the Bill Run engine
|
|
29
|
+
* created.
|
|
30
|
+
*
|
|
31
|
+
* Phase 2 deviation note: the invoice number is already set at
|
|
32
|
+
* draft-create time (because `SalesInvoice.invoice_number` is NOT
|
|
33
|
+
* NULL upstream). "Post" therefore only:
|
|
34
|
+
* 1. Asserts the invoice is currently a draft (refuse otherwise).
|
|
35
|
+
* 2. Refuses test-mode invoices (`metadata.test_run=true`) — they
|
|
36
|
+
* should never reach production posting.
|
|
37
|
+
* 3. Flips `status` (and `status_entry_id`) to `posted`.
|
|
38
|
+
* 4. Fires `billing.invoice.posted` (one) + `billing.invoice.line_posted`
|
|
39
|
+
* per line (for downstream consumers — accounting, analytics).
|
|
40
|
+
*
|
|
41
|
+
* Once posted, the invoice is frozen at the billing layer; corrections
|
|
42
|
+
* go through `core/sales`'s manual edit flow.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
function getEm(ctx: CommandRuntimeContext): EntityManager {
|
|
46
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type PostResult = {
|
|
50
|
+
invoiceId: string
|
|
51
|
+
invoiceNumber: string
|
|
52
|
+
status: 'posted'
|
|
53
|
+
lineCount: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> = {
|
|
57
|
+
id: 'billing.invoices.post',
|
|
58
|
+
|
|
59
|
+
async execute(rawInput, ctx) {
|
|
60
|
+
const parsed = billingInvoicePostSchema.parse(rawInput)
|
|
61
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
62
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
63
|
+
|
|
64
|
+
const em = getEm(ctx)
|
|
65
|
+
const invoice = await em.findOne(SalesInvoice, {
|
|
66
|
+
id: parsed.invoiceId,
|
|
67
|
+
tenantId: parsed.tenantId,
|
|
68
|
+
organizationId: parsed.organizationId,
|
|
69
|
+
deletedAt: null,
|
|
70
|
+
})
|
|
71
|
+
if (!invoice) {
|
|
72
|
+
throw new CrudHttpError(404, { error: 'Invoice not found' })
|
|
73
|
+
}
|
|
74
|
+
if (invoice.status !== 'draft') {
|
|
75
|
+
throw new CrudHttpError(409, {
|
|
76
|
+
error: 'Only draft invoices can be posted',
|
|
77
|
+
code: 'billing.invoice.not_draft',
|
|
78
|
+
currentStatus: invoice.status,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
const meta = (invoice.metadata as Record<string, unknown> | null) ?? {}
|
|
82
|
+
if (meta.test_run === true) {
|
|
83
|
+
// Test invoices live for inspection only — posting them would
|
|
84
|
+
// leak test data into accounting. Force-delete the test invoice
|
|
85
|
+
// instead (Phase 4b admin UI exposes a "Wipe test drafts"
|
|
86
|
+
// button against `DELETE /api/billing/test-invoices`).
|
|
87
|
+
throw new CrudHttpError(409, {
|
|
88
|
+
error: 'Test-mode invoices cannot be posted',
|
|
89
|
+
code: 'billing.invoice.test_run',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const postedStatusEntryId = await resolveInvoiceStatusEntryId(
|
|
94
|
+
em,
|
|
95
|
+
parsed.tenantId,
|
|
96
|
+
parsed.organizationId,
|
|
97
|
+
'posted',
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await withAtomicFlush(em, [
|
|
101
|
+
() => {
|
|
102
|
+
invoice.status = 'posted'
|
|
103
|
+
invoice.statusEntryId = postedStatusEntryId
|
|
104
|
+
invoice.updatedAt = new Date()
|
|
105
|
+
},
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
const lines = await em.find(SalesInvoiceLine, {
|
|
109
|
+
invoice,
|
|
110
|
+
tenantId: parsed.tenantId,
|
|
111
|
+
organizationId: parsed.organizationId,
|
|
112
|
+
} as never)
|
|
113
|
+
|
|
114
|
+
// Side effects fire AFTER the DB commit so downstream subscribers
|
|
115
|
+
// (mailer, accounting bridge, analytics) never observe a "posted"
|
|
116
|
+
// event for an invoice the DB doesn't reflect.
|
|
117
|
+
await emitBillingEvent('billing.invoice.posted', {
|
|
118
|
+
invoiceId: invoice.id,
|
|
119
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
120
|
+
tenantId: invoice.tenantId,
|
|
121
|
+
organizationId: invoice.organizationId,
|
|
122
|
+
grandTotalNet: invoice.grandTotalNetAmount,
|
|
123
|
+
grandTotalGross: invoice.grandTotalGrossAmount,
|
|
124
|
+
currencyCode: invoice.currencyCode,
|
|
125
|
+
billRunId: (meta as { bill_run_id?: string }).bill_run_id ?? null,
|
|
126
|
+
billAccountId: (meta as { bill_account_id?: string }).bill_account_id ?? null,
|
|
127
|
+
billPeriodStart: (meta as { bill_period_start?: string }).bill_period_start ?? null,
|
|
128
|
+
billPeriodEnd: (meta as { bill_period_end?: string }).bill_period_end ?? null,
|
|
129
|
+
lineCount: lines.length,
|
|
130
|
+
})
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const lineMeta = (line.metadata as Record<string, unknown> | null) ?? {}
|
|
133
|
+
await emitBillingEvent('billing.invoice.line_posted', {
|
|
134
|
+
invoiceId: invoice.id,
|
|
135
|
+
invoiceLineId: line.id,
|
|
136
|
+
billingItemId: lineMeta.billing_item_id ?? null,
|
|
137
|
+
billingType: lineMeta.billing_type ?? null,
|
|
138
|
+
description: line.description ?? null,
|
|
139
|
+
unitPriceNet: line.unitPriceNet,
|
|
140
|
+
quantity: line.quantity,
|
|
141
|
+
totalNetAmount: line.totalNetAmount,
|
|
142
|
+
tenantId: invoice.tenantId,
|
|
143
|
+
organizationId: invoice.organizationId,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
invoiceId: invoice.id,
|
|
149
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
150
|
+
status: 'posted',
|
|
151
|
+
lineCount: lines.length,
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Draft edit / add / remove (Phase 4b) ────────────────────────
|
|
157
|
+
//
|
|
158
|
+
// Three commands sharing the same shape:
|
|
159
|
+
// 1. Assert the invoice is still a draft (refuse otherwise).
|
|
160
|
+
// 2. Mutate the line / collection.
|
|
161
|
+
// 3. Write a `DraftInvoiceEdit` audit row carrying before+after
|
|
162
|
+
// snapshots (`null` on the "no prior state" / "no resulting state"
|
|
163
|
+
// side per the spec — see entity column docs).
|
|
164
|
+
// 4. Recompute the invoice totals from the surviving lines.
|
|
165
|
+
//
|
|
166
|
+
// The audit row is append-only; once written, never updated. Soft-
|
|
167
|
+
// deleting the invoice does NOT delete the audit rows (compliance).
|
|
168
|
+
|
|
169
|
+
function roundHalfUp2dp(value: number): number {
|
|
170
|
+
return Math.round((value + Number.EPSILON) * 100) / 100
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function format4dp(value: number): string {
|
|
174
|
+
return roundHalfUp2dp(value).toFixed(4)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function assertDraftInvoice(
|
|
178
|
+
em: EntityManager,
|
|
179
|
+
params: { invoiceId: string; tenantId: string; organizationId: string },
|
|
180
|
+
): Promise<SalesInvoice> {
|
|
181
|
+
const invoice = await em.findOne(SalesInvoice, {
|
|
182
|
+
id: params.invoiceId,
|
|
183
|
+
tenantId: params.tenantId,
|
|
184
|
+
organizationId: params.organizationId,
|
|
185
|
+
deletedAt: null,
|
|
186
|
+
})
|
|
187
|
+
if (!invoice) {
|
|
188
|
+
throw new CrudHttpError(404, { error: 'Invoice not found' })
|
|
189
|
+
}
|
|
190
|
+
if (invoice.status !== 'draft') {
|
|
191
|
+
throw new CrudHttpError(409, {
|
|
192
|
+
error: 'Only draft invoices can be edited',
|
|
193
|
+
code: 'billing.invoice.not_draft',
|
|
194
|
+
currentStatus: invoice.status,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
return invoice
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type LineSnapshot = {
|
|
201
|
+
id: string
|
|
202
|
+
description: string | null
|
|
203
|
+
quantity: string
|
|
204
|
+
unitPriceNet: string
|
|
205
|
+
totalNetAmount: string
|
|
206
|
+
totalGrossAmount: string
|
|
207
|
+
metadata: Record<string, unknown> | null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function snapshotLine(line: SalesInvoiceLine): LineSnapshot {
|
|
211
|
+
return {
|
|
212
|
+
id: line.id,
|
|
213
|
+
description: line.description ?? null,
|
|
214
|
+
quantity: line.quantity,
|
|
215
|
+
unitPriceNet: line.unitPriceNet,
|
|
216
|
+
totalNetAmount: line.totalNetAmount,
|
|
217
|
+
totalGrossAmount: line.totalGrossAmount,
|
|
218
|
+
metadata: (line.metadata as Record<string, unknown> | null) ?? null,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function recomputeInvoiceTotals(
|
|
223
|
+
em: EntityManager,
|
|
224
|
+
invoice: SalesInvoice,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const lines = await em.find(SalesInvoiceLine, {
|
|
227
|
+
invoice,
|
|
228
|
+
tenantId: invoice.tenantId,
|
|
229
|
+
organizationId: invoice.organizationId,
|
|
230
|
+
} as never)
|
|
231
|
+
let totalNet = 0
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
totalNet += Number.parseFloat(line.totalNetAmount)
|
|
234
|
+
}
|
|
235
|
+
const formatted = format4dp(totalNet)
|
|
236
|
+
invoice.subtotalNetAmount = formatted
|
|
237
|
+
invoice.subtotalGrossAmount = formatted
|
|
238
|
+
invoice.grandTotalNetAmount = formatted
|
|
239
|
+
invoice.grandTotalGrossAmount = formatted
|
|
240
|
+
invoice.outstandingAmount = formatted
|
|
241
|
+
invoice.updatedAt = new Date()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getUserIdFromCtx(ctx: CommandRuntimeContext): string {
|
|
245
|
+
const userId = (ctx.auth?.sub as string | undefined) ?? null
|
|
246
|
+
// The DraftInvoiceEdit.user_id column is NOT NULL. For system-driven
|
|
247
|
+
// edits (e.g. catch-up flow that revises a value), we fall back to
|
|
248
|
+
// a sentinel UUID. Operator-driven edits always have a real
|
|
249
|
+
// ctx.auth.sub.
|
|
250
|
+
return userId ?? '00000000-0000-0000-0000-000000000000'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const editDraftLineCommand: CommandHandler<
|
|
254
|
+
BillingInvoiceEditLineInput,
|
|
255
|
+
{ invoiceId: string; invoiceLineId: string; auditId: string }
|
|
256
|
+
> = {
|
|
257
|
+
id: 'billing.invoices.edit_draft_line',
|
|
258
|
+
|
|
259
|
+
async execute(rawInput, ctx) {
|
|
260
|
+
const parsed = billingInvoiceEditLineSchema.parse(rawInput)
|
|
261
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
262
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
263
|
+
|
|
264
|
+
const em = getEm(ctx)
|
|
265
|
+
const invoice = await assertDraftInvoice(em, {
|
|
266
|
+
invoiceId: parsed.invoiceId,
|
|
267
|
+
tenantId: parsed.tenantId,
|
|
268
|
+
organizationId: parsed.organizationId,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const line = await em.findOne(SalesInvoiceLine, {
|
|
272
|
+
id: parsed.invoiceLineId,
|
|
273
|
+
invoice,
|
|
274
|
+
tenantId: parsed.tenantId,
|
|
275
|
+
organizationId: parsed.organizationId,
|
|
276
|
+
})
|
|
277
|
+
if (!line) {
|
|
278
|
+
throw new CrudHttpError(404, { error: 'Invoice line not found' })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const before = snapshotLine(line)
|
|
282
|
+
let audit: DraftInvoiceEdit | null = null
|
|
283
|
+
|
|
284
|
+
await withAtomicFlush(em, [
|
|
285
|
+
() => {
|
|
286
|
+
if (parsed.changes.description !== undefined) {
|
|
287
|
+
line.description = parsed.changes.description
|
|
288
|
+
}
|
|
289
|
+
if (parsed.changes.quantity !== undefined) {
|
|
290
|
+
line.quantity = format4dp(parsed.changes.quantity)
|
|
291
|
+
line.normalizedQuantity = line.quantity
|
|
292
|
+
}
|
|
293
|
+
if (parsed.changes.unitPriceNet !== undefined) {
|
|
294
|
+
line.unitPriceNet = format4dp(parsed.changes.unitPriceNet)
|
|
295
|
+
line.unitPriceGross = line.unitPriceNet
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Recompute the line total. If the operator supplied an explicit
|
|
299
|
+
// `totalNetAmount` override, honour it; otherwise compute from
|
|
300
|
+
// unit_price × quantity.
|
|
301
|
+
if (parsed.changes.totalNetAmount !== undefined) {
|
|
302
|
+
const total = format4dp(parsed.changes.totalNetAmount)
|
|
303
|
+
line.totalNetAmount = total
|
|
304
|
+
line.totalGrossAmount = total
|
|
305
|
+
} else {
|
|
306
|
+
const total = format4dp(
|
|
307
|
+
Number.parseFloat(line.unitPriceNet) * Number.parseFloat(line.quantity),
|
|
308
|
+
)
|
|
309
|
+
line.totalNetAmount = total
|
|
310
|
+
line.totalGrossAmount = total
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
async () => {
|
|
314
|
+
await recomputeInvoiceTotals(em, invoice)
|
|
315
|
+
},
|
|
316
|
+
() => {
|
|
317
|
+
const after = snapshotLine(line)
|
|
318
|
+
audit = em.create(DraftInvoiceEdit, {
|
|
319
|
+
tenantId: parsed.tenantId,
|
|
320
|
+
organizationId: parsed.organizationId,
|
|
321
|
+
invoiceId: invoice.id,
|
|
322
|
+
invoiceLineId: line.id,
|
|
323
|
+
userId: getUserIdFromCtx(ctx),
|
|
324
|
+
action: 'line_edited',
|
|
325
|
+
beforeJson: before as Record<string, unknown>,
|
|
326
|
+
afterJson: after as Record<string, unknown>,
|
|
327
|
+
createdAt: new Date(),
|
|
328
|
+
})
|
|
329
|
+
em.persist(audit)
|
|
330
|
+
},
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const addDraftLineCommand: CommandHandler<
|
|
338
|
+
BillingInvoiceAddLineInput,
|
|
339
|
+
{ invoiceId: string; invoiceLineId: string; auditId: string }
|
|
340
|
+
> = {
|
|
341
|
+
id: 'billing.invoices.add_draft_line',
|
|
342
|
+
|
|
343
|
+
async execute(rawInput, ctx) {
|
|
344
|
+
const parsed = billingInvoiceAddLineSchema.parse(rawInput)
|
|
345
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
346
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
347
|
+
|
|
348
|
+
const em = getEm(ctx)
|
|
349
|
+
const invoice = await assertDraftInvoice(em, {
|
|
350
|
+
invoiceId: parsed.invoiceId,
|
|
351
|
+
tenantId: parsed.tenantId,
|
|
352
|
+
organizationId: parsed.organizationId,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const existing = await em.find(SalesInvoiceLine, {
|
|
356
|
+
invoice,
|
|
357
|
+
tenantId: parsed.tenantId,
|
|
358
|
+
organizationId: parsed.organizationId,
|
|
359
|
+
} as never)
|
|
360
|
+
const nextLineNumber = existing.length + 1
|
|
361
|
+
|
|
362
|
+
const total = format4dp(parsed.unitPriceNet * parsed.quantity)
|
|
363
|
+
const line = em.create(SalesInvoiceLine, {
|
|
364
|
+
invoice,
|
|
365
|
+
organizationId: parsed.organizationId,
|
|
366
|
+
tenantId: parsed.tenantId,
|
|
367
|
+
lineNumber: nextLineNumber,
|
|
368
|
+
kind: 'product',
|
|
369
|
+
description: parsed.description,
|
|
370
|
+
quantity: format4dp(parsed.quantity),
|
|
371
|
+
normalizedQuantity: format4dp(parsed.quantity),
|
|
372
|
+
currencyCode: invoice.currencyCode,
|
|
373
|
+
unitPriceNet: format4dp(parsed.unitPriceNet),
|
|
374
|
+
unitPriceGross: format4dp(parsed.unitPriceNet),
|
|
375
|
+
discountAmount: '0',
|
|
376
|
+
discountPercent: '0',
|
|
377
|
+
taxRate: '0',
|
|
378
|
+
taxAmount: '0',
|
|
379
|
+
totalNetAmount: total,
|
|
380
|
+
totalGrossAmount: total,
|
|
381
|
+
metadata: {
|
|
382
|
+
billing_item_id: parsed.billingItemId ?? null,
|
|
383
|
+
billing_type: parsed.billingType ?? 'manual',
|
|
384
|
+
operator_added: true,
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
let audit: DraftInvoiceEdit | null = null
|
|
388
|
+
|
|
389
|
+
await withAtomicFlush(em, [
|
|
390
|
+
() => {
|
|
391
|
+
em.persist(line)
|
|
392
|
+
},
|
|
393
|
+
async () => {
|
|
394
|
+
await recomputeInvoiceTotals(em, invoice)
|
|
395
|
+
},
|
|
396
|
+
() => {
|
|
397
|
+
const after = snapshotLine(line)
|
|
398
|
+
audit = em.create(DraftInvoiceEdit, {
|
|
399
|
+
tenantId: parsed.tenantId,
|
|
400
|
+
organizationId: parsed.organizationId,
|
|
401
|
+
invoiceId: invoice.id,
|
|
402
|
+
invoiceLineId: line.id,
|
|
403
|
+
userId: getUserIdFromCtx(ctx),
|
|
404
|
+
action: 'line_added',
|
|
405
|
+
beforeJson: null,
|
|
406
|
+
afterJson: after as Record<string, unknown>,
|
|
407
|
+
createdAt: new Date(),
|
|
408
|
+
})
|
|
409
|
+
em.persist(audit)
|
|
410
|
+
},
|
|
411
|
+
])
|
|
412
|
+
|
|
413
|
+
return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const removeDraftLineCommand: CommandHandler<
|
|
418
|
+
BillingInvoiceRemoveLineInput,
|
|
419
|
+
{ invoiceId: string; invoiceLineId: string; auditId: string }
|
|
420
|
+
> = {
|
|
421
|
+
id: 'billing.invoices.remove_draft_line',
|
|
422
|
+
|
|
423
|
+
async execute(rawInput, ctx) {
|
|
424
|
+
const parsed = billingInvoiceRemoveLineSchema.parse(rawInput)
|
|
425
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
426
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
427
|
+
|
|
428
|
+
const em = getEm(ctx)
|
|
429
|
+
const invoice = await assertDraftInvoice(em, {
|
|
430
|
+
invoiceId: parsed.invoiceId,
|
|
431
|
+
tenantId: parsed.tenantId,
|
|
432
|
+
organizationId: parsed.organizationId,
|
|
433
|
+
})
|
|
434
|
+
const line = await em.findOne(SalesInvoiceLine, {
|
|
435
|
+
id: parsed.invoiceLineId,
|
|
436
|
+
invoice,
|
|
437
|
+
tenantId: parsed.tenantId,
|
|
438
|
+
organizationId: parsed.organizationId,
|
|
439
|
+
})
|
|
440
|
+
if (!line) {
|
|
441
|
+
throw new CrudHttpError(404, { error: 'Invoice line not found' })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const before = snapshotLine(line)
|
|
445
|
+
let audit: DraftInvoiceEdit | null = null
|
|
446
|
+
|
|
447
|
+
await withAtomicFlush(em, [
|
|
448
|
+
() => {
|
|
449
|
+
em.remove(line)
|
|
450
|
+
},
|
|
451
|
+
async () => {
|
|
452
|
+
await recomputeInvoiceTotals(em, invoice)
|
|
453
|
+
},
|
|
454
|
+
() => {
|
|
455
|
+
audit = em.create(DraftInvoiceEdit, {
|
|
456
|
+
tenantId: parsed.tenantId,
|
|
457
|
+
organizationId: parsed.organizationId,
|
|
458
|
+
invoiceId: invoice.id,
|
|
459
|
+
invoiceLineId: null,
|
|
460
|
+
userId: getUserIdFromCtx(ctx),
|
|
461
|
+
action: 'line_removed',
|
|
462
|
+
beforeJson: before as Record<string, unknown>,
|
|
463
|
+
afterJson: null,
|
|
464
|
+
createdAt: new Date(),
|
|
465
|
+
})
|
|
466
|
+
em.persist(audit)
|
|
467
|
+
},
|
|
468
|
+
])
|
|
469
|
+
|
|
470
|
+
return { invoiceId: invoice.id, invoiceLineId: parsed.invoiceLineId, auditId: audit!.id }
|
|
471
|
+
},
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Wipe test invoices (Phase 4b) ───────────────────────────────
|
|
475
|
+
//
|
|
476
|
+
// Hard-deletes invoices flagged `metadata.test_run=true` (and their
|
|
477
|
+
// lines via cascade — the invoice's lines collection points at it
|
|
478
|
+
// with ON DELETE CASCADE). Optionally scoped to a single `bill_run_id`
|
|
479
|
+
// via the param so operators can wipe a specific test run without
|
|
480
|
+
// risking other test data.
|
|
481
|
+
|
|
482
|
+
const wipeTestInvoicesCommand: CommandHandler<
|
|
483
|
+
BillingWipeTestInvoicesInput,
|
|
484
|
+
{ invoicesRemoved: number }
|
|
485
|
+
> = {
|
|
486
|
+
id: 'billing.invoices.wipe_test',
|
|
487
|
+
|
|
488
|
+
async execute(rawInput, ctx) {
|
|
489
|
+
const parsed = billingWipeTestInvoicesSchema.parse(rawInput)
|
|
490
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
491
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
492
|
+
|
|
493
|
+
const em = getEm(ctx)
|
|
494
|
+
// The metadata->>'test_run' = 'true' predicate uses Postgres'
|
|
495
|
+
// JSONB ->> operator + the literal string 'true' (jsonb true is
|
|
496
|
+
// serialized as the JSON literal `true`, which ->> returns as the
|
|
497
|
+
// text "true").
|
|
498
|
+
const params: unknown[] = [parsed.tenantId, parsed.organizationId]
|
|
499
|
+
let billRunClause = ''
|
|
500
|
+
if (parsed.billRunId) {
|
|
501
|
+
billRunClause = ` AND metadata->>'bill_run_id' = ?`
|
|
502
|
+
params.push(parsed.billRunId)
|
|
503
|
+
}
|
|
504
|
+
const rows = (await em.execute(
|
|
505
|
+
`SELECT id FROM sales_invoices
|
|
506
|
+
WHERE tenant_id = ?
|
|
507
|
+
AND organization_id = ?
|
|
508
|
+
AND deleted_at IS NULL
|
|
509
|
+
AND metadata->>'test_run' = 'true'${billRunClause}`,
|
|
510
|
+
params,
|
|
511
|
+
)) as unknown as Array<{ id: string }> | undefined
|
|
512
|
+
if (!rows || rows.length === 0) {
|
|
513
|
+
return { invoicesRemoved: 0 }
|
|
514
|
+
}
|
|
515
|
+
const ids = rows.map((r) => r.id)
|
|
516
|
+
// Hard-delete lines first then invoices to avoid relying on
|
|
517
|
+
// cascade semantics that the migration may or may not encode.
|
|
518
|
+
await em.execute(
|
|
519
|
+
`DELETE FROM sales_invoice_lines WHERE invoice_id IN (${ids.map(() => '?').join(',')})`,
|
|
520
|
+
ids,
|
|
521
|
+
)
|
|
522
|
+
await em.execute(
|
|
523
|
+
`DELETE FROM sales_invoices WHERE id IN (${ids.map(() => '?').join(',')})`,
|
|
524
|
+
ids,
|
|
525
|
+
)
|
|
526
|
+
return { invoicesRemoved: ids.length }
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
registerCommand(postInvoiceCommand)
|
|
531
|
+
registerCommand(editDraftLineCommand)
|
|
532
|
+
registerCommand(addDraftLineCommand)
|
|
533
|
+
registerCommand(removeDraftLineCommand)
|
|
534
|
+
registerCommand(wipeTestInvoicesCommand)
|
|
535
|
+
|
|
536
|
+
export {
|
|
537
|
+
postInvoiceCommand,
|
|
538
|
+
editDraftLineCommand,
|
|
539
|
+
addDraftLineCommand,
|
|
540
|
+
removeDraftLineCommand,
|
|
541
|
+
wipeTestInvoicesCommand,
|
|
542
|
+
}
|