@dainamite/billing 0.19.0 → 0.20.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 +82 -0
- package/README.md +14 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +5 -5
- package/dist/modules/billing/api/export/account/[id]/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/add-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/add-line/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/edit-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +1 -1
- package/dist/modules/billing/api/invoices/remove-line/route.js +1 -1
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +1 -1
- package/dist/modules/billing/api/run-outcomes/route.js +2 -2
- package/dist/modules/billing/api/run-outcomes/route.js.map +2 -2
- package/dist/modules/billing/api/runs/retry-failed/route.js +2 -2
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +2 -2
- package/dist/modules/billing/api/runs/route.js +2 -2
- package/dist/modules/billing/api/runs/route.js.map +2 -2
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/accounts/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/invoices/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/items/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/runs/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/statements/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/topups/page.js.map +1 -1
- package/dist/modules/billing/backend/billing/transactions/page.js.map +1 -1
- package/dist/modules/billing/ce.js.map +2 -2
- package/dist/modules/billing/commands/invoices.js +4 -4
- package/dist/modules/billing/commands/invoices.js.map +2 -2
- package/dist/modules/billing/commands/runs.js +3 -3
- package/dist/modules/billing/commands/runs.js.map +2 -2
- package/dist/modules/billing/components/PrepaidPanel.js.map +1 -1
- package/dist/modules/billing/data/entities.js +56 -56
- package/dist/modules/billing/data/entities.js.map +2 -2
- package/dist/modules/billing/data/entityIds.js.map +2 -2
- package/dist/modules/billing/events.js +20 -0
- package/dist/modules/billing/events.js.map +2 -2
- package/dist/modules/billing/lib/billRunEngine.js +4 -4
- package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
- package/dist/modules/billing/lib/billRunLabel.js.map +1 -1
- package/dist/modules/billing/lib/prepaidPeriodClose.js.map +1 -1
- package/dist/modules/billing/lib/tenantLock.js.map +1 -1
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +2 -2
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +2 -2
- package/generated/entities/billing_account/index.ts +23 -0
- package/generated/entities/billing_account_balance/index.ts +13 -0
- package/generated/entities/billing_account_transaction/index.ts +21 -0
- package/generated/entities/billing_account_usage/index.ts +18 -0
- package/generated/entities/billing_draft_invoice_edit/index.ts +14 -0
- package/generated/entities/billing_item/index.ts +22 -0
- package/generated/entities/billing_run/index.ts +21 -0
- package/generated/entities/billing_run_outcome/index.ts +17 -0
- package/generated/entities/billing_statement/index.ts +21 -0
- package/generated/entities/billing_topup/index.ts +20 -0
- package/generated/entities.ids.generated.ts +28 -0
- package/package.json +11 -10
- package/src/modules/billing/__tests__/commands/invoices.test.ts +5 -5
- package/src/modules/billing/__tests__/entityRegistry.test.ts +319 -0
- package/src/modules/billing/__tests__/events.test.ts +142 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +3 -3
- package/src/modules/billing/api/export/account/[id]/route.ts +7 -7
- package/src/modules/billing/api/invoices/add-line/route.ts +1 -1
- package/src/modules/billing/api/invoices/edit-line/route.ts +2 -2
- package/src/modules/billing/api/invoices/remove-line/route.ts +1 -1
- package/src/modules/billing/api/run-outcomes/route.ts +2 -2
- package/src/modules/billing/api/runs/retry-failed/route.ts +2 -2
- package/src/modules/billing/api/runs/route.ts +2 -2
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/accounts/page.tsx +1 -1
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/invoices/page.tsx +1 -1
- package/src/modules/billing/backend/billing/items/page.tsx +1 -1
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/runs/page.tsx +1 -1
- package/src/modules/billing/backend/billing/statements/[id]/page.tsx +1 -1
- package/src/modules/billing/backend/billing/statements/page.tsx +1 -1
- package/src/modules/billing/backend/billing/topups/page.tsx +1 -1
- package/src/modules/billing/backend/billing/transactions/page.tsx +1 -1
- package/src/modules/billing/ce.ts +8 -7
- package/src/modules/billing/commands/invoices.ts +9 -9
- package/src/modules/billing/commands/runs.ts +3 -3
- package/src/modules/billing/components/PrepaidPanel.tsx +1 -1
- package/src/modules/billing/data/entities.ts +31 -10
- package/src/modules/billing/data/entityIds.ts +16 -11
- package/src/modules/billing/events.ts +26 -0
- package/src/modules/billing/lib/billRunEngine.ts +12 -12
- package/src/modules/billing/lib/billRunLabel.ts +1 -1
- package/src/modules/billing/lib/prepaidPeriodClose.ts +3 -3
- package/src/modules/billing/lib/tenantLock.ts +1 -1
- package/src/modules/billing/workers/reap-stale-bill-runs.ts +4 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -3,7 +3,7 @@ import * as React from 'react'
|
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
5
5
|
import { Plus } from 'lucide-react'
|
|
6
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
6
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
9
9
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { useParams } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'
|
|
8
8
|
import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { Play } from 'lucide-react'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
8
8
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { useParams } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
8
8
|
import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { CustomEntitySpec } from '@open-mercato/shared/modules/entities'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Billing module entity
|
|
4
|
+
* Billing module custom-entity specs (labels / custom-field surfaces).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* These ids are real MikroORM tables, not virtual custom entities. The
|
|
7
|
+
* framework only knows that while each id is also in the app's generated
|
|
8
|
+
* `E.billing` registry (fed by the package's shipped
|
|
9
|
+
* `generated/entities.ids.generated.ts`). For an id missing there,
|
|
10
|
+
* `mercato entities install` writes an active `custom_entities` row, and the
|
|
11
|
+
* query engine then reads the id from custom-entity storage instead of its
|
|
12
|
+
* table — every billing list comes back empty.
|
|
12
13
|
*
|
|
13
14
|
* `fields: []` means billing does NOT expose custom-field surfaces on
|
|
14
15
|
* these entities today (no EAV). Adding fields here is reserved for
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '@open-mercato/shared/lib/commands/scope'
|
|
9
9
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
10
|
import { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
11
|
-
import { BillingAccount,
|
|
11
|
+
import { BillingAccount, BillingDraftInvoiceEdit } from '../data/entities'
|
|
12
12
|
import { emitBillingEvent, type BillingEventId } from '../events'
|
|
13
13
|
import { buildPostpaidCreditCrossingEvents } from '../lib/accountStatus'
|
|
14
14
|
import {
|
|
@@ -182,7 +182,7 @@ const postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> =
|
|
|
182
182
|
// Three commands sharing the same shape:
|
|
183
183
|
// 1. Assert the invoice is still a draft (refuse otherwise).
|
|
184
184
|
// 2. Mutate the line / collection.
|
|
185
|
-
// 3. Write a `
|
|
185
|
+
// 3. Write a `BillingDraftInvoiceEdit` audit row carrying before+after
|
|
186
186
|
// snapshots (`null` on the "no prior state" / "no resulting state"
|
|
187
187
|
// side per the spec — see entity column docs).
|
|
188
188
|
// 4. Recompute the invoice totals from the surviving lines.
|
|
@@ -267,7 +267,7 @@ async function recomputeInvoiceTotals(
|
|
|
267
267
|
|
|
268
268
|
function getUserIdFromCtx(ctx: CommandRuntimeContext): string {
|
|
269
269
|
const userId = (ctx.auth?.sub as string | undefined) ?? null
|
|
270
|
-
// The
|
|
270
|
+
// The BillingDraftInvoiceEdit.user_id column is NOT NULL. For system-driven
|
|
271
271
|
// edits (e.g. catch-up flow that revises a value), we fall back to
|
|
272
272
|
// a sentinel UUID. Operator-driven edits always have a real
|
|
273
273
|
// ctx.auth.sub.
|
|
@@ -303,7 +303,7 @@ const editDraftLineCommand: CommandHandler<
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
305
|
const before = snapshotLine(line)
|
|
306
|
-
let audit:
|
|
306
|
+
let audit: BillingDraftInvoiceEdit | null = null
|
|
307
307
|
|
|
308
308
|
await withAtomicFlush(em, [
|
|
309
309
|
() => {
|
|
@@ -339,7 +339,7 @@ const editDraftLineCommand: CommandHandler<
|
|
|
339
339
|
},
|
|
340
340
|
() => {
|
|
341
341
|
const after = snapshotLine(line)
|
|
342
|
-
audit = em.create(
|
|
342
|
+
audit = em.create(BillingDraftInvoiceEdit, {
|
|
343
343
|
tenantId: parsed.tenantId,
|
|
344
344
|
organizationId: parsed.organizationId,
|
|
345
345
|
invoiceId: invoice.id,
|
|
@@ -408,7 +408,7 @@ const addDraftLineCommand: CommandHandler<
|
|
|
408
408
|
operator_added: true,
|
|
409
409
|
},
|
|
410
410
|
})
|
|
411
|
-
let audit:
|
|
411
|
+
let audit: BillingDraftInvoiceEdit | null = null
|
|
412
412
|
|
|
413
413
|
await withAtomicFlush(em, [
|
|
414
414
|
() => {
|
|
@@ -419,7 +419,7 @@ const addDraftLineCommand: CommandHandler<
|
|
|
419
419
|
},
|
|
420
420
|
() => {
|
|
421
421
|
const after = snapshotLine(line)
|
|
422
|
-
audit = em.create(
|
|
422
|
+
audit = em.create(BillingDraftInvoiceEdit, {
|
|
423
423
|
tenantId: parsed.tenantId,
|
|
424
424
|
organizationId: parsed.organizationId,
|
|
425
425
|
invoiceId: invoice.id,
|
|
@@ -466,7 +466,7 @@ const removeDraftLineCommand: CommandHandler<
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
const before = snapshotLine(line)
|
|
469
|
-
let audit:
|
|
469
|
+
let audit: BillingDraftInvoiceEdit | null = null
|
|
470
470
|
|
|
471
471
|
await withAtomicFlush(em, [
|
|
472
472
|
() => {
|
|
@@ -476,7 +476,7 @@ const removeDraftLineCommand: CommandHandler<
|
|
|
476
476
|
await recomputeInvoiceTotals(em, invoice)
|
|
477
477
|
},
|
|
478
478
|
() => {
|
|
479
|
-
audit = em.create(
|
|
479
|
+
audit = em.create(BillingDraftInvoiceEdit, {
|
|
480
480
|
tenantId: parsed.tenantId,
|
|
481
481
|
organizationId: parsed.organizationId,
|
|
482
482
|
invoiceId: invoice.id,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
ensureTenantScope,
|
|
8
8
|
} from '@open-mercato/shared/lib/commands/scope'
|
|
9
9
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
-
import {
|
|
10
|
+
import { BillingRun, BillingRunOutcome } from '../data/entities'
|
|
11
11
|
import {
|
|
12
12
|
billingRunRetryFailedSchema,
|
|
13
13
|
billingRunTriggerSchema,
|
|
@@ -107,7 +107,7 @@ const retryFailedCommand: CommandHandler<BillingRunRetryFailedInput, TriggerResu
|
|
|
107
107
|
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
108
108
|
|
|
109
109
|
const em = getEm(ctx)
|
|
110
|
-
const parentRun = await em.findOne(
|
|
110
|
+
const parentRun = await em.findOne(BillingRun, {
|
|
111
111
|
id: parsed.billRunId,
|
|
112
112
|
tenantId: parsed.tenantId,
|
|
113
113
|
organizationId: parsed.organizationId,
|
|
@@ -116,7 +116,7 @@ const retryFailedCommand: CommandHandler<BillingRunRetryFailedInput, TriggerResu
|
|
|
116
116
|
if (!parentRun) {
|
|
117
117
|
throw new CrudHttpError(404, { error: 'Parent Bill Run not found' })
|
|
118
118
|
}
|
|
119
|
-
const failedOutcomes = await em.find(
|
|
119
|
+
const failedOutcomes = await em.find(BillingRunOutcome, {
|
|
120
120
|
billRunId: parentRun.id,
|
|
121
121
|
tenantId: parsed.tenantId,
|
|
122
122
|
organizationId: parsed.organizationId,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { Plus, SlidersHorizontal } from 'lucide-react'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
7
|
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
8
8
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
@@ -318,13 +318,20 @@ export class BillingAccountUsage {
|
|
|
318
318
|
deletedAt?: Date | null
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
// ───
|
|
321
|
+
// ─── BillingRun ──────────────────────────────────────────────────
|
|
322
322
|
//
|
|
323
323
|
// A single execution of the engine — one row per trigger (schedule or
|
|
324
324
|
// manual). The concurrency lock is a Postgres transaction-scoped advisory
|
|
325
325
|
// lock keyed by hash(tenant_id || 'billing-run'); it is NOT stored on this
|
|
326
326
|
// row. Crash recovery for zombie `status='running'` rows is handled by
|
|
327
327
|
// workers/reap-stale-bill-runs.ts.
|
|
328
|
+
//
|
|
329
|
+
// Class names must be the PascalCase of the entity-id suffix (`billing:
|
|
330
|
+
// billing_run` → `BillingRun`) — that is how Open Mercato maps an id to its
|
|
331
|
+
// table. Until 0.20.0 this class was `BillRun` (likewise `BillRunOutcome`,
|
|
332
|
+
// `DraftInvoiceEdit`), and those three ids resolved to no table; see
|
|
333
|
+
// data/entityIds.ts. Type-only aliases for the old names are at the end of
|
|
334
|
+
// this file.
|
|
328
335
|
|
|
329
336
|
@Entity({ tableName: 'billing_runs' })
|
|
330
337
|
@Index({
|
|
@@ -335,7 +342,7 @@ export class BillingAccountUsage {
|
|
|
335
342
|
name: 'billing_runs_parent_idx',
|
|
336
343
|
properties: ['parentRunId'],
|
|
337
344
|
})
|
|
338
|
-
export class
|
|
345
|
+
export class BillingRun {
|
|
339
346
|
[OptionalProps]?:
|
|
340
347
|
| 'parentRunId'
|
|
341
348
|
| 'dryRun'
|
|
@@ -417,10 +424,10 @@ export class BillRun {
|
|
|
417
424
|
deletedAt?: Date | null
|
|
418
425
|
}
|
|
419
426
|
|
|
420
|
-
// ───
|
|
427
|
+
// ─── BillingRunOutcome ───────────────────────────────────────────
|
|
421
428
|
//
|
|
422
429
|
// Per-account outcome of a Bill Run. Stored in its own table (not as a
|
|
423
|
-
// JSONB array on
|
|
430
|
+
// JSONB array on BillingRun.summary) so operators can query failures across
|
|
424
431
|
// runs without jsonb_array_elements gymnastics. See spec rationale.
|
|
425
432
|
|
|
426
433
|
@Entity({ tableName: 'billing_run_outcomes' })
|
|
@@ -432,7 +439,7 @@ export class BillRun {
|
|
|
432
439
|
name: 'billing_run_outcomes_account_history_idx',
|
|
433
440
|
properties: ['organizationId', 'tenantId', 'billAccountId', 'createdAt'],
|
|
434
441
|
})
|
|
435
|
-
export class
|
|
442
|
+
export class BillingRunOutcome {
|
|
436
443
|
[OptionalProps]?:
|
|
437
444
|
| 'errorMessage'
|
|
438
445
|
| 'warnings'
|
|
@@ -493,7 +500,7 @@ export class BillRunOutcome {
|
|
|
493
500
|
deletedAt?: Date | null
|
|
494
501
|
}
|
|
495
502
|
|
|
496
|
-
// ───
|
|
503
|
+
// ─── BillingDraftInvoiceEdit (audit) ─────────────────────────────
|
|
497
504
|
//
|
|
498
505
|
// Append-only audit of every operator edit on a draft invoice line.
|
|
499
506
|
// Pattern mirrors `cpq_subscription_change_logs` — minimal columns
|
|
@@ -509,7 +516,7 @@ export class BillRunOutcome {
|
|
|
509
516
|
name: 'billing_draft_invoice_edits_user_idx',
|
|
510
517
|
properties: ['organizationId', 'tenantId', 'userId', 'createdAt'],
|
|
511
518
|
})
|
|
512
|
-
export class
|
|
519
|
+
export class BillingDraftInvoiceEdit {
|
|
513
520
|
[OptionalProps]?: 'invoiceLineId' | 'beforeJson' | 'afterJson' | 'createdAt' | 'deletedAt'
|
|
514
521
|
|
|
515
522
|
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
@@ -630,8 +637,8 @@ export class BillingAccountBalance {
|
|
|
630
637
|
// ─── BillingAccountTransaction (SPEC-002, append-only) ───────────
|
|
631
638
|
//
|
|
632
639
|
// The source of truth for every balance movement. Append-only — pattern
|
|
633
|
-
// mirrors
|
|
634
|
-
// reserved for compliance erasure only). `balance = SUM(amount)`.
|
|
640
|
+
// mirrors BillingDraftInvoiceEdit (no `updated_at` / `is_active`;
|
|
641
|
+
// `deleted_at` reserved for compliance erasure only). `balance = SUM(amount)`.
|
|
635
642
|
//
|
|
636
643
|
// The unique `(tenant_id, bill_account_id, source_ref)` is the idempotency
|
|
637
644
|
// gate: a deduplicated usage upload or a retried recurring charge can never
|
|
@@ -860,7 +867,7 @@ export class BillingStatement {
|
|
|
860
867
|
@Property({ name: 'bill_account_id', type: 'uuid' })
|
|
861
868
|
billAccountId!: string
|
|
862
869
|
|
|
863
|
-
// FK (string) to the
|
|
870
|
+
// FK (string) to the BillingRun that generated it (period close reuses the
|
|
864
871
|
// run engine). Null for a manually-generated statement.
|
|
865
872
|
@Property({ name: 'bill_run_id', type: 'uuid', nullable: true })
|
|
866
873
|
billRunId?: string | null
|
|
@@ -914,3 +921,17 @@ export class BillingStatement {
|
|
|
914
921
|
@Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
|
|
915
922
|
updatedAt: Date = new Date()
|
|
916
923
|
}
|
|
924
|
+
|
|
925
|
+
// ─── Deprecated names (0.20.0) ───────────────────────────────────
|
|
926
|
+
//
|
|
927
|
+
// Type-only on purpose: a runtime alias (`export const BillRun = BillingRun`)
|
|
928
|
+
// would make the app's generated `entities` list carry the same class twice.
|
|
929
|
+
// Code that used the old names as values (`em.find(BillRun, …)`) must switch
|
|
930
|
+
// to the new ones — TypeScript flags it.
|
|
931
|
+
|
|
932
|
+
/** @deprecated Renamed to {@link BillingRun} in 0.20.0. */
|
|
933
|
+
export type BillRun = BillingRun
|
|
934
|
+
/** @deprecated Renamed to {@link BillingRunOutcome} in 0.20.0. */
|
|
935
|
+
export type BillRunOutcome = BillingRunOutcome
|
|
936
|
+
/** @deprecated Renamed to {@link BillingDraftInvoiceEdit} in 0.20.0. */
|
|
937
|
+
export type DraftInvoiceEdit = BillingDraftInvoiceEdit
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical entity IDs for the billing module.
|
|
2
|
+
* Canonical entity IDs for the billing module — the single source of truth.
|
|
3
|
+
* Billing code (CRUD factory `entityId`, indexer `entityType`, response
|
|
4
|
+
* enricher `targetEntity`, …) imports from here; a package must not import
|
|
5
|
+
* the app's `#generated/entities.ids.generated`.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* place that previously would have referenced `E.billing.*` (CRUD
|
|
10
|
-
* factory `entityId`, indexer `entityType`, response enricher
|
|
11
|
-
* `targetEntity`, etc.) imports from here instead.
|
|
7
|
+
* The build turns this object into the shipped registry
|
|
8
|
+
* `generated/entities.ids.generated.ts` (scripts/generate-entity-registry.mjs),
|
|
9
|
+
* which a consuming app's `mercato generate` reads to register these ids in
|
|
10
|
+
* its own `E.billing`. Without that, the query index rejects billing writes
|
|
11
|
+
* and `mercato entities install` treats the ids as virtual custom entities.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Adding an entity: add it here, then `yarn workspace @dainamite/billing
|
|
14
|
+
* build` and commit `generated/`. The id suffix must be the snake_case of
|
|
15
|
+
* the entity class name (`billing_run` → `BillingRun`): that is how the
|
|
16
|
+
* framework maps an id to its table, and the build fails otherwise. Do not
|
|
17
|
+
* rely on its table-name fallback — on Open Mercato 0.9 with MikroORM 7 it
|
|
18
|
+
* never matches (it iterates `MetadataStorage.getAll()` as an array, but
|
|
19
|
+
* that returns a Map).
|
|
15
20
|
*/
|
|
16
21
|
export const billingEntityIds = {
|
|
17
22
|
account: 'billing:billing_account',
|
|
@@ -12,8 +12,34 @@ import { createModuleEvents } from '@open-mercato/shared/modules/events'
|
|
|
12
12
|
* - `billing.run.completed` fires once per Bill Run after all per-account
|
|
13
13
|
* transactions finished (status is one of `completed` / `partial_failure`
|
|
14
14
|
* / `failed`). Useful for "did the nightly run go OK?" alerts.
|
|
15
|
+
*
|
|
16
|
+
* CRUD events (`billing.<entity>.<action>`) are emitted by the data engine
|
|
17
|
+
* from the commands' `emitCrudSideEffects({ events: { module: 'billing',
|
|
18
|
+
* entity } })`. Only the (entity, action) pairs a command actually emits are
|
|
19
|
+
* declared — `__tests__/events.test.ts` fails when a command emits one that
|
|
20
|
+
* is missing here (the data engine would log "Emitting undeclared event").
|
|
15
21
|
*/
|
|
16
22
|
const events = [
|
|
23
|
+
// ─── CRUD (commands/*.ts) ────────────────────────────────────
|
|
24
|
+
{ id: 'billing.account.created', label: 'Billing Account Created', entity: 'account', category: 'crud' },
|
|
25
|
+
{ id: 'billing.account.updated', label: 'Billing Account Updated', entity: 'account', category: 'crud' },
|
|
26
|
+
{ id: 'billing.account.deleted', label: 'Billing Account Deleted', entity: 'account', category: 'crud' },
|
|
27
|
+
{ id: 'billing.item.created', label: 'Billing Item Created', entity: 'item', category: 'crud' },
|
|
28
|
+
{ id: 'billing.item.updated', label: 'Billing Item Updated', entity: 'item', category: 'crud' },
|
|
29
|
+
{ id: 'billing.item.deleted', label: 'Billing Item Deleted', entity: 'item', category: 'crud' },
|
|
30
|
+
{
|
|
31
|
+
// One per usage upload, on the same high-throughput path as
|
|
32
|
+
// `billing.usage.rated` — excluded from workflow triggers for the same
|
|
33
|
+
// reason (see Open Question #2).
|
|
34
|
+
id: 'billing.usage.created',
|
|
35
|
+
label: 'Billing Usage Record Created',
|
|
36
|
+
entity: 'usage',
|
|
37
|
+
category: 'crud',
|
|
38
|
+
excludeFromTriggers: true,
|
|
39
|
+
},
|
|
40
|
+
{ id: 'billing.transaction.created', label: 'Billing Balance Transaction Created', entity: 'transaction', category: 'crud' },
|
|
41
|
+
{ id: 'billing.topup.created', label: 'Billing Top-up Created', entity: 'topup', category: 'crud' },
|
|
42
|
+
// ─── Lifecycle ───────────────────────────────────────────────
|
|
17
43
|
{
|
|
18
44
|
id: 'billing.invoice.posted',
|
|
19
45
|
label: 'Billing Invoice Posted',
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
BillingAccount,
|
|
5
5
|
BillingAccountUsage,
|
|
6
6
|
BillingItem,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
BillingRun,
|
|
8
|
+
BillingRunOutcome,
|
|
9
9
|
} from '../data/entities'
|
|
10
10
|
import { advanceNextBillDate, deriveBillPeriod, isCycleDue } from './billPeriod'
|
|
11
11
|
import {
|
|
@@ -72,8 +72,8 @@ export type RunBillRunParams = {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export type RunBillRunResult = {
|
|
75
|
-
billRun:
|
|
76
|
-
outcomes:
|
|
75
|
+
billRun: BillingRun
|
|
76
|
+
outcomes: BillingRunOutcome[]
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
type RunOutput = RunBillRunResult & {
|
|
@@ -136,7 +136,7 @@ async function executeRunWithinLock(
|
|
|
136
136
|
params: RunBillRunParams,
|
|
137
137
|
): Promise<RunOutput> {
|
|
138
138
|
const now = new Date()
|
|
139
|
-
const billRun = em.create(
|
|
139
|
+
const billRun = em.create(BillingRun, {
|
|
140
140
|
organizationId: params.organizationId,
|
|
141
141
|
tenantId: params.tenantId,
|
|
142
142
|
triggeredBy: params.triggeredBy,
|
|
@@ -157,7 +157,7 @@ async function executeRunWithinLock(
|
|
|
157
157
|
await em.flush()
|
|
158
158
|
|
|
159
159
|
const accounts = await selectTargetAccounts(em, params)
|
|
160
|
-
const outcomes:
|
|
160
|
+
const outcomes: BillingRunOutcome[] = []
|
|
161
161
|
const pendingEvents: DomainEvent[] = []
|
|
162
162
|
let totalUsageRecordsRated = 0
|
|
163
163
|
|
|
@@ -166,7 +166,7 @@ async function executeRunWithinLock(
|
|
|
166
166
|
if (processResult.status !== 'failed') {
|
|
167
167
|
totalUsageRecordsRated += processResult.usageRecordsRated
|
|
168
168
|
}
|
|
169
|
-
const outcome = em.create(
|
|
169
|
+
const outcome = em.create(BillingRunOutcome, {
|
|
170
170
|
organizationId: params.organizationId,
|
|
171
171
|
tenantId: params.tenantId,
|
|
172
172
|
billRunId: billRun.id,
|
|
@@ -222,7 +222,7 @@ async function selectTargetAccounts(
|
|
|
222
222
|
async function processAccountSafely(
|
|
223
223
|
em: EntityManager,
|
|
224
224
|
container: AwilixContainer,
|
|
225
|
-
billRun:
|
|
225
|
+
billRun: BillingRun,
|
|
226
226
|
account: BillingAccount,
|
|
227
227
|
params: RunBillRunParams,
|
|
228
228
|
): Promise<AccountProcessResult> {
|
|
@@ -241,7 +241,7 @@ async function processAccountSafely(
|
|
|
241
241
|
async function processAccount(
|
|
242
242
|
em: EntityManager,
|
|
243
243
|
container: AwilixContainer,
|
|
244
|
-
billRun:
|
|
244
|
+
billRun: BillingRun,
|
|
245
245
|
account: BillingAccount,
|
|
246
246
|
params: RunBillRunParams,
|
|
247
247
|
): Promise<AccountProcessResult> {
|
|
@@ -438,8 +438,8 @@ async function findExistingDraftForPeriod(
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
function computeFinalRunStatus(
|
|
441
|
-
outcomes: readonly
|
|
442
|
-
):
|
|
441
|
+
outcomes: readonly BillingRunOutcome[],
|
|
442
|
+
): BillingRun['status'] {
|
|
443
443
|
if (outcomes.length === 0) return 'completed'
|
|
444
444
|
const anyFailed = outcomes.some((o) => o.status === 'failed')
|
|
445
445
|
const anySuccess = outcomes.some(
|
|
@@ -451,7 +451,7 @@ function computeFinalRunStatus(
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
function buildRunSummary(
|
|
454
|
-
outcomes: readonly
|
|
454
|
+
outcomes: readonly BillingRunOutcome[],
|
|
455
455
|
totalAccounts: number,
|
|
456
456
|
usageRecordsRated: number,
|
|
457
457
|
): Record<string, unknown> {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Human-readable label for a Bill Run.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The BillingRun entity has no `name` column — the engine keys runs by id.
|
|
5
5
|
* The admin UI derives a stable, scannable label from the run's as-of
|
|
6
6
|
* date so operators see `BILL_RUN_2026-05-20` instead of a raw UUID.
|
|
7
7
|
*/
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
BillingAccountTransaction,
|
|
6
6
|
BillingItem,
|
|
7
7
|
BillingStatement,
|
|
8
|
-
|
|
8
|
+
BillingRun,
|
|
9
9
|
} from '../data/entities'
|
|
10
10
|
import type { BillCycle } from '../data/validators'
|
|
11
11
|
import { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'
|
|
@@ -75,7 +75,7 @@ export type ProcessPrepaidParams = {
|
|
|
75
75
|
export async function processPrepaidAccount(
|
|
76
76
|
em: EntityManager,
|
|
77
77
|
account: BillingAccount,
|
|
78
|
-
billRun:
|
|
78
|
+
billRun: BillingRun,
|
|
79
79
|
params: ProcessPrepaidParams,
|
|
80
80
|
): Promise<PrepaidAccountResult> {
|
|
81
81
|
const maxCycles =
|
|
@@ -212,7 +212,7 @@ async function closePrepaidPeriod(
|
|
|
212
212
|
em: EntityManager,
|
|
213
213
|
account: BillingAccount,
|
|
214
214
|
period: BillPeriod,
|
|
215
|
-
billRun:
|
|
215
|
+
billRun: BillingRun,
|
|
216
216
|
opts: {
|
|
217
217
|
mode: PrepaidCloseMode
|
|
218
218
|
statementEnabled: boolean
|
|
@@ -13,7 +13,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
13
13
|
* signal instead of waiting on a row.
|
|
14
14
|
* - It's released automatically when the transaction ends (commit,
|
|
15
15
|
* rollback, or process death) — so there is no lock-leak path even
|
|
16
|
-
* on `kill -9`. The `
|
|
16
|
+
* on `kill -9`. The `BillingRun` row's `status='running'` may be left
|
|
17
17
|
* hanging, but the lock itself vanishes; the reaper worker
|
|
18
18
|
* (workers/reap-stale-bill-runs.ts) handles the orphaned row.
|
|
19
19
|
* - It works without a sentinel table — we hash a string key into the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
|
|
2
2
|
import type { FilterQuery } from '@mikro-orm/core'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
-
import {
|
|
4
|
+
import { BillingRun } from '../data/entities'
|
|
5
5
|
|
|
6
6
|
export const metadata: WorkerMeta = {
|
|
7
7
|
queue: 'billing-reap-stale-runs',
|
|
@@ -19,7 +19,7 @@ type HandlerContext = JobContext & {
|
|
|
19
19
|
* Background — the Bill Run engine acquires a per-tenant Postgres
|
|
20
20
|
* transaction-scoped advisory lock (`pg_try_advisory_xact_lock`). When the
|
|
21
21
|
* holding transaction ends (commit / rollback / process death) the lock is
|
|
22
|
-
* released automatically — so there is no lock leak — but the `
|
|
22
|
+
* released automatically — so there is no lock leak — but the `BillingRun`
|
|
23
23
|
* row's `status='running'` stays set forever, blocking restarts and
|
|
24
24
|
* confusing the dashboard.
|
|
25
25
|
*
|
|
@@ -38,10 +38,10 @@ const STALE_THRESHOLD_MS = 60 * 60 * 1000 // 1 hour
|
|
|
38
38
|
export default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {
|
|
39
39
|
const em = ctx.resolve<EntityManager>('em')
|
|
40
40
|
const threshold = new Date(Date.now() - STALE_THRESHOLD_MS)
|
|
41
|
-
const stale = await em.find(
|
|
41
|
+
const stale = await em.find(BillingRun, {
|
|
42
42
|
status: 'running',
|
|
43
43
|
startedAt: { $lt: threshold },
|
|
44
|
-
} satisfies FilterQuery<
|
|
44
|
+
} satisfies FilterQuery<BillingRun>)
|
|
45
45
|
if (stale.length === 0) return
|
|
46
46
|
const now = new Date()
|
|
47
47
|
for (const run of stale) {
|