@dainamite/billing 0.17.0 → 0.18.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 (205) hide show
  1. package/CHANGELOG.md +81 -19
  2. package/dist/modules/billing/acl.js +9 -1
  3. package/dist/modules/billing/acl.js.map +2 -2
  4. package/dist/modules/billing/api/accounts/[id]/balance/route.js +62 -0
  5. package/dist/modules/billing/api/accounts/[id]/balance/route.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js +56 -0
  7. package/dist/modules/billing/api/accounts/[id]/credit-status/route.js.map +7 -0
  8. package/dist/modules/billing/api/accounts/[id]/topups/route.js +36 -0
  9. package/dist/modules/billing/api/accounts/[id]/topups/route.js.map +7 -0
  10. package/dist/modules/billing/api/accounts/route.js +7 -0
  11. package/dist/modules/billing/api/accounts/route.js.map +2 -2
  12. package/dist/modules/billing/api/export/account/[id]/route.js +48 -3
  13. package/dist/modules/billing/api/export/account/[id]/route.js.map +2 -2
  14. package/dist/modules/billing/api/statements/generate/route.js +68 -0
  15. package/dist/modules/billing/api/statements/generate/route.js.map +7 -0
  16. package/dist/modules/billing/api/statements/route.js +110 -0
  17. package/dist/modules/billing/api/statements/route.js.map +7 -0
  18. package/dist/modules/billing/api/topups/route.js +139 -0
  19. package/dist/modules/billing/api/topups/route.js.map +7 -0
  20. package/dist/modules/billing/api/transactions/adjust/route.js +71 -0
  21. package/dist/modules/billing/api/transactions/adjust/route.js.map +7 -0
  22. package/dist/modules/billing/api/transactions/route.js +109 -0
  23. package/dist/modules/billing/api/transactions/route.js.map +7 -0
  24. package/dist/modules/billing/api/usage/route.js +30 -6
  25. package/dist/modules/billing/api/usage/route.js.map +2 -2
  26. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +16 -1
  27. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +2 -2
  28. package/dist/modules/billing/backend/billing/accounts/create/page.js +8 -1
  29. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +2 -2
  30. package/dist/modules/billing/backend/billing/accounts/page.js +26 -1
  31. package/dist/modules/billing/backend/billing/accounts/page.js.map +2 -2
  32. package/dist/modules/billing/backend/billing/statements/[id]/page.js +129 -0
  33. package/dist/modules/billing/backend/billing/statements/[id]/page.js.map +7 -0
  34. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js +21 -0
  35. package/dist/modules/billing/backend/billing/statements/[id]/page.meta.js.map +7 -0
  36. package/dist/modules/billing/backend/billing/statements/page.js +152 -0
  37. package/dist/modules/billing/backend/billing/statements/page.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/statements/page.meta.js +18 -0
  39. package/dist/modules/billing/backend/billing/statements/page.meta.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/topups/[id]/page.js +90 -0
  41. package/dist/modules/billing/backend/billing/topups/[id]/page.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js +21 -0
  43. package/dist/modules/billing/backend/billing/topups/[id]/page.meta.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/topups/page.js +147 -0
  45. package/dist/modules/billing/backend/billing/topups/page.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/topups/page.meta.js +18 -0
  47. package/dist/modules/billing/backend/billing/topups/page.meta.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/transactions/[id]/page.js +114 -0
  49. package/dist/modules/billing/backend/billing/transactions/[id]/page.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js +21 -0
  51. package/dist/modules/billing/backend/billing/transactions/[id]/page.meta.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/transactions/page.js +149 -0
  53. package/dist/modules/billing/backend/billing/transactions/page.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/transactions/page.meta.js +18 -0
  55. package/dist/modules/billing/backend/billing/transactions/page.meta.js.map +7 -0
  56. package/dist/modules/billing/commands/accounts.js +26 -1
  57. package/dist/modules/billing/commands/accounts.js.map +2 -2
  58. package/dist/modules/billing/commands/index.js +3 -0
  59. package/dist/modules/billing/commands/index.js.map +2 -2
  60. package/dist/modules/billing/commands/invoices.js +21 -1
  61. package/dist/modules/billing/commands/invoices.js.map +2 -2
  62. package/dist/modules/billing/commands/statements.js +74 -0
  63. package/dist/modules/billing/commands/statements.js.map +7 -0
  64. package/dist/modules/billing/commands/topups.js +139 -0
  65. package/dist/modules/billing/commands/topups.js.map +7 -0
  66. package/dist/modules/billing/commands/transactions.js +119 -0
  67. package/dist/modules/billing/commands/transactions.js.map +7 -0
  68. package/dist/modules/billing/commands/usage.js +72 -0
  69. package/dist/modules/billing/commands/usage.js.map +2 -2
  70. package/dist/modules/billing/components/AccountForm.js +53 -1
  71. package/dist/modules/billing/components/AccountForm.js.map +2 -2
  72. package/dist/modules/billing/components/PrepaidPanel.js +314 -0
  73. package/dist/modules/billing/components/PrepaidPanel.js.map +7 -0
  74. package/dist/modules/billing/components/accountNames.js +20 -0
  75. package/dist/modules/billing/components/accountNames.js.map +7 -0
  76. package/dist/modules/billing/data/entities.js +290 -0
  77. package/dist/modules/billing/data/entities.js.map +2 -2
  78. package/dist/modules/billing/data/entityIds.js +6 -1
  79. package/dist/modules/billing/data/entityIds.js.map +2 -2
  80. package/dist/modules/billing/data/validators.js +107 -2
  81. package/dist/modules/billing/data/validators.js.map +2 -2
  82. package/dist/modules/billing/events.js +52 -0
  83. package/dist/modules/billing/events.js.map +2 -2
  84. package/dist/modules/billing/i18n/en.json +112 -4
  85. package/dist/modules/billing/i18n/pl.json +112 -4
  86. package/dist/modules/billing/index.js +1 -1
  87. package/dist/modules/billing/index.js.map +2 -2
  88. package/dist/modules/billing/lib/accountStatus.js +106 -0
  89. package/dist/modules/billing/lib/accountStatus.js.map +7 -0
  90. package/dist/modules/billing/lib/balanceEvents.js +48 -0
  91. package/dist/modules/billing/lib/balanceEvents.js.map +7 -0
  92. package/dist/modules/billing/lib/balanceLedger.js +136 -0
  93. package/dist/modules/billing/lib/balanceLedger.js.map +7 -0
  94. package/dist/modules/billing/lib/balanceStatus.js +29 -0
  95. package/dist/modules/billing/lib/balanceStatus.js.map +7 -0
  96. package/dist/modules/billing/lib/billRunEngine.js +40 -2
  97. package/dist/modules/billing/lib/billRunEngine.js.map +2 -2
  98. package/dist/modules/billing/lib/creditStatus.js +65 -0
  99. package/dist/modules/billing/lib/creditStatus.js.map +7 -0
  100. package/dist/modules/billing/lib/money.js +47 -0
  101. package/dist/modules/billing/lib/money.js.map +7 -0
  102. package/dist/modules/billing/lib/prepaidConfig.js +45 -0
  103. package/dist/modules/billing/lib/prepaidConfig.js.map +7 -0
  104. package/dist/modules/billing/lib/prepaidConsume.js +214 -0
  105. package/dist/modules/billing/lib/prepaidConsume.js.map +7 -0
  106. package/dist/modules/billing/lib/prepaidPeriodClose.js +285 -0
  107. package/dist/modules/billing/lib/prepaidPeriodClose.js.map +7 -0
  108. package/dist/modules/billing/lib/topupCapture.js +62 -0
  109. package/dist/modules/billing/lib/topupCapture.js.map +7 -0
  110. package/dist/modules/billing/lib/topupReceipt.js +97 -0
  111. package/dist/modules/billing/lib/topupReceipt.js.map +7 -0
  112. package/dist/modules/billing/lib/topupTransition.js +19 -0
  113. package/dist/modules/billing/lib/topupTransition.js.map +7 -0
  114. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js +110 -0
  115. package/dist/modules/billing/migrations/Migration20260619000000_billing_prepaid.js.map +7 -0
  116. package/dist/modules/billing/setup.js +32 -1
  117. package/dist/modules/billing/setup.js.map +2 -2
  118. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js +26 -0
  119. package/dist/modules/billing/subscribers/prepaid-topup-cancelled.js.map +7 -0
  120. package/dist/modules/billing/subscribers/prepaid-topup-captured.js +87 -0
  121. package/dist/modules/billing/subscribers/prepaid-topup-captured.js.map +7 -0
  122. package/dist/modules/billing/subscribers/prepaid-topup-expired.js +26 -0
  123. package/dist/modules/billing/subscribers/prepaid-topup-expired.js.map +7 -0
  124. package/dist/modules/billing/subscribers/prepaid-topup-failed.js +26 -0
  125. package/dist/modules/billing/subscribers/prepaid-topup-failed.js.map +7 -0
  126. package/dist/modules/billing/workers/reconcile-prepaid-balances.js +50 -0
  127. package/dist/modules/billing/workers/reconcile-prepaid-balances.js.map +7 -0
  128. package/package.json +1 -1
  129. package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
  130. package/src/modules/billing/__tests__/commands/transactions.test.ts +84 -0
  131. package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
  132. package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
  133. package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
  134. package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
  135. package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
  136. package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
  137. package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
  138. package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
  139. package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
  140. package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
  141. package/src/modules/billing/__tests__/setup.test.ts +28 -2
  142. package/src/modules/billing/acl.ts +8 -0
  143. package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
  144. package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
  145. package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
  146. package/src/modules/billing/api/accounts/route.ts +7 -0
  147. package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
  148. package/src/modules/billing/api/statements/generate/route.ts +78 -0
  149. package/src/modules/billing/api/statements/route.ts +117 -0
  150. package/src/modules/billing/api/topups/route.ts +154 -0
  151. package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
  152. package/src/modules/billing/api/transactions/route.ts +116 -0
  153. package/src/modules/billing/api/usage/route.ts +36 -5
  154. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
  155. package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
  156. package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
  157. package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
  158. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
  159. package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
  160. package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
  161. package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
  162. package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
  163. package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
  164. package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
  165. package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
  166. package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
  167. package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
  168. package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
  169. package/src/modules/billing/commands/accounts.ts +42 -1
  170. package/src/modules/billing/commands/index.ts +3 -0
  171. package/src/modules/billing/commands/invoices.ts +26 -2
  172. package/src/modules/billing/commands/statements.ts +101 -0
  173. package/src/modules/billing/commands/topups.ts +181 -0
  174. package/src/modules/billing/commands/transactions.ts +152 -0
  175. package/src/modules/billing/commands/usage.ts +114 -4
  176. package/src/modules/billing/components/AccountForm.tsx +66 -0
  177. package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
  178. package/src/modules/billing/components/accountNames.ts +23 -0
  179. package/src/modules/billing/data/entities.ts +383 -0
  180. package/src/modules/billing/data/entityIds.ts +5 -0
  181. package/src/modules/billing/data/validators.ts +144 -0
  182. package/src/modules/billing/events.ts +52 -0
  183. package/src/modules/billing/i18n/en.json +112 -4
  184. package/src/modules/billing/i18n/pl.json +112 -4
  185. package/src/modules/billing/index.ts +1 -1
  186. package/src/modules/billing/lib/accountStatus.ts +155 -0
  187. package/src/modules/billing/lib/balanceEvents.ts +74 -0
  188. package/src/modules/billing/lib/balanceLedger.ts +258 -0
  189. package/src/modules/billing/lib/balanceStatus.ts +78 -0
  190. package/src/modules/billing/lib/billRunEngine.ts +66 -5
  191. package/src/modules/billing/lib/creditStatus.ts +153 -0
  192. package/src/modules/billing/lib/money.ts +73 -0
  193. package/src/modules/billing/lib/prepaidConfig.ts +58 -0
  194. package/src/modules/billing/lib/prepaidConsume.ts +299 -0
  195. package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
  196. package/src/modules/billing/lib/topupCapture.ts +117 -0
  197. package/src/modules/billing/lib/topupReceipt.ts +153 -0
  198. package/src/modules/billing/lib/topupTransition.ts +31 -0
  199. package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
  200. package/src/modules/billing/setup.ts +45 -0
  201. package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
  202. package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
  203. package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
  204. package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
  205. package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Exact decimal arithmetic for the prepaid balance ledger (SPEC-002).
3
+ *
4
+ * All billing money columns are `numeric(18,4)` — four decimal places. Doing
5
+ * the running-balance / reconciliation math in JS `number` would accumulate
6
+ * binary floating-point error (`0.1 + 0.2 !== 0.3`), and the reconciliation
7
+ * invariant `balance == SUM(amount) == latest.balance_after` would drift.
8
+ *
9
+ * So we work in **integer ten-thousandths** ("units"): the smallest
10
+ * representable step of a `numeric(18,4)` column. `12.3400` ⇄ `123400` units.
11
+ * Addition/subtraction of integers is exact; we only ever format back to a
12
+ * 4dp string at the persistence boundary.
13
+ *
14
+ * `numeric(18,4)` holds values up to ~10^14, i.e. ~10^18 units — well within
15
+ * JS `Number.MAX_SAFE_INTEGER` (~9.007×10^15) for any realistic balance
16
+ * (max safe ~±900,000,000,000 currency units). Magnitudes beyond that would
17
+ * already overflow the column itself; we guard the parse defensively.
18
+ */
19
+
20
+ const SCALE = 10_000
21
+
22
+ /** Parse a numeric(18,4) string (or number) into integer ten-thousandths. */
23
+ export function toUnits(value: string | number | null | undefined): number {
24
+ if (value === null || value === undefined) return 0
25
+ const n = typeof value === 'number' ? value : Number.parseFloat(value)
26
+ if (!Number.isFinite(n)) return 0
27
+ // Round to the nearest ten-thousandth — half-up, EPSILON-nudged to survive
28
+ // representations like 12.33999999998.
29
+ return Math.round((n + Number.EPSILON * Math.sign(n)) * SCALE)
30
+ }
31
+
32
+ /** Format integer ten-thousandths back to a numeric(18,4) string. */
33
+ export function fromUnits(units: number): string {
34
+ const sign = units < 0 ? '-' : ''
35
+ const abs = Math.abs(Math.trunc(units))
36
+ const whole = Math.trunc(abs / SCALE)
37
+ const frac = abs % SCALE
38
+ return `${sign}${whole}.${frac.toString().padStart(4, '0')}`
39
+ }
40
+
41
+ /** Add two numeric(18,4) values exactly, returning a 4dp string. */
42
+ export function addMoney(a: string | number, b: string | number): string {
43
+ return fromUnits(toUnits(a) + toUnits(b))
44
+ }
45
+
46
+ /** Subtract `b` from `a` exactly, returning a 4dp string. */
47
+ export function subMoney(a: string | number, b: string | number): string {
48
+ return fromUnits(toUnits(a) - toUnits(b))
49
+ }
50
+
51
+ /** Negate a numeric(18,4) value, returning a 4dp string. */
52
+ export function negateMoney(a: string | number): string {
53
+ return fromUnits(-toUnits(a))
54
+ }
55
+
56
+ /** Absolute value of a numeric(18,4), returning a 4dp string. */
57
+ export function absMoney(a: string | number): string {
58
+ return fromUnits(Math.abs(toUnits(a)))
59
+ }
60
+
61
+ /** Compare two numeric(18,4) values. Returns -1 / 0 / 1. */
62
+ export function compareMoney(a: string | number, b: string | number): -1 | 0 | 1 {
63
+ const ua = toUnits(a)
64
+ const ub = toUnits(b)
65
+ if (ua < ub) return -1
66
+ if (ua > ub) return 1
67
+ return 0
68
+ }
69
+
70
+ /** Normalize any numeric(18,4) input to a canonical 4dp string. */
71
+ export function formatMoney(value: string | number): string {
72
+ return fromUnits(toUnits(value))
73
+ }
@@ -0,0 +1,58 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { ModuleConfig } from '@open-mercato/core/modules/configs/data/entities'
3
+
4
+ /**
5
+ * Typed readers for the prepaid billing module configs (SPEC-002), seeded by
6
+ * `setup.ts → seedBillingConfigs`. Stored as raw JSON scalars in
7
+ * `ModuleConfig.valueJson`. Each reader falls back to the same default the
8
+ * seed uses, so a missing row (older tenant) behaves identically.
9
+ */
10
+
11
+ async function readConfig(em: EntityManager, name: string): Promise<unknown> {
12
+ const row = await em.findOne(ModuleConfig, { moduleId: 'billing', name })
13
+ if (!row) return undefined
14
+ return row.valueJson as unknown
15
+ }
16
+
17
+ function asNumberString(value: unknown, fallback: string): string {
18
+ if (value === null || value === undefined) return fallback
19
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
20
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
21
+ return value.trim()
22
+ }
23
+ return fallback
24
+ }
25
+
26
+ /** `prepaid.low_balance_threshold_default` — numeric string (default '0'). */
27
+ export async function getLowBalanceThresholdDefault(em: EntityManager): Promise<string> {
28
+ return asNumberString(await readConfig(em, 'prepaid.low_balance_threshold_default'), '0')
29
+ }
30
+
31
+ /** `credit.near_limit_buffer_default` — numeric string (default '0'). */
32
+ export async function getNearLimitBufferDefault(em: EntityManager): Promise<string> {
33
+ return asNumberString(await readConfig(em, 'credit.near_limit_buffer_default'), '0')
34
+ }
35
+
36
+ /** `prepaid.topup_provider` — gateway provider key (default 'stripe'). */
37
+ export async function getTopupProvider(em: EntityManager): Promise<string> {
38
+ const value = await readConfig(em, 'prepaid.topup_provider')
39
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'stripe'
40
+ }
41
+
42
+ /** `prepaid.statement_enabled` — master switch (default true). */
43
+ export async function getStatementEnabled(em: EntityManager): Promise<boolean> {
44
+ const value = await readConfig(em, 'prepaid.statement_enabled')
45
+ if (value === undefined || value === null) return true
46
+ if (typeof value === 'boolean') return value
47
+ // A stored string "false" must read as false (Boolean("false") === true).
48
+ if (typeof value === 'string') return value.trim().toLowerCase() !== 'false'
49
+ return Boolean(value)
50
+ }
51
+
52
+ /** `prepaid.topup_tax_rate` — VAT percent on top-up receipts (default 0). */
53
+ export async function getTopupTaxRate(em: EntityManager): Promise<number> {
54
+ const value = await readConfig(em, 'prepaid.topup_tax_rate')
55
+ if (typeof value === 'number' && Number.isFinite(value)) return value
56
+ if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)
57
+ return 0
58
+ }
@@ -0,0 +1,299 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ BillingAccount,
4
+ BillingAccountBalance,
5
+ BillingAccountTransaction,
6
+ BillingAccountUsage,
7
+ BillingItem,
8
+ } from '../data/entities'
9
+ import type { BalanceStatus, CreditStatus } from '../data/validators'
10
+ import {
11
+ applyBalanceMovement,
12
+ PREPAID_USAGE_SENTINEL_RUN_ID,
13
+ } from './balanceLedger'
14
+ import {
15
+ computeBalanceStatus,
16
+ resolveLowBalanceThreshold,
17
+ } from './balanceStatus'
18
+ import {
19
+ computeCreditStatus,
20
+ computeAvailableCredit,
21
+ prepaidCreditUsed,
22
+ } from './creditStatus'
23
+ import { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'
24
+ import { absMoney, negateMoney } from './money'
25
+ import {
26
+ getLowBalanceThresholdDefault,
27
+ getNearLimitBufferDefault,
28
+ } from './prepaidConfig'
29
+ import { rateUsageForItem, type TierBreakdownEntry } from './usageRater'
30
+
31
+ /**
32
+ * Real-time prepaid consume (SPEC-002 P1).
33
+ *
34
+ * For a prepaid account, a usage upload is rated in real time, debited from
35
+ * the balance, and the remaining balance is returned — *"I uploaded usage and
36
+ * got my remaining balance back."* Usage is NEVER rejected: a missing price or
37
+ * an exhausted balance still records the usage; we report status and let the
38
+ * client throttle itself.
39
+ *
40
+ * The caller MUST run this inside an `em.transactional(...)` so the usage
41
+ * insert, the atomic balance debit, and the transaction insert commit (or roll
42
+ * back) together. Domain events are returned (not emitted) so the caller can
43
+ * fire them AFTER the DB commit.
44
+ */
45
+
46
+ export type PrepaidConsumeInput = {
47
+ organizationId: string
48
+ tenantId: string
49
+ billAccountId: string
50
+ uomCode: string
51
+ quantity: number
52
+ periodStart: Date
53
+ periodEnd: Date
54
+ lineDescription?: string | null
55
+ sourceRef?: string | null
56
+ }
57
+
58
+ export type PrepaidConsumeResult = {
59
+ id: string
60
+ deduplicated: boolean
61
+ ratedAmount: string
62
+ currencyCode: string
63
+ balance: string
64
+ balanceStatus: BalanceStatus
65
+ creditStatus: CreditStatus
66
+ tierBreakdown: TierBreakdownEntry[]
67
+ warning: string | null
68
+ }
69
+
70
+ export type PrepaidConsumeOutcome = {
71
+ result: PrepaidConsumeResult
72
+ events: DomainEvent[]
73
+ }
74
+
75
+ function breakdownFromMetadata(meta: Record<string, unknown> | null | undefined): TierBreakdownEntry[] {
76
+ const b = meta?.usage_tier_breakdown
77
+ return Array.isArray(b) ? (b as TierBreakdownEntry[]) : []
78
+ }
79
+
80
+ /**
81
+ * Resolve the active `type=usage` Billing Item priced for a uom (exact match,
82
+ * no conversion — same rule as the postpaid runner). Latest active item wins.
83
+ */
84
+ async function findUsageItem(
85
+ em: EntityManager,
86
+ input: PrepaidConsumeInput,
87
+ ): Promise<BillingItem | null> {
88
+ return em.findOne(
89
+ BillingItem,
90
+ {
91
+ tenantId: input.tenantId,
92
+ organizationId: input.organizationId,
93
+ billAccountId: input.billAccountId,
94
+ type: 'usage',
95
+ uomCode: input.uomCode,
96
+ isActive: true,
97
+ deletedAt: null,
98
+ } as never,
99
+ { orderBy: { createdAt: 'DESC' } },
100
+ )
101
+ }
102
+
103
+ export async function consumePrepaidUsage(
104
+ em: EntityManager,
105
+ account: BillingAccount,
106
+ input: PrepaidConsumeInput,
107
+ ): Promise<PrepaidConsumeOutcome> {
108
+ const currencyCode = account.currencyCode
109
+ const threshold = resolveLowBalanceThreshold(
110
+ await readAccountThreshold(em, input),
111
+ await getLowBalanceThresholdDefault(em),
112
+ )
113
+ const nearLimitBuffer = await getNearLimitBufferDefault(em)
114
+ const creditLimit = account.creditLimit ?? '0'
115
+
116
+ // ─── Idempotency: a duplicate sourceRef never double-debits ────
117
+ if (input.sourceRef) {
118
+ const existing = await em.findOne(BillingAccountUsage, {
119
+ tenantId: input.tenantId,
120
+ billAccountId: input.billAccountId,
121
+ sourceRef: input.sourceRef,
122
+ deletedAt: null,
123
+ })
124
+ if (existing) {
125
+ const txn = await em.findOne(BillingAccountTransaction, {
126
+ tenantId: input.tenantId,
127
+ billAccountId: input.billAccountId,
128
+ usageId: existing.id,
129
+ deletedAt: null,
130
+ })
131
+ const balance = txn?.balanceAfter ?? (await readCurrentBalance(em, input))
132
+ const ratedAmount = txn ? absMoney(txn.amount) : '0.0000'
133
+ const warning = (txn?.metadata?.warning as string | undefined) ?? null
134
+ return {
135
+ result: {
136
+ id: existing.id,
137
+ deduplicated: true,
138
+ ratedAmount,
139
+ currencyCode,
140
+ balance,
141
+ balanceStatus: computeBalanceStatus(balance, threshold),
142
+ creditStatus: computeCreditStatus(
143
+ computeAvailableCredit(creditLimit, prepaidCreditUsed(balance)),
144
+ nearLimitBuffer,
145
+ prepaidCreditUsed(balance),
146
+ ),
147
+ tierBreakdown: breakdownFromMetadata(txn?.metadata),
148
+ warning,
149
+ },
150
+ events: [],
151
+ }
152
+ }
153
+ }
154
+
155
+ // ─── 3. Persist the usage record (rated at ingest = sentinel) ──
156
+ const now = new Date()
157
+ const usage = em.create(BillingAccountUsage, {
158
+ organizationId: input.organizationId,
159
+ tenantId: input.tenantId,
160
+ billAccountId: input.billAccountId,
161
+ uomCode: input.uomCode,
162
+ quantity: input.quantity.toFixed(4),
163
+ periodStart: input.periodStart,
164
+ periodEnd: input.periodEnd,
165
+ lineDescription: input.lineDescription ?? null,
166
+ sourceRef: input.sourceRef ?? null,
167
+ // Stamped immediately so a postpaid-style run never re-rates it.
168
+ ratedInBillRunId: PREPAID_USAGE_SENTINEL_RUN_ID,
169
+ isActive: true,
170
+ createdAt: now,
171
+ updatedAt: now,
172
+ })
173
+ em.persist(usage)
174
+ await em.flush()
175
+
176
+ // ─── 4. Rate against the matching usage item ───────────────────
177
+ const item = await findUsageItem(em, input)
178
+ let ratedAmount = '0.0000'
179
+ let breakdown: TierBreakdownEntry[] = []
180
+ let warning: string | null = null
181
+ if (!item) {
182
+ warning = 'no_matching_item'
183
+ } else {
184
+ const rated = rateUsageForItem(item, input.quantity)
185
+ if (!rated) {
186
+ warning = 'unratable'
187
+ } else {
188
+ ratedAmount = rated.amount
189
+ breakdown = rated.breakdown
190
+ }
191
+ }
192
+
193
+ // ─── 5–7. Atomic debit + paired transaction (signed amount) ────
194
+ const movement = await applyBalanceMovement(em, {
195
+ organizationId: input.organizationId,
196
+ tenantId: input.tenantId,
197
+ billAccountId: input.billAccountId,
198
+ currencyCode,
199
+ type: 'usage',
200
+ amount: negateMoney(ratedAmount),
201
+ description: buildDescription(item, input, warning),
202
+ usageId: usage.id,
203
+ billingItemId: item?.id ?? null,
204
+ sourceRef: input.sourceRef ?? null,
205
+ metadata: {
206
+ uom_code: input.uomCode,
207
+ usage_tier_breakdown: breakdown,
208
+ ...(warning ? { warning } : {}),
209
+ },
210
+ })
211
+
212
+ const { balanceBefore, balanceAfter } = movement
213
+
214
+ // ─── 8. Status + crossing events ──────────────────────────────
215
+ const balanceStatus = computeBalanceStatus(balanceAfter, threshold)
216
+ const scope = {
217
+ billAccountId: input.billAccountId,
218
+ tenantId: input.tenantId,
219
+ organizationId: input.organizationId,
220
+ currencyCode,
221
+ }
222
+ const events: DomainEvent[] = buildBalanceCrossingEvents({
223
+ scope,
224
+ balanceBefore,
225
+ balanceAfter,
226
+ threshold,
227
+ creditLimit,
228
+ nearLimitBuffer,
229
+ })
230
+
231
+ // Optional per-debit signal (excluded from workflow triggers).
232
+ events.push({
233
+ id: 'billing.usage.rated',
234
+ payload: {
235
+ ...scope,
236
+ balance: balanceAfter,
237
+ usageId: usage.id,
238
+ transactionId: movement.transaction.id,
239
+ ratedAmount,
240
+ balanceStatus,
241
+ },
242
+ })
243
+
244
+ const usedAfter = prepaidCreditUsed(balanceAfter)
245
+ const creditStatus = computeCreditStatus(
246
+ computeAvailableCredit(creditLimit, usedAfter),
247
+ nearLimitBuffer,
248
+ usedAfter,
249
+ )
250
+
251
+ return {
252
+ result: {
253
+ id: usage.id,
254
+ deduplicated: false,
255
+ ratedAmount,
256
+ currencyCode,
257
+ balance: balanceAfter,
258
+ balanceStatus,
259
+ creditStatus,
260
+ tierBreakdown: breakdown,
261
+ warning,
262
+ },
263
+ events,
264
+ }
265
+ }
266
+
267
+ function buildDescription(
268
+ item: BillingItem | null,
269
+ input: PrepaidConsumeInput,
270
+ warning: string | null,
271
+ ): string {
272
+ if (input.lineDescription) return input.lineDescription
273
+ const qty = input.quantity.toLocaleString('en-US')
274
+ if (!item) return `Usage — ${qty} ${input.uomCode} (no matching item)`
275
+ if (warning === 'unratable') return `Usage — ${qty} ${input.uomCode} (unratable)`
276
+ return `${item.description} — ${qty} ${input.uomCode}`
277
+ }
278
+
279
+ async function readAccountThreshold(
280
+ em: EntityManager,
281
+ input: PrepaidConsumeInput,
282
+ ): Promise<string | null> {
283
+ const row = await em.findOne(BillingAccountBalance, {
284
+ tenantId: input.tenantId,
285
+ billAccountId: input.billAccountId,
286
+ })
287
+ return row?.lowBalanceThreshold ?? null
288
+ }
289
+
290
+ async function readCurrentBalance(
291
+ em: EntityManager,
292
+ input: PrepaidConsumeInput,
293
+ ): Promise<string> {
294
+ const row = await em.findOne(BillingAccountBalance, {
295
+ tenantId: input.tenantId,
296
+ billAccountId: input.billAccountId,
297
+ })
298
+ return row?.balance ?? '0.0000'
299
+ }