@dainamite/billing 0.18.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +14 -0
  3. package/dist/modules/billing/api/export/account/[id]/route.js +5 -5
  4. package/dist/modules/billing/api/export/account/[id]/route.js.map +1 -1
  5. package/dist/modules/billing/api/invoices/add-line/route.js +1 -1
  6. package/dist/modules/billing/api/invoices/add-line/route.js.map +1 -1
  7. package/dist/modules/billing/api/invoices/edit-line/route.js +1 -1
  8. package/dist/modules/billing/api/invoices/edit-line/route.js.map +1 -1
  9. package/dist/modules/billing/api/invoices/remove-line/route.js +1 -1
  10. package/dist/modules/billing/api/invoices/remove-line/route.js.map +1 -1
  11. package/dist/modules/billing/api/run-outcomes/route.js +2 -2
  12. package/dist/modules/billing/api/run-outcomes/route.js.map +2 -2
  13. package/dist/modules/billing/api/runs/retry-failed/route.js +2 -2
  14. package/dist/modules/billing/api/runs/retry-failed/route.js.map +2 -2
  15. package/dist/modules/billing/api/runs/route.js +2 -2
  16. package/dist/modules/billing/api/runs/route.js.map +2 -2
  17. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +1 -1
  18. package/dist/modules/billing/backend/billing/accounts/page.js.map +1 -1
  19. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +1 -1
  20. package/dist/modules/billing/backend/billing/invoices/page.js.map +1 -1
  21. package/dist/modules/billing/backend/billing/items/page.js.map +1 -1
  22. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +1 -1
  23. package/dist/modules/billing/backend/billing/runs/page.js.map +1 -1
  24. package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +1 -1
  25. package/dist/modules/billing/backend/billing/statements/page.js.map +1 -1
  26. package/dist/modules/billing/backend/billing/topups/page.js.map +1 -1
  27. package/dist/modules/billing/backend/billing/transactions/page.js.map +1 -1
  28. package/dist/modules/billing/ce.js.map +2 -2
  29. package/dist/modules/billing/commands/invoices.js +4 -4
  30. package/dist/modules/billing/commands/invoices.js.map +2 -2
  31. package/dist/modules/billing/commands/runs.js +3 -3
  32. package/dist/modules/billing/commands/runs.js.map +2 -2
  33. package/dist/modules/billing/components/PrepaidPanel.js.map +1 -1
  34. package/dist/modules/billing/data/entities.js +56 -56
  35. package/dist/modules/billing/data/entities.js.map +2 -2
  36. package/dist/modules/billing/data/entityIds.js.map +2 -2
  37. package/dist/modules/billing/events.js +20 -0
  38. package/dist/modules/billing/events.js.map +2 -2
  39. package/dist/modules/billing/lib/billRunEngine.js +4 -4
  40. package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
  41. package/dist/modules/billing/lib/billRunLabel.js.map +1 -1
  42. package/dist/modules/billing/lib/prepaidPeriodClose.js.map +1 -1
  43. package/dist/modules/billing/lib/tenantLock.js.map +1 -1
  44. package/dist/modules/billing/workers/reap-stale-bill-runs.js +2 -2
  45. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +2 -2
  46. package/generated/entities/billing_account/index.ts +23 -0
  47. package/generated/entities/billing_account_balance/index.ts +13 -0
  48. package/generated/entities/billing_account_transaction/index.ts +21 -0
  49. package/generated/entities/billing_account_usage/index.ts +18 -0
  50. package/generated/entities/billing_draft_invoice_edit/index.ts +14 -0
  51. package/generated/entities/billing_item/index.ts +22 -0
  52. package/generated/entities/billing_run/index.ts +21 -0
  53. package/generated/entities/billing_run_outcome/index.ts +17 -0
  54. package/generated/entities/billing_statement/index.ts +21 -0
  55. package/generated/entities/billing_topup/index.ts +20 -0
  56. package/generated/entities.ids.generated.ts +28 -0
  57. package/package.json +11 -10
  58. package/src/modules/billing/__tests__/commands/invoices.test.ts +8 -8
  59. package/src/modules/billing/__tests__/commands/topups.test.ts +1 -1
  60. package/src/modules/billing/__tests__/commands/transactions.test.ts +25 -2
  61. package/src/modules/billing/__tests__/entityRegistry.test.ts +319 -0
  62. package/src/modules/billing/__tests__/events.test.ts +142 -0
  63. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +3 -3
  64. package/src/modules/billing/api/export/account/[id]/route.ts +7 -7
  65. package/src/modules/billing/api/invoices/add-line/route.ts +1 -1
  66. package/src/modules/billing/api/invoices/edit-line/route.ts +2 -2
  67. package/src/modules/billing/api/invoices/remove-line/route.ts +1 -1
  68. package/src/modules/billing/api/run-outcomes/route.ts +2 -2
  69. package/src/modules/billing/api/runs/retry-failed/route.ts +2 -2
  70. package/src/modules/billing/api/runs/route.ts +2 -2
  71. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +1 -1
  72. package/src/modules/billing/backend/billing/accounts/page.tsx +1 -1
  73. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +1 -1
  74. package/src/modules/billing/backend/billing/invoices/page.tsx +1 -1
  75. package/src/modules/billing/backend/billing/items/page.tsx +1 -1
  76. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +1 -1
  77. package/src/modules/billing/backend/billing/runs/page.tsx +1 -1
  78. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +1 -1
  79. package/src/modules/billing/backend/billing/statements/page.tsx +1 -1
  80. package/src/modules/billing/backend/billing/topups/page.tsx +1 -1
  81. package/src/modules/billing/backend/billing/transactions/page.tsx +1 -1
  82. package/src/modules/billing/ce.ts +8 -7
  83. package/src/modules/billing/commands/invoices.ts +9 -9
  84. package/src/modules/billing/commands/runs.ts +3 -3
  85. package/src/modules/billing/components/PrepaidPanel.tsx +1 -1
  86. package/src/modules/billing/data/entities.ts +31 -10
  87. package/src/modules/billing/data/entityIds.ts +16 -11
  88. package/src/modules/billing/events.ts +26 -0
  89. package/src/modules/billing/lib/billRunEngine.ts +12 -12
  90. package/src/modules/billing/lib/billRunLabel.ts +1 -1
  91. package/src/modules/billing/lib/prepaidPeriodClose.ts +3 -3
  92. package/src/modules/billing/lib/tenantLock.ts +1 -1
  93. package/src/modules/billing/workers/reap-stale-bill-runs.ts +4 -4
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/lib/prepaidPeriodClose.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n BillingAccount,\n BillingAccountBalance,\n BillingAccountTransaction,\n BillingItem,\n BillingStatement,\n BillRun,\n} from '../data/entities'\nimport type { BillCycle } from '../data/validators'\nimport { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'\nimport { applyBalanceMovement } from './balanceLedger'\nimport { resolveLowBalanceThreshold } from './balanceStatus'\nimport { buildInvoiceLinesFromItems } from './invoiceWriter'\nimport { selectItemsForPeriod, type SelectableItem } from './itemSelector'\nimport { absMoney, addMoney, fromUnits, subMoney, toUnits } from './money'\nimport {\n getLowBalanceThresholdDefault,\n getNearLimitBufferDefault,\n getStatementEnabled,\n} from './prepaidConfig'\nimport { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'\n\n/**\n * Prepaid period close (SPEC-002 P3).\n *\n * A prepaid account never gets a payable draft invoice. Instead the Bill Run,\n * for a due prepaid account:\n * 1. charges its `recurring` / `one_time` items to the **balance** (atomic\n * debit + transaction, deterministic `source_ref =\n * recurring-{itemId}-{periodEndISO}`, then `billed_to_date = period_end`),\n * 2. builds a non-fiscal **BillingStatement** (opening, topups, usage,\n * recurring, closing + per-item breakdown).\n *\n * Metered usage was already rated + debited in real time during the period \u2014\n * it is NOT re-rated here; the statement only summarizes it.\n *\n * Three anti-duplicate guards: the statement unique\n * `(bill_account_id, period_start, period_end)`, the per-item `billed_to_date`,\n * and the deterministic charge `source_ref`.\n */\n\nexport type PrepaidCloseMode = 'real' | 'test' | 'dry'\n\nexport type PrepaidAccountWarnings = {\n // Reserved for future per-close warnings (e.g. negative-balance close).\n closed_negative?: boolean\n}\n\nexport type PrepaidAccountResult =\n | {\n status: 'success' | 'success_with_warnings' | 'skipped_existing_statement'\n warnings: PrepaidAccountWarnings | null\n statementId: string | null\n cyclesEmitted: number\n statementEvents: DomainEvent[]\n }\n | { status: 'failed'; errorMessage: string }\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60\n\nexport type ProcessPrepaidParams = {\n asOfDate: Date\n mode: PrepaidCloseMode\n triggeredBy: 'schedule' | 'manual'\n catchUp?: boolean\n}\n\n/**\n * Multi-cycle prepaid account processing (mirrors the postpaid engine's\n * catch-up loop). One statement + one set of recurring debits per missed\n * period \u2014 never combined.\n */\nexport async function processPrepaidAccount(\n em: EntityManager,\n account: BillingAccount,\n billRun: BillRun,\n params: ProcessPrepaidParams,\n): Promise<PrepaidAccountResult> {\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n const statementEnabled = await getStatementEnabled(em)\n // Resolve the crossing thresholds once \u2014 they don't change during the run.\n const balanceRow = await em.findOne(BillingAccountBalance, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n })\n const threshold = resolveLowBalanceThreshold(\n balanceRow?.lowBalanceThreshold ?? null,\n await getLowBalanceThresholdDefault(em),\n )\n const nearLimitBuffer = await getNearLimitBufferDefault(em)\n const creditLimit = account.creditLimit ?? '0'\n\n let cyclesEmitted = 0\n let lastStatementId: string | null = null\n const statementEvents: DomainEvent[] = []\n // Chain opening \u2192 closing across cycles in a catch-up run. Null on the first\n // cycle \u2192 closePrepaidPeriod resolves it from the prior statement / ledger.\n let previousClosing: string | null = null\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Statement anti-duplicate (only when we would actually persist).\n if (params.mode !== 'dry') {\n const existing = await em.findOne(BillingStatement, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n })\n if (existing) {\n return {\n status: 'skipped_existing_statement',\n warnings: null,\n statementId: existing.id,\n cyclesEmitted,\n statementEvents,\n }\n }\n }\n\n const cycle = await closePrepaidPeriod(em, account, period, billRun, {\n mode: params.mode,\n statementEnabled,\n previousClosing,\n threshold,\n nearLimitBuffer,\n creditLimit,\n })\n previousClosing = cycle.closingBalance\n // Recurring charges move the balance too, so they can trip the same\n // low/exhausted/over_limit crossings as a usage debit (SPEC-002). These\n // are accumulated and emitted by the engine AFTER the run commits.\n statementEvents.push(...cycle.crossingEvents)\n\n // A negative closing balance is NORMAL for prepaid (the customer overspent;\n // it's cleared by the next top-up), so it is not flagged as a warning.\n if (cycle.statementId) {\n lastStatementId = cycle.statementId\n statementEvents.push({\n id: 'billing.statement.generated',\n payload: {\n statementId: cycle.statementId,\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billRunId: billRun.id,\n periodStart: period.periodStart.toISOString().slice(0, 10),\n periodEnd: period.periodEnd.toISOString().slice(0, 10),\n currencyCode: account.currencyCode,\n openingBalance: cycle.openingBalance,\n closingBalance: cycle.closingBalance,\n totalTopups: cycle.totalTopups,\n totalUsage: cycle.totalUsage,\n totalRecurring: cycle.totalRecurring,\n totalAdjustments: cycle.totalAdjustments,\n },\n })\n }\n\n if (params.mode === 'real') {\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n await em.flush()\n } else {\n // test / dry: do not advance state; one iteration only.\n cyclesEmitted += 1\n break\n }\n cyclesEmitted += 1\n }\n\n return {\n status: 'success',\n warnings: null,\n statementId: lastStatementId,\n cyclesEmitted,\n statementEvents,\n }\n}\n\ntype ClosePeriodResult = {\n statementId: string | null\n openingBalance: string\n totalTopups: string\n totalUsage: string\n totalRecurring: string\n totalAdjustments: string\n closingBalance: string\n crossingEvents: DomainEvent[]\n}\n\ntype DebitBreakdownEntry = {\n billing_item_id: string\n billing_type: string\n uom_code?: string\n quantity: string\n amount: string\n}\n\nasync function closePrepaidPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n billRun: BillRun,\n opts: {\n mode: PrepaidCloseMode\n statementEnabled: boolean\n previousClosing: string | null\n threshold: string\n nearLimitBuffer: string\n creditLimit: string\n },\n): Promise<ClosePeriodResult> {\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n\n // Recurring + one_time items eligible for this period (usage excluded by the\n // selector \u2014 it was rated in real time).\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n const { included } = selectItemsForPeriod(items as unknown as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n const lines = buildInvoiceLinesFromItems(billableItems)\n\n // Window bounds for categorized sums (topups / usage already in the ledger).\n const windowStart = period.periodStart\n const windowEnd = addDays(period.periodEnd, 1) // exclusive upper bound\n\n // Opening chains from the prior statement's closing \u2014 NOT a created_at sum.\n // Recurring debits are stamped at run time (after period_end), so a\n // `SUM(amount WHERE created_at < period_start)` would drop the prior period's\n // recurring charge and break the chain. Resolution order:\n // 1. previous cycle's closing (catch-up, in this run),\n // 2. the prior persisted statement's closing (month-over-month),\n // 3. the ledger balance before the first-ever statement window.\n const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing)\n const totalTopups = await sumByTypeInWindow(em, account, 'topup', windowStart, windowEnd)\n const totalUsage = absMoney(\n await sumByTypeInWindow(em, account, 'usage', windowStart, windowEnd),\n )\n // Manual adjustments / reversals that landed in the window \u2014 SIGNED (a credit\n // is +, a debit \u2212). Kept as a first-class term so the statement is\n // self-consistent and the opening\u2192closing chain never drifts.\n const totalAdjustments = addMoney(\n await sumByTypeInWindow(em, account, 'adjustment', windowStart, windowEnd),\n await sumByTypeInWindow(em, account, 'reversal', windowStart, windowEnd),\n )\n\n let totalRecurring = '0.0000'\n const breakdown: DebitBreakdownEntry[] = []\n const chargeTransactionIds: string[] = []\n // The balance just before / after the recurring batch \u2014 for crossing events.\n let recurringBalanceBefore: string | null = null\n let recurringBalanceAfter: string | null = null\n\n if (opts.mode === 'real') {\n const now = new Date()\n for (const line of lines) {\n const movement = await applyBalanceMovement(em, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n currencyCode: account.currencyCode,\n type: line.billingType === 'one_time' ? 'one_time' : 'recurring',\n amount: fromUnits(-toUnits(line.totalNetAmount)),\n description: line.description,\n billingItemId: line.billingItemId,\n sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,\n metadata: {\n bill_period_start: period.periodStart.toISOString().slice(0, 10),\n bill_period_end: periodEndISO,\n bill_run_id: billRun.id,\n },\n })\n if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore\n recurringBalanceAfter = movement.balanceAfter\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n const item = billableItems.find((it) => it.id === line.billingItemId)\n if (item) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n }\n await em.flush()\n } else {\n // test / dry: compute the would-be charges, persist nothing.\n for (const line of lines) {\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n }\n }\n\n // closing = opening + topups + adjustments \u2212 usage \u2212 recurring.\n const closingBalance = subMoney(\n subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),\n totalRecurring,\n )\n\n // The recurring batch can push the balance low / exhausted / over-limit \u2014\n // emit those crossings (real mode only; dry/test never move the balance).\n const crossingEvents =\n recurringBalanceBefore !== null && recurringBalanceAfter !== null\n ? buildBalanceCrossingEvents({\n scope: {\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n currencyCode: account.currencyCode,\n },\n balanceBefore: recurringBalanceBefore,\n balanceAfter: recurringBalanceAfter,\n threshold: opts.threshold,\n creditLimit: opts.creditLimit,\n nearLimitBuffer: opts.nearLimitBuffer,\n })\n : []\n\n let statementId: string | null = null\n if (opts.mode === 'real' && opts.statementEnabled) {\n const now = new Date()\n const statement = em.create(BillingStatement, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n billRunId: billRun.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n currencyCode: account.currencyCode,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n debitBreakdown: { items: breakdown },\n status: 'generated',\n createdAt: now,\n updatedAt: now,\n })\n em.persist(statement)\n await em.flush()\n statementId = statement.id\n\n // Backfill statement_id on the charge transactions for attribution.\n if (chargeTransactionIds.length > 0) {\n await em.nativeUpdate(\n BillingAccountTransaction,\n { id: { $in: chargeTransactionIds } } as never,\n { statementId } as never,\n )\n }\n }\n\n return {\n statementId,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n crossingEvents,\n }\n}\n\n/**\n * Resolve a period's opening balance by chaining from the prior statement's\n * closing (the spec's \"chains cleanly\"), with a ledger fallback for the very\n * first statement. See the call site for why a created_at sum alone is wrong.\n */\nasync function resolveOpeningBalance(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n previousClosing: string | null,\n): Promise<string> {\n if (previousClosing !== null) return previousClosing\n const prior = await em.findOne(\n BillingStatement,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n periodEnd: { $lt: period.periodStart },\n } as never,\n { orderBy: { periodEnd: 'DESC' } },\n )\n if (prior) return fromUnits(toUnits(prior.closingBalance))\n // First-ever statement: the ledger balance just before the period window\n // (only top-ups/usage exist before the first close, so created_at is safe).\n return sumAmountsBefore(em, account, period.periodStart)\n}\n\n/** Balance at `before` = SUM(amount) of transactions created strictly before it. */\nasync function sumAmountsBefore(\n em: EntityManager,\n account: BillingAccount,\n before: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, before],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n\n/** SUM(amount) for one transaction type within [from, to). */\nasync function sumByTypeInWindow(\n em: EntityManager,\n account: BillingAccount,\n type: string,\n from: Date,\n to: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND type = ?\n AND created_at >= ? AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, type, from, to],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n BillingAccount,\n BillingAccountBalance,\n BillingAccountTransaction,\n BillingItem,\n BillingStatement,\n BillingRun,\n} from '../data/entities'\nimport type { BillCycle } from '../data/validators'\nimport { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'\nimport { applyBalanceMovement } from './balanceLedger'\nimport { resolveLowBalanceThreshold } from './balanceStatus'\nimport { buildInvoiceLinesFromItems } from './invoiceWriter'\nimport { selectItemsForPeriod, type SelectableItem } from './itemSelector'\nimport { absMoney, addMoney, fromUnits, subMoney, toUnits } from './money'\nimport {\n getLowBalanceThresholdDefault,\n getNearLimitBufferDefault,\n getStatementEnabled,\n} from './prepaidConfig'\nimport { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'\n\n/**\n * Prepaid period close (SPEC-002 P3).\n *\n * A prepaid account never gets a payable draft invoice. Instead the Bill Run,\n * for a due prepaid account:\n * 1. charges its `recurring` / `one_time` items to the **balance** (atomic\n * debit + transaction, deterministic `source_ref =\n * recurring-{itemId}-{periodEndISO}`, then `billed_to_date = period_end`),\n * 2. builds a non-fiscal **BillingStatement** (opening, topups, usage,\n * recurring, closing + per-item breakdown).\n *\n * Metered usage was already rated + debited in real time during the period \u2014\n * it is NOT re-rated here; the statement only summarizes it.\n *\n * Three anti-duplicate guards: the statement unique\n * `(bill_account_id, period_start, period_end)`, the per-item `billed_to_date`,\n * and the deterministic charge `source_ref`.\n */\n\nexport type PrepaidCloseMode = 'real' | 'test' | 'dry'\n\nexport type PrepaidAccountWarnings = {\n // Reserved for future per-close warnings (e.g. negative-balance close).\n closed_negative?: boolean\n}\n\nexport type PrepaidAccountResult =\n | {\n status: 'success' | 'success_with_warnings' | 'skipped_existing_statement'\n warnings: PrepaidAccountWarnings | null\n statementId: string | null\n cyclesEmitted: number\n statementEvents: DomainEvent[]\n }\n | { status: 'failed'; errorMessage: string }\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60\n\nexport type ProcessPrepaidParams = {\n asOfDate: Date\n mode: PrepaidCloseMode\n triggeredBy: 'schedule' | 'manual'\n catchUp?: boolean\n}\n\n/**\n * Multi-cycle prepaid account processing (mirrors the postpaid engine's\n * catch-up loop). One statement + one set of recurring debits per missed\n * period \u2014 never combined.\n */\nexport async function processPrepaidAccount(\n em: EntityManager,\n account: BillingAccount,\n billRun: BillingRun,\n params: ProcessPrepaidParams,\n): Promise<PrepaidAccountResult> {\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n const statementEnabled = await getStatementEnabled(em)\n // Resolve the crossing thresholds once \u2014 they don't change during the run.\n const balanceRow = await em.findOne(BillingAccountBalance, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n })\n const threshold = resolveLowBalanceThreshold(\n balanceRow?.lowBalanceThreshold ?? null,\n await getLowBalanceThresholdDefault(em),\n )\n const nearLimitBuffer = await getNearLimitBufferDefault(em)\n const creditLimit = account.creditLimit ?? '0'\n\n let cyclesEmitted = 0\n let lastStatementId: string | null = null\n const statementEvents: DomainEvent[] = []\n // Chain opening \u2192 closing across cycles in a catch-up run. Null on the first\n // cycle \u2192 closePrepaidPeriod resolves it from the prior statement / ledger.\n let previousClosing: string | null = null\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Statement anti-duplicate (only when we would actually persist).\n if (params.mode !== 'dry') {\n const existing = await em.findOne(BillingStatement, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n })\n if (existing) {\n return {\n status: 'skipped_existing_statement',\n warnings: null,\n statementId: existing.id,\n cyclesEmitted,\n statementEvents,\n }\n }\n }\n\n const cycle = await closePrepaidPeriod(em, account, period, billRun, {\n mode: params.mode,\n statementEnabled,\n previousClosing,\n threshold,\n nearLimitBuffer,\n creditLimit,\n })\n previousClosing = cycle.closingBalance\n // Recurring charges move the balance too, so they can trip the same\n // low/exhausted/over_limit crossings as a usage debit (SPEC-002). These\n // are accumulated and emitted by the engine AFTER the run commits.\n statementEvents.push(...cycle.crossingEvents)\n\n // A negative closing balance is NORMAL for prepaid (the customer overspent;\n // it's cleared by the next top-up), so it is not flagged as a warning.\n if (cycle.statementId) {\n lastStatementId = cycle.statementId\n statementEvents.push({\n id: 'billing.statement.generated',\n payload: {\n statementId: cycle.statementId,\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billRunId: billRun.id,\n periodStart: period.periodStart.toISOString().slice(0, 10),\n periodEnd: period.periodEnd.toISOString().slice(0, 10),\n currencyCode: account.currencyCode,\n openingBalance: cycle.openingBalance,\n closingBalance: cycle.closingBalance,\n totalTopups: cycle.totalTopups,\n totalUsage: cycle.totalUsage,\n totalRecurring: cycle.totalRecurring,\n totalAdjustments: cycle.totalAdjustments,\n },\n })\n }\n\n if (params.mode === 'real') {\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n await em.flush()\n } else {\n // test / dry: do not advance state; one iteration only.\n cyclesEmitted += 1\n break\n }\n cyclesEmitted += 1\n }\n\n return {\n status: 'success',\n warnings: null,\n statementId: lastStatementId,\n cyclesEmitted,\n statementEvents,\n }\n}\n\ntype ClosePeriodResult = {\n statementId: string | null\n openingBalance: string\n totalTopups: string\n totalUsage: string\n totalRecurring: string\n totalAdjustments: string\n closingBalance: string\n crossingEvents: DomainEvent[]\n}\n\ntype DebitBreakdownEntry = {\n billing_item_id: string\n billing_type: string\n uom_code?: string\n quantity: string\n amount: string\n}\n\nasync function closePrepaidPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n billRun: BillingRun,\n opts: {\n mode: PrepaidCloseMode\n statementEnabled: boolean\n previousClosing: string | null\n threshold: string\n nearLimitBuffer: string\n creditLimit: string\n },\n): Promise<ClosePeriodResult> {\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n\n // Recurring + one_time items eligible for this period (usage excluded by the\n // selector \u2014 it was rated in real time).\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n const { included } = selectItemsForPeriod(items as unknown as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n const lines = buildInvoiceLinesFromItems(billableItems)\n\n // Window bounds for categorized sums (topups / usage already in the ledger).\n const windowStart = period.periodStart\n const windowEnd = addDays(period.periodEnd, 1) // exclusive upper bound\n\n // Opening chains from the prior statement's closing \u2014 NOT a created_at sum.\n // Recurring debits are stamped at run time (after period_end), so a\n // `SUM(amount WHERE created_at < period_start)` would drop the prior period's\n // recurring charge and break the chain. Resolution order:\n // 1. previous cycle's closing (catch-up, in this run),\n // 2. the prior persisted statement's closing (month-over-month),\n // 3. the ledger balance before the first-ever statement window.\n const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing)\n const totalTopups = await sumByTypeInWindow(em, account, 'topup', windowStart, windowEnd)\n const totalUsage = absMoney(\n await sumByTypeInWindow(em, account, 'usage', windowStart, windowEnd),\n )\n // Manual adjustments / reversals that landed in the window \u2014 SIGNED (a credit\n // is +, a debit \u2212). Kept as a first-class term so the statement is\n // self-consistent and the opening\u2192closing chain never drifts.\n const totalAdjustments = addMoney(\n await sumByTypeInWindow(em, account, 'adjustment', windowStart, windowEnd),\n await sumByTypeInWindow(em, account, 'reversal', windowStart, windowEnd),\n )\n\n let totalRecurring = '0.0000'\n const breakdown: DebitBreakdownEntry[] = []\n const chargeTransactionIds: string[] = []\n // The balance just before / after the recurring batch \u2014 for crossing events.\n let recurringBalanceBefore: string | null = null\n let recurringBalanceAfter: string | null = null\n\n if (opts.mode === 'real') {\n const now = new Date()\n for (const line of lines) {\n const movement = await applyBalanceMovement(em, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n currencyCode: account.currencyCode,\n type: line.billingType === 'one_time' ? 'one_time' : 'recurring',\n amount: fromUnits(-toUnits(line.totalNetAmount)),\n description: line.description,\n billingItemId: line.billingItemId,\n sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,\n metadata: {\n bill_period_start: period.periodStart.toISOString().slice(0, 10),\n bill_period_end: periodEndISO,\n bill_run_id: billRun.id,\n },\n })\n if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore\n recurringBalanceAfter = movement.balanceAfter\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n const item = billableItems.find((it) => it.id === line.billingItemId)\n if (item) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n }\n await em.flush()\n } else {\n // test / dry: compute the would-be charges, persist nothing.\n for (const line of lines) {\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n }\n }\n\n // closing = opening + topups + adjustments \u2212 usage \u2212 recurring.\n const closingBalance = subMoney(\n subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),\n totalRecurring,\n )\n\n // The recurring batch can push the balance low / exhausted / over-limit \u2014\n // emit those crossings (real mode only; dry/test never move the balance).\n const crossingEvents =\n recurringBalanceBefore !== null && recurringBalanceAfter !== null\n ? buildBalanceCrossingEvents({\n scope: {\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n currencyCode: account.currencyCode,\n },\n balanceBefore: recurringBalanceBefore,\n balanceAfter: recurringBalanceAfter,\n threshold: opts.threshold,\n creditLimit: opts.creditLimit,\n nearLimitBuffer: opts.nearLimitBuffer,\n })\n : []\n\n let statementId: string | null = null\n if (opts.mode === 'real' && opts.statementEnabled) {\n const now = new Date()\n const statement = em.create(BillingStatement, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n billRunId: billRun.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n currencyCode: account.currencyCode,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n debitBreakdown: { items: breakdown },\n status: 'generated',\n createdAt: now,\n updatedAt: now,\n })\n em.persist(statement)\n await em.flush()\n statementId = statement.id\n\n // Backfill statement_id on the charge transactions for attribution.\n if (chargeTransactionIds.length > 0) {\n await em.nativeUpdate(\n BillingAccountTransaction,\n { id: { $in: chargeTransactionIds } } as never,\n { statementId } as never,\n )\n }\n }\n\n return {\n statementId,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n crossingEvents,\n }\n}\n\n/**\n * Resolve a period's opening balance by chaining from the prior statement's\n * closing (the spec's \"chains cleanly\"), with a ledger fallback for the very\n * first statement. See the call site for why a created_at sum alone is wrong.\n */\nasync function resolveOpeningBalance(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n previousClosing: string | null,\n): Promise<string> {\n if (previousClosing !== null) return previousClosing\n const prior = await em.findOne(\n BillingStatement,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n periodEnd: { $lt: period.periodStart },\n } as never,\n { orderBy: { periodEnd: 'DESC' } },\n )\n if (prior) return fromUnits(toUnits(prior.closingBalance))\n // First-ever statement: the ledger balance just before the period window\n // (only top-ups/usage exist before the first close, so created_at is safe).\n return sumAmountsBefore(em, account, period.periodStart)\n}\n\n/** Balance at `before` = SUM(amount) of transactions created strictly before it. */\nasync function sumAmountsBefore(\n em: EntityManager,\n account: BillingAccount,\n before: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, before],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n\n/** SUM(amount) for one transaction type within [from, to). */\nasync function sumByTypeInWindow(\n em: EntityManager,\n account: BillingAccount,\n type: string,\n from: Date,\n to: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND type = ?\n AND created_at >= ? AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, type, from, to],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/lib/tenantLock.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n/**\n * Per-tenant Postgres transaction-scoped advisory lock primitive.\n *\n * The Bill Run engine (Phase 2) uses this to guarantee at most one\n * concurrently-running run per tenant \u2014 the spec requires this so two\n * cron triggers or a \"schedule\" + \"manual\" overlap cannot produce\n * duplicate drafts.\n *\n * Why advisory locks (not row locks):\n * - `pg_try_advisory_xact_lock` is non-blocking \u2014 we get a clean \"busy\"\n * signal instead of waiting on a row.\n * - It's released automatically when the transaction ends (commit,\n * rollback, or process death) \u2014 so there is no lock-leak path even\n * on `kill -9`. The `BillRun` row's `status='running'` may be left\n * hanging, but the lock itself vanishes; the reaper worker\n * (workers/reap-stale-bill-runs.ts) handles the orphaned row.\n * - It works without a sentinel table \u2014 we hash a string key into the\n * bigint key space the advisory-lock API requires.\n *\n * Usage:\n *\n * ```ts\n * import { withTenantLock, TenantLockBusyError } from '../lib/tenantLock'\n *\n * try {\n * await withTenantLock(em, { tenantId, lockName: 'billing-run' }, async (tem) => {\n * // tem is the transactional EntityManager; lock is held here.\n * // Returns to caller after function resolves and tx commits.\n * })\n * } catch (err) {\n * if (err instanceof TenantLockBusyError) {\n * // HTTP 409 \u2014 another run is in progress.\n * }\n * throw err\n * }\n * ```\n *\n * Keep work inside the callback short. The advisory lock is released only\n * when the transaction ends; long-running work blocks other triggers for\n * the whole tenant.\n */\n\nexport class TenantLockBusyError extends Error {\n readonly tenantId: string\n readonly lockName: string\n\n constructor(tenantId: string, lockName: string) {\n super(`Tenant ${tenantId} already holds the billing lock \"${lockName}\"`)\n this.name = 'TenantLockBusyError'\n this.tenantId = tenantId\n this.lockName = lockName\n }\n}\n\nconst LOCK_KEY_PREFIX = 'dainamite-billing'\n\n/**\n * Build the deterministic string key fed into `hashtext()`. Exposed for\n * tests so they can assert lock-key independence across tenants /\n * lockNames without scraping SQL strings.\n */\nexport function buildLockKey(tenantId: string, lockName: string): string {\n return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`\n}\n\ntype AdvisoryLockRow = { acquired: boolean }\n\nexport async function withTenantLock<T>(\n em: EntityManager,\n params: { tenantId: string; lockName: string },\n fn: (tem: EntityManager) => Promise<T>,\n): Promise<T> {\n return em.transactional(async (tem) => {\n const key = buildLockKey(params.tenantId, params.lockName)\n const rows = (await tem.execute(\n 'SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired',\n [key],\n )) as unknown as AdvisoryLockRow[] | AdvisoryLockRow | undefined\n const first: AdvisoryLockRow | undefined = Array.isArray(rows) ? rows[0] : rows\n if (!first?.acquired) {\n throw new TenantLockBusyError(params.tenantId, params.lockName)\n }\n return fn(tem)\n })\n}\n"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n/**\n * Per-tenant Postgres transaction-scoped advisory lock primitive.\n *\n * The Bill Run engine (Phase 2) uses this to guarantee at most one\n * concurrently-running run per tenant \u2014 the spec requires this so two\n * cron triggers or a \"schedule\" + \"manual\" overlap cannot produce\n * duplicate drafts.\n *\n * Why advisory locks (not row locks):\n * - `pg_try_advisory_xact_lock` is non-blocking \u2014 we get a clean \"busy\"\n * signal instead of waiting on a row.\n * - It's released automatically when the transaction ends (commit,\n * rollback, or process death) \u2014 so there is no lock-leak path even\n * on `kill -9`. The `BillingRun` row's `status='running'` may be left\n * hanging, but the lock itself vanishes; the reaper worker\n * (workers/reap-stale-bill-runs.ts) handles the orphaned row.\n * - It works without a sentinel table \u2014 we hash a string key into the\n * bigint key space the advisory-lock API requires.\n *\n * Usage:\n *\n * ```ts\n * import { withTenantLock, TenantLockBusyError } from '../lib/tenantLock'\n *\n * try {\n * await withTenantLock(em, { tenantId, lockName: 'billing-run' }, async (tem) => {\n * // tem is the transactional EntityManager; lock is held here.\n * // Returns to caller after function resolves and tx commits.\n * })\n * } catch (err) {\n * if (err instanceof TenantLockBusyError) {\n * // HTTP 409 \u2014 another run is in progress.\n * }\n * throw err\n * }\n * ```\n *\n * Keep work inside the callback short. The advisory lock is released only\n * when the transaction ends; long-running work blocks other triggers for\n * the whole tenant.\n */\n\nexport class TenantLockBusyError extends Error {\n readonly tenantId: string\n readonly lockName: string\n\n constructor(tenantId: string, lockName: string) {\n super(`Tenant ${tenantId} already holds the billing lock \"${lockName}\"`)\n this.name = 'TenantLockBusyError'\n this.tenantId = tenantId\n this.lockName = lockName\n }\n}\n\nconst LOCK_KEY_PREFIX = 'dainamite-billing'\n\n/**\n * Build the deterministic string key fed into `hashtext()`. Exposed for\n * tests so they can assert lock-key independence across tenants /\n * lockNames without scraping SQL strings.\n */\nexport function buildLockKey(tenantId: string, lockName: string): string {\n return `${LOCK_KEY_PREFIX}:${tenantId}:${lockName}`\n}\n\ntype AdvisoryLockRow = { acquired: boolean }\n\nexport async function withTenantLock<T>(\n em: EntityManager,\n params: { tenantId: string; lockName: string },\n fn: (tem: EntityManager) => Promise<T>,\n): Promise<T> {\n return em.transactional(async (tem) => {\n const key = buildLockKey(params.tenantId, params.lockName)\n const rows = (await tem.execute(\n 'SELECT pg_try_advisory_xact_lock(hashtext(?)::bigint) AS acquired',\n [key],\n )) as unknown as AdvisoryLockRow[] | AdvisoryLockRow | undefined\n const first: AdvisoryLockRow | undefined = Array.isArray(rows) ? rows[0] : rows\n if (!first?.acquired) {\n throw new TenantLockBusyError(params.tenantId, params.lockName)\n }\n return fn(tem)\n })\n}\n"],
5
5
  "mappings": "AA4CO,MAAM,4BAA4B,MAAM;AAAA,EAI7C,YAAY,UAAkB,UAAkB;AAC9C,UAAM,UAAU,QAAQ,oCAAoC,QAAQ,GAAG;AACvE,SAAK,OAAO;AACZ,SAAK,WAAW;AAChB,SAAK,WAAW;AAAA,EAClB;AACF;AAEA,MAAM,kBAAkB;AAOjB,SAAS,aAAa,UAAkB,UAA0B;AACvE,SAAO,GAAG,eAAe,IAAI,QAAQ,IAAI,QAAQ;AACnD;AAIA,eAAsB,eACpB,IACA,QACA,IACY;AACZ,SAAO,GAAG,cAAc,OAAO,QAAQ;AACrC,UAAM,MAAM,aAAa,OAAO,UAAU,OAAO,QAAQ;AACzD,UAAM,OAAQ,MAAM,IAAI;AAAA,MACtB;AAAA,MACA,CAAC,GAAG;AAAA,IACN;AACA,UAAM,QAAqC,MAAM,QAAQ,IAAI,IAAI,KAAK,CAAC,IAAI;AAC3E,QAAI,CAAC,OAAO,UAAU;AACpB,YAAM,IAAI,oBAAoB,OAAO,UAAU,OAAO,QAAQ;AAAA,IAChE;AACA,WAAO,GAAG,GAAG;AAAA,EACf,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- import { BillRun } from "../data/entities.js";
1
+ import { BillingRun } from "../data/entities.js";
2
2
  const metadata = {
3
3
  queue: "billing-reap-stale-runs",
4
4
  id: "billing:reap-stale-bill-runs",
@@ -8,7 +8,7 @@ const STALE_THRESHOLD_MS = 60 * 60 * 1e3;
8
8
  async function handle(_job, ctx) {
9
9
  const em = ctx.resolve("em");
10
10
  const threshold = new Date(Date.now() - STALE_THRESHOLD_MS);
11
- const stale = await em.find(BillRun, {
11
+ const stale = await em.find(BillingRun, {
12
12
  status: "running",
13
13
  startedAt: { $lt: threshold }
14
14
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/workers/reap-stale-bill-runs.ts"],
4
- "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillRun } from '../data/entities'\n\nexport const metadata: WorkerMeta = {\n queue: 'billing-reap-stale-runs',\n id: 'billing:reap-stale-bill-runs',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Reaper for \"zombie\" Bill Runs left in `status='running'` after a crash.\n *\n * Background \u2014 the Bill Run engine acquires a per-tenant Postgres\n * transaction-scoped advisory lock (`pg_try_advisory_xact_lock`). When the\n * holding transaction ends (commit / rollback / process death) the lock is\n * released automatically \u2014 so there is no lock leak \u2014 but the `BillRun`\n * row's `status='running'` stays set forever, blocking restarts and\n * confusing the dashboard.\n *\n * Phase 0 ships this as the **registered worker shell** so the module's\n * worker auto-discovery includes it from day one. The active-advisory-lock\n * check (which is what distinguishes a real zombie from a healthy\n * long-running run) lands with the Bill Run engine itself in Phase 2.\n *\n * For now the heuristic is time-only: any `running` row older than 1 hour\n * is marked failed with an explanatory message. That is conservative \u2014\n * real runs almost never take an hour even for 500k-account tenants\n * (see spec Performance Considerations).\n */\nconst STALE_THRESHOLD_MS = 60 * 60 * 1000 // 1 hour\n\nexport default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {\n const em = ctx.resolve<EntityManager>('em')\n const threshold = new Date(Date.now() - STALE_THRESHOLD_MS)\n const stale = await em.find(BillRun, {\n status: 'running',\n startedAt: { $lt: threshold },\n } satisfies FilterQuery<BillRun>)\n if (stale.length === 0) return\n const now = new Date()\n for (const run of stale) {\n run.status = 'failed'\n run.finishedAt = now\n const summary = (run.summary ?? {}) as Record<string, unknown>\n summary.reaper_message =\n 'Stale running run reaped after crash; advisory lock released'\n summary.reaped_at = now.toISOString()\n run.summary = summary\n }\n await em.flush()\n}\n"],
5
- "mappings": "AAGA,SAAS,eAAe;AAEjB,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AA0BA,MAAM,qBAAqB,KAAK,KAAK;AAErC,eAAO,OAA8B,MAAiB,KAAoC;AACxF,QAAM,KAAK,IAAI,QAAuB,IAAI;AAC1C,QAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,kBAAkB;AAC1D,QAAM,QAAQ,MAAM,GAAG,KAAK,SAAS;AAAA,IACnC,QAAQ;AAAA,IACR,WAAW,EAAE,KAAK,UAAU;AAAA,EAC9B,CAAgC;AAChC,MAAI,MAAM,WAAW,EAAG;AACxB,QAAM,MAAM,oBAAI,KAAK;AACrB,aAAW,OAAO,OAAO;AACvB,QAAI,SAAS;AACb,QAAI,aAAa;AACjB,UAAM,UAAW,IAAI,WAAW,CAAC;AACjC,YAAQ,iBACN;AACF,YAAQ,YAAY,IAAI,YAAY;AACpC,QAAI,UAAU;AAAA,EAChB;AACA,QAAM,GAAG,MAAM;AACjB;",
4
+ "sourcesContent": ["import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingRun } from '../data/entities'\n\nexport const metadata: WorkerMeta = {\n queue: 'billing-reap-stale-runs',\n id: 'billing:reap-stale-bill-runs',\n concurrency: 1,\n}\n\ntype HandlerContext = JobContext & {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Reaper for \"zombie\" Bill Runs left in `status='running'` after a crash.\n *\n * Background \u2014 the Bill Run engine acquires a per-tenant Postgres\n * transaction-scoped advisory lock (`pg_try_advisory_xact_lock`). When the\n * holding transaction ends (commit / rollback / process death) the lock is\n * released automatically \u2014 so there is no lock leak \u2014 but the `BillingRun`\n * row's `status='running'` stays set forever, blocking restarts and\n * confusing the dashboard.\n *\n * Phase 0 ships this as the **registered worker shell** so the module's\n * worker auto-discovery includes it from day one. The active-advisory-lock\n * check (which is what distinguishes a real zombie from a healthy\n * long-running run) lands with the Bill Run engine itself in Phase 2.\n *\n * For now the heuristic is time-only: any `running` row older than 1 hour\n * is marked failed with an explanatory message. That is conservative \u2014\n * real runs almost never take an hour even for 500k-account tenants\n * (see spec Performance Considerations).\n */\nconst STALE_THRESHOLD_MS = 60 * 60 * 1000 // 1 hour\n\nexport default async function handle(_job: QueuedJob, ctx: HandlerContext): Promise<void> {\n const em = ctx.resolve<EntityManager>('em')\n const threshold = new Date(Date.now() - STALE_THRESHOLD_MS)\n const stale = await em.find(BillingRun, {\n status: 'running',\n startedAt: { $lt: threshold },\n } satisfies FilterQuery<BillingRun>)\n if (stale.length === 0) return\n const now = new Date()\n for (const run of stale) {\n run.status = 'failed'\n run.finishedAt = now\n const summary = (run.summary ?? {}) as Record<string, unknown>\n summary.reaper_message =\n 'Stale running run reaped after crash; advisory lock released'\n summary.reaped_at = now.toISOString()\n run.summary = summary\n }\n await em.flush()\n}\n"],
5
+ "mappings": "AAGA,SAAS,kBAAkB;AAEpB,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AACf;AA0BA,MAAM,qBAAqB,KAAK,KAAK;AAErC,eAAO,OAA8B,MAAiB,KAAoC;AACxF,QAAM,KAAK,IAAI,QAAuB,IAAI;AAC1C,QAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,kBAAkB;AAC1D,QAAM,QAAQ,MAAM,GAAG,KAAK,YAAY;AAAA,IACtC,QAAQ;AAAA,IACR,WAAW,EAAE,KAAK,UAAU;AAAA,EAC9B,CAAmC;AACnC,MAAI,MAAM,WAAW,EAAG;AACxB,QAAM,MAAM,oBAAI,KAAK;AACrB,aAAW,OAAO,OAAO;AACvB,QAAI,SAAS;AACb,QAAI,aAAa;AACjB,UAAM,UAAW,IAAI,WAAW,CAAC;AACjC,YAAQ,iBACN;AACF,YAAQ,YAAY,IAAI,YAAY;AACpC,QAAI,UAAU;AAAA,EAChB;AACA,QAAM,GAAG,MAAM;AACjB;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,23 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_account (BillingAccount, table billing_accounts).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const customer_id = "customer_id"
8
+ export const name = "name"
9
+ export const currency_code = "currency_code"
10
+ export const bill_cycle = "bill_cycle"
11
+ export const bill_cycle_anchor = "bill_cycle_anchor"
12
+ export const invoice_email = "invoice_email"
13
+ export const invoice_language = "invoice_language"
14
+ export const tax_id = "tax_id"
15
+ export const invoice_address = "invoice_address"
16
+ export const next_bill_date = "next_bill_date"
17
+ export const last_bill_date = "last_bill_date"
18
+ export const billing_mode = "billing_mode"
19
+ export const credit_limit = "credit_limit"
20
+ export const is_active = "is_active"
21
+ export const created_at = "created_at"
22
+ export const updated_at = "updated_at"
23
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,13 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_account_balance (BillingAccountBalance, table billing_account_balances).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const currency_code = "currency_code"
9
+ export const balance = "balance"
10
+ export const low_balance_threshold = "low_balance_threshold"
11
+ export const last_movement_at = "last_movement_at"
12
+ export const created_at = "created_at"
13
+ export const updated_at = "updated_at"
@@ -0,0 +1,21 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_account_transaction (BillingAccountTransaction, table billing_account_transactions).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const type = "type"
9
+ export const amount = "amount"
10
+ export const currency_code = "currency_code"
11
+ export const balance_after = "balance_after"
12
+ export const usage_id = "usage_id"
13
+ export const billing_item_id = "billing_item_id"
14
+ export const topup_id = "topup_id"
15
+ export const statement_id = "statement_id"
16
+ export const description = "description"
17
+ export const source_ref = "source_ref"
18
+ export const metadata = "metadata"
19
+ export const user_id = "user_id"
20
+ export const created_at = "created_at"
21
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,18 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_account_usage (BillingAccountUsage, table billing_account_usage).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const uom_code = "uom_code"
9
+ export const quantity = "quantity"
10
+ export const period_start = "period_start"
11
+ export const period_end = "period_end"
12
+ export const line_description = "line_description"
13
+ export const source_ref = "source_ref"
14
+ export const rated_in_bill_run_id = "rated_in_bill_run_id"
15
+ export const is_active = "is_active"
16
+ export const created_at = "created_at"
17
+ export const updated_at = "updated_at"
18
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,14 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_draft_invoice_edit (BillingDraftInvoiceEdit, table billing_draft_invoice_edits).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const invoice_id = "invoice_id"
8
+ export const invoice_line_id = "invoice_line_id"
9
+ export const user_id = "user_id"
10
+ export const action = "action"
11
+ export const before_json = "before_json"
12
+ export const after_json = "after_json"
13
+ export const created_at = "created_at"
14
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,22 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_item (BillingItem, table billing_items).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const type = "type"
9
+ export const bill_start_date = "bill_start_date"
10
+ export const bill_end_date = "bill_end_date"
11
+ export const description = "description"
12
+ export const rate_json = "rate_json"
13
+ export const uom_code = "uom_code"
14
+ export const subscription_id = "subscription_id"
15
+ export const subscription_item_id = "subscription_item_id"
16
+ export const source_ref = "source_ref"
17
+ export const currency_mismatch = "currency_mismatch"
18
+ export const billed_to_date = "billed_to_date"
19
+ export const is_active = "is_active"
20
+ export const created_at = "created_at"
21
+ export const updated_at = "updated_at"
22
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,21 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_run (BillingRun, table billing_runs).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const triggered_by = "triggered_by"
8
+ export const parent_run_id = "parent_run_id"
9
+ export const dry_run = "dry_run"
10
+ export const test_mode = "test_mode"
11
+ export const catch_up = "catch_up"
12
+ export const scoped_account_ids = "scoped_account_ids"
13
+ export const as_of_date = "as_of_date"
14
+ export const started_at = "started_at"
15
+ export const finished_at = "finished_at"
16
+ export const status = "status"
17
+ export const summary = "summary"
18
+ export const is_active = "is_active"
19
+ export const created_at = "created_at"
20
+ export const updated_at = "updated_at"
21
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,17 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_run_outcome (BillingRunOutcome, table billing_run_outcomes).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_run_id = "bill_run_id"
8
+ export const bill_account_id = "bill_account_id"
9
+ export const status = "status"
10
+ export const error_message = "error_message"
11
+ export const warnings = "warnings"
12
+ export const draft_invoice_id = "draft_invoice_id"
13
+ export const statement_id = "statement_id"
14
+ export const is_active = "is_active"
15
+ export const created_at = "created_at"
16
+ export const updated_at = "updated_at"
17
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,21 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_statement (BillingStatement, table billing_statements).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const bill_run_id = "bill_run_id"
9
+ export const period_start = "period_start"
10
+ export const period_end = "period_end"
11
+ export const currency_code = "currency_code"
12
+ export const opening_balance = "opening_balance"
13
+ export const total_topups = "total_topups"
14
+ export const total_usage = "total_usage"
15
+ export const total_recurring = "total_recurring"
16
+ export const total_adjustments = "total_adjustments"
17
+ export const closing_balance = "closing_balance"
18
+ export const debit_breakdown = "debit_breakdown"
19
+ export const status = "status"
20
+ export const created_at = "created_at"
21
+ export const updated_at = "updated_at"
@@ -0,0 +1,20 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Columns of billing:billing_topup (BillingTopup, table billing_topups).
3
+
4
+ export const id = "id"
5
+ export const organization_id = "organization_id"
6
+ export const tenant_id = "tenant_id"
7
+ export const bill_account_id = "bill_account_id"
8
+ export const status = "status"
9
+ export const amount = "amount"
10
+ export const currency_code = "currency_code"
11
+ export const provider_key = "provider_key"
12
+ export const payment_id = "payment_id"
13
+ export const gateway_transaction_id = "gateway_transaction_id"
14
+ export const transaction_id = "transaction_id"
15
+ export const receipt_invoice_id = "receipt_invoice_id"
16
+ export const source_ref = "source_ref"
17
+ export const metadata = "metadata"
18
+ export const created_at = "created_at"
19
+ export const updated_at = "updated_at"
20
+ export const deleted_at = "deleted_at"
@@ -0,0 +1,28 @@
1
+ // AUTO-GENERATED by @dainamite/billing scripts/generate-entity-registry.mjs — do not edit.
2
+ // Regenerate with `yarn workspace @dainamite/billing build`.
3
+ //
4
+ // Read by a consuming app's `mercato generate` (@open-mercato/cli entity-ids
5
+ // generator), which takes the KEYS of `E.billing` and registers each id as
6
+ // `billing:<key>`. Source of truth: src/modules/billing/data/entityIds.ts.
7
+
8
+ export const M = {
9
+ billing: 'billing',
10
+ } as const
11
+
12
+ export const E = {
13
+ billing: {
14
+ billing_account: 'billing:billing_account',
15
+ billing_item: 'billing:billing_item',
16
+ billing_account_usage: 'billing:billing_account_usage',
17
+ billing_run: 'billing:billing_run',
18
+ billing_run_outcome: 'billing:billing_run_outcome',
19
+ billing_draft_invoice_edit: 'billing:billing_draft_invoice_edit',
20
+ billing_account_balance: 'billing:billing_account_balance',
21
+ billing_account_transaction: 'billing:billing_account_transaction',
22
+ billing_topup: 'billing:billing_topup',
23
+ billing_statement: 'billing:billing_statement',
24
+ },
25
+ } as const
26
+
27
+ export type KnownModuleId = keyof typeof M
28
+ export type KnownEntities = typeof E
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@dainamite/billing",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Dainamite Billing — lightweight recurring-billing engine for Open Mercato (Bill Runs, one-time / recurring / usage items, draft invoices in core/sales with human approval).",
5
5
  "type": "module",
6
6
  "main": "./dist/modules/billing/index.js",
7
7
  "files": [
8
8
  "dist",
9
+ "generated",
9
10
  "src",
10
11
  "README.md",
11
12
  "CHANGELOG.md"
@@ -117,15 +118,15 @@
117
118
  }
118
119
  },
119
120
  "peerDependencies": {
120
- "@mikro-orm/core": "^7.0.0",
121
- "@mikro-orm/decorators": "^7.0.0",
122
- "@mikro-orm/postgresql": "^7.0.0",
123
- "@open-mercato/core": "^0.6.0",
124
- "@open-mercato/events": "^0.6.0",
125
- "@open-mercato/queue": "^0.6.0",
126
- "@open-mercato/shared": "^0.6.0",
127
- "@open-mercato/ui": "^0.6.0",
128
- "@tanstack/react-table": "^8.0.0 || ^9.0.0",
121
+ "@mikro-orm/core": "^7.1.14",
122
+ "@mikro-orm/decorators": "^7.1.14",
123
+ "@mikro-orm/postgresql": "^7.1.14",
124
+ "@open-mercato/core": "^0.9.0",
125
+ "@open-mercato/events": "^0.9.0",
126
+ "@open-mercato/queue": "^0.9.0",
127
+ "@open-mercato/shared": "^0.9.0",
128
+ "@open-mercato/ui": "^0.9.0",
129
+ "@tanstack/react-table": "^9.0.0",
129
130
  "awilix": "^12.0.0",
130
131
  "lucide-react": "^0.400.0",
131
132
  "next": "^16.0.0",
@@ -23,7 +23,7 @@ import {
23
23
  wipeTestInvoicesCommand,
24
24
  } from '../../commands/invoices'
25
25
  import {
26
- DraftInvoiceEdit,
26
+ BillingDraftInvoiceEdit,
27
27
  } from '../../data/entities'
28
28
 
29
29
  type FakeInvoice = {
@@ -106,7 +106,7 @@ function makeLine(invoice: FakeInvoice, overrides: Partial<FakeLine> = {}): Fake
106
106
  }
107
107
 
108
108
  type Env = {
109
- ctx: { container: { resolve: jest.MockedFunction<(name: string) => unknown> }; auth: { sub?: string } | null }
109
+ ctx: { container: { resolve: jest.MockedFunction<(name: string) => unknown> }; auth: { sub?: string; tenantId?: string; orgId?: string } | null }
110
110
  em: {
111
111
  findOne: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown>>
112
112
  find: jest.MockedFunction<(entity: unknown, where: unknown) => Promise<unknown[]>>
@@ -131,7 +131,7 @@ function createEnv(
131
131
  statusEntryPosted?: string | null
132
132
  initialLines?: FakeLine[]
133
133
  invoiceFinalLines?: FakeLine[]
134
- auth?: { sub?: string } | null
134
+ auth?: { sub?: string; tenantId?: string; orgId?: string } | null
135
135
  testInvoiceRows?: Array<{ id: string }>
136
136
  } = {},
137
137
  ): Env {
@@ -167,7 +167,7 @@ function createEnv(
167
167
  }) as never,
168
168
  create: jest.fn((entity: unknown, data: Record<string, unknown>) => {
169
169
  const ctor = (entity as { name?: string }).name ?? ''
170
- if (ctor === 'DraftInvoiceEdit') {
170
+ if (ctor === 'BillingDraftInvoiceEdit') {
171
171
  auditIdCounter += 1
172
172
  return { ...data, id: `audit-${auditIdCounter}` }
173
173
  }
@@ -199,7 +199,7 @@ function createEnv(
199
199
  return {
200
200
  ctx: {
201
201
  container,
202
- auth: options.auth ?? { sub: '99999999-9999-4999-8999-999999999999' },
202
+ auth: options.auth ?? { sub: '99999999-9999-4999-8999-999999999999', tenantId: TENANT, orgId: ORG },
203
203
  },
204
204
  em,
205
205
  persistedEntities,
@@ -318,7 +318,7 @@ describe('editDraftLineCommand', () => {
318
318
  expect(line.totalNetAmount).toBe('999.9900')
319
319
  })
320
320
 
321
- it('persists a DraftInvoiceEdit audit row with before+after snapshots', async () => {
321
+ it('persists a BillingDraftInvoiceEdit audit row with before+after snapshots', async () => {
322
322
  const invoice = makeInvoice({ status: 'draft' })
323
323
  const line = makeLine(invoice, {
324
324
  description: 'Old',
@@ -458,7 +458,7 @@ describe('wipeTestInvoicesCommand', () => {
458
458
  })
459
459
  })
460
460
 
461
- // Suppress unused-import warning — DraftInvoiceEdit is needed by the
461
+ // Suppress unused-import warning — BillingDraftInvoiceEdit is needed by the
462
462
  // mock's discriminator path but not directly referenced by name in
463
463
  // individual assertions.
464
- void DraftInvoiceEdit
464
+ void BillingDraftInvoiceEdit
@@ -23,7 +23,7 @@ function makeCtx(em: MockEm, gateway: unknown) {
23
23
  flushOrmEntityChanges: jest.fn(async () => undefined),
24
24
  }
25
25
  return {
26
- auth: { sub: '99999999-9999-4999-8999-999999999999' },
26
+ auth: { sub: '99999999-9999-4999-8999-999999999999', tenantId: TENANT, orgId: ORG },
27
27
  container: {
28
28
  resolve: jest.fn((name: string) => {
29
29
  if (name === 'em') return em
@@ -17,13 +17,15 @@ function prepaidAccount(overrides: Record<string, unknown> = {}) {
17
17
  }
18
18
  }
19
19
 
20
- function makeCtx(em: MockEm) {
20
+ type CtxAuth = { sub?: string; tenantId?: string; orgId?: string } | null
21
+
22
+ function makeCtx(em: MockEm, auth: CtxAuth = { sub: '99999999-9999-4999-8999-999999999999', tenantId: TENANT, orgId: ORG }) {
21
23
  const dataEngine = {
22
24
  markOrmEntityChange: jest.fn(),
23
25
  flushOrmEntityChanges: jest.fn(async () => undefined),
24
26
  }
25
27
  return {
26
- auth: { sub: '99999999-9999-4999-8999-999999999999' },
28
+ auth,
27
29
  container: {
28
30
  resolve: jest.fn((name: string) => {
29
31
  if (name === 'em') return em
@@ -54,6 +56,27 @@ describe('adjustTransactionCommand', () => {
54
56
  expect(em.transactions[0].userId).toBe('99999999-9999-4999-8999-999999999999')
55
57
  })
56
58
 
59
+ // The CPQ↔billing connector runs as a system identity, not a user: it calls
60
+ // the command bus with `auth: null` and carries tenant/organization in the
61
+ // payload instead. Open Mercato 0.6.7 hardened ensureTenantScope from
62
+ // fail-open to fail-closed, but kept `auth: null` a no-op — this test pins
63
+ // that, because the connector has no other guarantee than prose. If a future
64
+ // release closes this branch too, the connector breaks and this fails first.
65
+ it('accepts a system-identity call (auth: null) without a scope error', async () => {
66
+ const em = createPrepaidMockEm({
67
+ account: prepaidAccount(),
68
+ balance: { tenantId: TENANT, billAccountId: ACCT, balance: '100.0000' },
69
+ })
70
+ const ctx = makeCtx(em, null)
71
+ const result = await adjustTransactionCommand.execute(
72
+ { tenantId: TENANT, organizationId: ORG, billAccountId: ACCT, amount: -10, description: 'Connector debit' } as never,
73
+ ctx as never,
74
+ )
75
+ expect(result.balance).toBe('90.0000')
76
+ expect(em.transactions).toHaveLength(1)
77
+ expect(em.transactions[0].userId).toBeFalsy()
78
+ })
79
+
57
80
  it('credits the balance', async () => {
58
81
  const em = createPrepaidMockEm({
59
82
  account: prepaidAccount(),