@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
@@ -32,6 +32,8 @@ export class BillingAccount {
32
32
  [OptionalProps]?:
33
33
  | 'taxId'
34
34
  | 'lastBillDate'
35
+ | 'billingMode'
36
+ | 'creditLimit'
35
37
  | 'isActive'
36
38
  | 'createdAt'
37
39
  | 'updatedAt'
@@ -94,6 +96,19 @@ export class BillingAccount {
94
96
  @Property({ name: 'last_bill_date', type: 'date', columnType: 'date', nullable: true })
95
97
  lastBillDate?: Date | null
96
98
 
99
+ // Billing mode — 'postpaid' (default; the shipped Bill Run → draft →
100
+ // post flow) or 'prepaid' (top up a balance, draw it down in real time;
101
+ // SPEC-002). Immutable after create (like currency_code); validated at
102
+ // API ingress. Existing rows default to postpaid — zero behavior change.
103
+ @Property({ name: 'billing_mode', type: 'text', default: 'postpaid' })
104
+ billingMode: string = 'postpaid'
105
+
106
+ // Credit line extended to this customer (both modes). Prepaid: permitted
107
+ // overdraft below zero. Postpaid: max outstanding AR exposure. `0` = no
108
+ // credit. Reported, never enforced by billing (SPEC-002 assumption #11).
109
+ @Property({ name: 'credit_limit', type: 'numeric', columnType: 'numeric(18, 4)', default: '0' })
110
+ creditLimit: string = '0'
111
+
97
112
  @Property({ name: 'is_active', type: 'boolean', default: true })
98
113
  isActive: boolean = true
99
114
 
@@ -422,6 +437,7 @@ export class BillRunOutcome {
422
437
  | 'errorMessage'
423
438
  | 'warnings'
424
439
  | 'draftInvoiceId'
440
+ | 'statementId'
425
441
  | 'isActive'
426
442
  | 'createdAt'
427
443
  | 'updatedAt'
@@ -458,6 +474,12 @@ export class BillRunOutcome {
458
474
  @Property({ name: 'draft_invoice_id', type: 'uuid', nullable: true })
459
475
  draftInvoiceId?: string | null
460
476
 
477
+ // FK (string) to the BillingStatement produced for a PREPAID account
478
+ // (SPEC-002). Prepaid accounts get a statement instead of a draft invoice
479
+ // at period close, so this is the prepaid analogue of `draft_invoice_id`.
480
+ @Property({ name: 'statement_id', type: 'uuid', nullable: true })
481
+ statementId?: string | null
482
+
461
483
  @Property({ name: 'is_active', type: 'boolean', default: true })
462
484
  isActive: boolean = true
463
485
 
@@ -531,3 +553,364 @@ export class DraftInvoiceEdit {
531
553
  @Property({ name: 'deleted_at', type: Date, nullable: true })
532
554
  deletedAt?: Date | null
533
555
  }
556
+
557
+ // ─── BillingAccountBalance (SPEC-002) ────────────────────────────
558
+ //
559
+ // The denormalized running balance for a prepaid account — exactly one
560
+ // row per prepaid account. It is the O(1) read AND the row the atomic
561
+ // `UPDATE … RETURNING` debit/credit locks, which is why the balance is
562
+ // NOT a column on `billing_accounts`: the high-write balance update never
563
+ // touches the account row that the Bill Run hot-path index sits on.
564
+ //
565
+ // `balance` is the cache; the source of truth is
566
+ // `SUM(billing_account_transactions.amount)`. The reconciliation invariant
567
+ // `balance == SUM(amount) == latest.balance_after` is unit-tested.
568
+
569
+ @Entity({ tableName: 'billing_account_balances' })
570
+ @Unique({
571
+ name: 'billing_account_balances_account_unique',
572
+ properties: ['tenantId', 'billAccountId'],
573
+ })
574
+ @Index({
575
+ name: 'billing_account_balances_balance_idx',
576
+ properties: ['organizationId', 'tenantId', 'balance'],
577
+ })
578
+ export class BillingAccountBalance {
579
+ [OptionalProps]?:
580
+ | 'lowBalanceThreshold'
581
+ | 'lastMovementAt'
582
+ | 'createdAt'
583
+ | 'updatedAt'
584
+
585
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
586
+ id!: string
587
+
588
+ @Property({ name: 'organization_id', type: 'uuid' })
589
+ organizationId!: string
590
+
591
+ @Property({ name: 'tenant_id', type: 'uuid' })
592
+ tenantId!: string
593
+
594
+ // Unique FK (string) to BillingAccount.id — one balance per account.
595
+ @Property({ name: 'bill_account_id', type: 'uuid' })
596
+ billAccountId!: string
597
+
598
+ // Denormalized from the account so the balance row is self-contained.
599
+ @Property({ name: 'currency_code', type: 'text' })
600
+ currencyCode!: string
601
+
602
+ // Running balance. May be NEGATIVE (usage is never rejected). Maintained
603
+ // exclusively by the atomic `UPDATE … RETURNING` in lib/balanceLedger.ts.
604
+ @Property({ name: 'balance', type: 'numeric', columnType: 'numeric(18, 4)', default: '0' })
605
+ balance: string = '0'
606
+
607
+ // When `balance <= threshold` → status `low` + `billing.balance.low`.
608
+ // Null = use the tenant config default
609
+ // (`billing.prepaid.low_balance_threshold_default`).
610
+ @Property({
611
+ name: 'low_balance_threshold',
612
+ type: 'numeric',
613
+ columnType: 'numeric(18, 4)',
614
+ nullable: true,
615
+ })
616
+ lowBalanceThreshold?: string | null
617
+
618
+ // Timestamp of the most recent transaction — a cheap "is this account
619
+ // active?" signal.
620
+ @Property({ name: 'last_movement_at', type: Date, nullable: true })
621
+ lastMovementAt?: Date | null
622
+
623
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
624
+ createdAt: Date = new Date()
625
+
626
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
627
+ updatedAt: Date = new Date()
628
+ }
629
+
630
+ // ─── BillingAccountTransaction (SPEC-002, append-only) ───────────
631
+ //
632
+ // The source of truth for every balance movement. Append-only — pattern
633
+ // mirrors DraftInvoiceEdit (no `updated_at` / `is_active`; `deleted_at`
634
+ // reserved for compliance erasure only). `balance = SUM(amount)`.
635
+ //
636
+ // The unique `(tenant_id, bill_account_id, source_ref)` is the idempotency
637
+ // gate: a deduplicated usage upload or a retried recurring charge can never
638
+ // double-debit (Postgres treats NULL source_ref as distinct — manual
639
+ // adjustments without a key always insert).
640
+
641
+ @Entity({ tableName: 'billing_account_transactions' })
642
+ @Unique({
643
+ name: 'billing_account_transactions_source_ref_unique',
644
+ properties: ['tenantId', 'billAccountId', 'sourceRef'],
645
+ })
646
+ @Index({
647
+ name: 'billing_account_transactions_history_idx',
648
+ properties: ['organizationId', 'tenantId', 'billAccountId', 'createdAt'],
649
+ })
650
+ @Index({
651
+ name: 'billing_account_transactions_account_type_idx',
652
+ properties: ['billAccountId', 'type', 'createdAt'],
653
+ })
654
+ export class BillingAccountTransaction {
655
+ [OptionalProps]?:
656
+ | 'usageId'
657
+ | 'billingItemId'
658
+ | 'topupId'
659
+ | 'statementId'
660
+ | 'sourceRef'
661
+ | 'metadata'
662
+ | 'userId'
663
+ | 'createdAt'
664
+ | 'deletedAt'
665
+
666
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
667
+ id!: string
668
+
669
+ @Property({ name: 'organization_id', type: 'uuid' })
670
+ organizationId!: string
671
+
672
+ @Property({ name: 'tenant_id', type: 'uuid' })
673
+ tenantId!: string
674
+
675
+ @Property({ name: 'bill_account_id', type: 'uuid' })
676
+ billAccountId!: string
677
+
678
+ // 'topup' | 'usage' | 'recurring' | 'one_time' | 'adjustment' | 'reversal'.
679
+ @Property({ type: 'text' })
680
+ type!: string
681
+
682
+ // SIGNED: `> 0` credit, `< 0` debit. Balance = SUM(amount).
683
+ @Property({ type: 'numeric', columnType: 'numeric(18, 4)' })
684
+ amount!: string
685
+
686
+ @Property({ name: 'currency_code', type: 'text' })
687
+ currencyCode!: string
688
+
689
+ // Running balance AFTER this transaction — snapshotted from the atomic
690
+ // `UPDATE … RETURNING`. Enables audit + fast statement math without
691
+ // re-summing the whole ledger.
692
+ @Property({ name: 'balance_after', type: 'numeric', columnType: 'numeric(18, 4)' })
693
+ balanceAfter!: string
694
+
695
+ // FK (string) to BillingAccountUsage for type='usage'.
696
+ @Property({ name: 'usage_id', type: 'uuid', nullable: true })
697
+ usageId?: string | null
698
+
699
+ // FK (string) to the Billing Item that priced this debit.
700
+ @Property({ name: 'billing_item_id', type: 'uuid', nullable: true })
701
+ billingItemId?: string | null
702
+
703
+ // FK (string) to BillingTopup for type='topup'.
704
+ @Property({ name: 'topup_id', type: 'uuid', nullable: true })
705
+ topupId?: string | null
706
+
707
+ // FK (string) to the BillingStatement a period-close charge was rolled into.
708
+ @Property({ name: 'statement_id', type: 'uuid', nullable: true })
709
+ statementId?: string | null
710
+
711
+ @Property({ type: 'text' })
712
+ description!: string
713
+
714
+ // Idempotency key, unique per (tenant_id, bill_account_id, source_ref).
715
+ // usage debits = the usage record's source_ref; recurring =
716
+ // `recurring-{itemId}-{periodEndISO}`; topup = `topup-{paymentId}`.
717
+ @Property({ name: 'source_ref', type: 'text', nullable: true })
718
+ sourceRef?: string | null
719
+
720
+ // e.g. usage_tier_breakdown, uom_code, bill_period_start/end, gateway ids.
721
+ @Property({ type: 'jsonb', nullable: true })
722
+ metadata?: Record<string, unknown> | null
723
+
724
+ // Operator for manual `adjustment` / `reversal`.
725
+ @Property({ name: 'user_id', type: 'uuid', nullable: true })
726
+ userId?: string | null
727
+
728
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
729
+ createdAt: Date = new Date()
730
+
731
+ // Compliance erasure only.
732
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
733
+ deletedAt?: Date | null
734
+ }
735
+
736
+ // ─── BillingTopup (SPEC-002 — the "registered top-up") ───────────
737
+ //
738
+ // One row per top-up attempt with a lifecycle (pending → captured | failed
739
+ // | expired | cancelled), linked to its GatewayTransaction, its balance
740
+ // credit transaction, and its VAT receipt invoice. "The balance went up"
741
+ // is always explainable.
742
+
743
+ @Entity({ tableName: 'billing_topups' })
744
+ @Unique({
745
+ name: 'billing_topups_payment_id_unique',
746
+ properties: ['tenantId', 'paymentId'],
747
+ })
748
+ @Index({
749
+ name: 'billing_topups_account_history_idx',
750
+ properties: ['organizationId', 'tenantId', 'billAccountId', 'createdAt'],
751
+ })
752
+ @Index({
753
+ name: 'billing_topups_status_idx',
754
+ properties: ['tenantId', 'status'],
755
+ })
756
+ export class BillingTopup {
757
+ [OptionalProps]?:
758
+ | 'gatewayTransactionId'
759
+ | 'transactionId'
760
+ | 'receiptInvoiceId'
761
+ | 'sourceRef'
762
+ | 'metadata'
763
+ | 'createdAt'
764
+ | 'updatedAt'
765
+ | 'deletedAt'
766
+
767
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
768
+ id!: string
769
+
770
+ @Property({ name: 'organization_id', type: 'uuid' })
771
+ organizationId!: string
772
+
773
+ @Property({ name: 'tenant_id', type: 'uuid' })
774
+ tenantId!: string
775
+
776
+ @Property({ name: 'bill_account_id', type: 'uuid' })
777
+ billAccountId!: string
778
+
779
+ // 'pending' | 'captured' | 'failed' | 'expired' | 'cancelled'.
780
+ @Property({ type: 'text' })
781
+ status!: string
782
+
783
+ @Property({ type: 'numeric', columnType: 'numeric(18, 4)' })
784
+ amount!: string
785
+
786
+ @Property({ name: 'currency_code', type: 'text' })
787
+ currencyCode!: string
788
+
789
+ @Property({ name: 'provider_key', type: 'text' })
790
+ providerKey!: string
791
+
792
+ // The UUID we pass to createPaymentSession and match the captured event
793
+ // on. Unique per tenant.
794
+ @Property({ name: 'payment_id', type: 'uuid' })
795
+ paymentId!: string
796
+
797
+ // FK (string) to payment_gateways.GatewayTransaction once the session is
798
+ // created.
799
+ @Property({ name: 'gateway_transaction_id', type: 'uuid', nullable: true })
800
+ gatewayTransactionId?: string | null
801
+
802
+ // FK (string) to the credit BillingAccountTransaction written on capture.
803
+ @Property({ name: 'transaction_id', type: 'uuid', nullable: true })
804
+ transactionId?: string | null
805
+
806
+ // FK (string) to the core/sales VAT receipt invoice.
807
+ @Property({ name: 'receipt_invoice_id', type: 'uuid', nullable: true })
808
+ receiptInvoiceId?: string | null
809
+
810
+ // Optional idempotency for the initiating request.
811
+ @Property({ name: 'source_ref', type: 'text', nullable: true })
812
+ sourceRef?: string | null
813
+
814
+ // success_url / cancel_url, provider payload echoes.
815
+ @Property({ type: 'jsonb', nullable: true })
816
+ metadata?: Record<string, unknown> | null
817
+
818
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
819
+ createdAt: Date = new Date()
820
+
821
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
822
+ updatedAt: Date = new Date()
823
+
824
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
825
+ deletedAt?: Date | null
826
+ }
827
+
828
+ // ─── BillingStatement (SPEC-002 — period close) ──────────────────
829
+ //
830
+ // A non-fiscal consumption summary for a prepaid account's bill period.
831
+ // Chains cleanly: each statement's `opening_balance` equals the prior
832
+ // statement's `closing_balance`. Anti-duplicate via the unique
833
+ // (tenant_id, bill_account_id, period_start, period_end).
834
+
835
+ @Entity({ tableName: 'billing_statements' })
836
+ @Unique({
837
+ name: 'billing_statements_period_unique',
838
+ properties: ['tenantId', 'billAccountId', 'periodStart', 'periodEnd'],
839
+ })
840
+ @Index({
841
+ name: 'billing_statements_account_period_idx',
842
+ properties: ['organizationId', 'tenantId', 'billAccountId', 'periodEnd'],
843
+ })
844
+ export class BillingStatement {
845
+ [OptionalProps]?:
846
+ | 'billRunId'
847
+ | 'totalAdjustments'
848
+ | 'createdAt'
849
+ | 'updatedAt'
850
+
851
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
852
+ id!: string
853
+
854
+ @Property({ name: 'organization_id', type: 'uuid' })
855
+ organizationId!: string
856
+
857
+ @Property({ name: 'tenant_id', type: 'uuid' })
858
+ tenantId!: string
859
+
860
+ @Property({ name: 'bill_account_id', type: 'uuid' })
861
+ billAccountId!: string
862
+
863
+ // FK (string) to the BillRun that generated it (period close reuses the
864
+ // run engine). Null for a manually-generated statement.
865
+ @Property({ name: 'bill_run_id', type: 'uuid', nullable: true })
866
+ billRunId?: string | null
867
+
868
+ @Property({ name: 'period_start', type: 'date', columnType: 'date' })
869
+ periodStart!: Date
870
+
871
+ @Property({ name: 'period_end', type: 'date', columnType: 'date' })
872
+ periodEnd!: Date
873
+
874
+ @Property({ name: 'currency_code', type: 'text' })
875
+ currencyCode!: string
876
+
877
+ // Balance at period_start (= the prior statement's closing_balance).
878
+ @Property({ name: 'opening_balance', type: 'numeric', columnType: 'numeric(18, 4)' })
879
+ openingBalance!: string
880
+
881
+ // Sum of credits in window.
882
+ @Property({ name: 'total_topups', type: 'numeric', columnType: 'numeric(18, 4)' })
883
+ totalTopups!: string
884
+
885
+ // Sum of metered (real-time) usage debits in window (absolute).
886
+ @Property({ name: 'total_usage', type: 'numeric', columnType: 'numeric(18, 4)' })
887
+ totalUsage!: string
888
+
889
+ // Sum of recurring + one_time charges debited at this close (absolute).
890
+ @Property({ name: 'total_recurring', type: 'numeric', columnType: 'numeric(18, 4)' })
891
+ totalRecurring!: string
892
+
893
+ // Signed sum of manual adjustment + reversal movements in the window. Kept as
894
+ // its own term so the statement stays self-consistent (closing reproduces
895
+ // from the shown components) and the opening→closing chain never drifts.
896
+ @Property({ name: 'total_adjustments', type: 'numeric', columnType: 'numeric(18, 4)', default: '0' })
897
+ totalAdjustments: string = '0'
898
+
899
+ // opening + topups + adjustments − usage − recurring (= the period's net).
900
+ @Property({ name: 'closing_balance', type: 'numeric', columnType: 'numeric(18, 4)' })
901
+ closingBalance!: string
902
+
903
+ // Per billing_item_id: { billing_type, uom_code?, quantity, amount }.
904
+ @Property({ name: 'debit_breakdown', type: 'jsonb' })
905
+ debitBreakdown!: Record<string, unknown>
906
+
907
+ // 'generated' | 'sent'.
908
+ @Property({ type: 'text' })
909
+ status!: string
910
+
911
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
912
+ createdAt: Date = new Date()
913
+
914
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
915
+ updatedAt: Date = new Date()
916
+ }
@@ -20,6 +20,11 @@ export const billingEntityIds = {
20
20
  run: 'billing:billing_run',
21
21
  runOutcome: 'billing:billing_run_outcome',
22
22
  draftInvoiceEdit: 'billing:billing_draft_invoice_edit',
23
+ // SPEC-002 (prepaid balance billing)
24
+ balance: 'billing:billing_account_balance',
25
+ transaction: 'billing:billing_account_transaction',
26
+ topup: 'billing:billing_topup',
27
+ statement: 'billing:billing_statement',
23
28
  } as const
24
29
 
25
30
  export type BillingEntityId = (typeof billingEntityIds)[keyof typeof billingEntityIds]
@@ -18,6 +18,9 @@ export const BILL_RUN_OUTCOME_STATUSES = [
18
18
  'success',
19
19
  'success_with_warnings',
20
20
  'skipped_existing_draft',
21
+ // SPEC-002: prepaid period close was skipped because a statement already
22
+ // exists for the window.
23
+ 'skipped_existing_statement',
21
24
  'failed',
22
25
  ] as const
23
26
  export type BillRunOutcomeStatus = (typeof BILL_RUN_OUTCOME_STATUSES)[number]
@@ -28,6 +31,41 @@ export type DraftInvoiceEditAction = (typeof DRAFT_INVOICE_EDIT_ACTIONS)[number]
28
31
  export const INVOICE_STATUSES = ['draft', 'posted', 'paid', 'void'] as const
29
32
  export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]
30
33
 
34
+ // ─── Prepaid (SPEC-002) enums ────────────────────────────────────
35
+
36
+ export const BILLING_MODES = ['postpaid', 'prepaid'] as const
37
+ export type BillingMode = (typeof BILLING_MODES)[number]
38
+
39
+ export const TRANSACTION_TYPES = [
40
+ 'topup',
41
+ 'usage',
42
+ 'recurring',
43
+ 'one_time',
44
+ 'adjustment',
45
+ 'reversal',
46
+ ] as const
47
+ export type TransactionType = (typeof TRANSACTION_TYPES)[number]
48
+
49
+ export const TOPUP_STATUSES = [
50
+ 'pending',
51
+ 'captured',
52
+ 'failed',
53
+ 'expired',
54
+ 'cancelled',
55
+ ] as const
56
+ export type TopupStatus = (typeof TOPUP_STATUSES)[number]
57
+
58
+ export const STATEMENT_STATUSES = ['generated', 'sent'] as const
59
+ export type StatementStatus = (typeof STATEMENT_STATUSES)[number]
60
+
61
+ // Prepaid funds status (the account's own money).
62
+ export const BALANCE_STATUSES = ['ok', 'low', 'exhausted'] as const
63
+ export type BalanceStatus = (typeof BALANCE_STATUSES)[number]
64
+
65
+ // Credit-line status (extended overdraft / AR exposure — both modes).
66
+ export const CREDIT_STATUSES = ['within_limit', 'near_limit', 'over_limit'] as const
67
+ export type CreditStatus = (typeof CREDIT_STATUSES)[number]
68
+
31
69
  // `mon`–`sun` for weekly cycles; 1–28 (as text) for the others.
32
70
  // Why text for numeric anchors: the column type stays uniform across cycles
33
71
  // and we never do arithmetic on the raw value (the engine parses it on use).
@@ -213,6 +251,10 @@ const listPaginationBase = {
213
251
 
214
252
  // ─── BillingAccount payloads ─────────────────────────────────────
215
253
 
254
+ // numeric(18,4) money inputs arrive as numbers; the command coerces to a
255
+ // 4dp string at persist time.
256
+ const moneyNonNegative = z.number().finite().nonnegative()
257
+
216
258
  export const billingAccountCreateSchema = scopedSchema.extend({
217
259
  customerId: z.string().min(1).max(255),
218
260
  name: z.string().trim().min(1).max(255),
@@ -229,6 +271,14 @@ export const billingAccountCreateSchema = scopedSchema.extend({
229
271
  nextBillDate: z.coerce.date(),
230
272
  lastBillDate: z.coerce.date().optional(),
231
273
  isActive: z.boolean().optional(),
274
+ // SPEC-002: mode is immutable after create (like currencyCode). Default
275
+ // 'postpaid' so existing callers are unaffected.
276
+ billingMode: z.enum(BILLING_MODES).optional(),
277
+ // Credit line (both modes). 0 = no credit.
278
+ creditLimit: moneyNonNegative.optional(),
279
+ // Initial low-balance threshold for a prepaid account's balance row.
280
+ // Null = resolve the tenant config default at read time.
281
+ lowBalanceThreshold: moneyNonNegative.nullable().optional(),
232
282
  })
233
283
 
234
284
  export type BillingAccountCreateInput = z.infer<typeof billingAccountCreateSchema>
@@ -245,6 +295,13 @@ export const billingAccountUpdateSchema = scopedSchema.extend({
245
295
  nextBillDate: z.coerce.date().optional(),
246
296
  lastBillDate: z.coerce.date().nullable().optional(),
247
297
  isActive: z.boolean().optional(),
298
+ // SPEC-002: credit_limit is editable (it's an AR/overdraft policy knob);
299
+ // `billingMode` is intentionally NOT updatable — mode is frozen at create
300
+ // (like currencyCode), no live postpaid↔prepaid switch in v1.
301
+ creditLimit: moneyNonNegative.optional(),
302
+ // Editing the prepaid balance row's low-balance threshold. Null clears it
303
+ // back to the tenant config default.
304
+ lowBalanceThreshold: moneyNonNegative.nullable().optional(),
248
305
  // `currencyCode` is intentionally NOT updatable — currency is frozen at
249
306
  // account create time so historical invoices keep their currency context.
250
307
  // `customerId` is also immutable; create a new account if the customer
@@ -266,6 +323,7 @@ export const billingAccountListQuerySchema = z
266
323
  customerId: z.string().optional(),
267
324
  currencyCode: z.string().optional(),
268
325
  billCycle: z.enum(BILL_CYCLES).optional(),
326
+ billingMode: z.enum(BILLING_MODES).optional(),
269
327
  isActive: z.coerce.boolean().optional(),
270
328
  search: z.string().optional(),
271
329
  })
@@ -559,4 +617,90 @@ export const billingWipeTestInvoicesSchema = scopedSchema.extend({
559
617
 
560
618
  export type BillingWipeTestInvoicesInput = z.infer<typeof billingWipeTestInvoicesSchema>
561
619
 
620
+ // ─── Prepaid: top-ups (SPEC-002 P2) ──────────────────────────────
621
+
622
+ export const billingTopupCreateSchema = scopedSchema.extend({
623
+ // The route maps the `/accounts/{id}/topups` path param into billAccountId.
624
+ billAccountId: z.string().uuid(),
625
+ amount: z.number().finite().positive(),
626
+ // Optional — defaults to the tenant config `prepaid.topup_provider`.
627
+ providerKey: z.string().trim().min(1).max(64).optional(),
628
+ successUrl: z.string().trim().url().max(2048).optional(),
629
+ cancelUrl: z.string().trim().url().max(2048).optional(),
630
+ // Optional idempotency for the initiating request.
631
+ sourceRef: z.string().trim().min(1).max(255).optional(),
632
+ })
633
+
634
+ export type BillingTopupCreateInput = z.infer<typeof billingTopupCreateSchema>
635
+
636
+ export const billingTopupListQuerySchema = z
637
+ .object({
638
+ ...listPaginationBase,
639
+ id: z.string().uuid().optional(),
640
+ billAccountId: z.string().uuid().optional(),
641
+ status: z.enum(TOPUP_STATUSES).optional(),
642
+ search: z.string().optional(),
643
+ })
644
+ .passthrough()
645
+
646
+ export type BillingTopupListQuery = z.infer<typeof billingTopupListQuerySchema>
647
+
648
+ // ─── Prepaid: transactions / manual adjustments (SPEC-002 P1/P5) ──
649
+
650
+ export const billingTransactionAdjustSchema = scopedSchema
651
+ .extend({
652
+ billAccountId: z.string().uuid(),
653
+ // SIGNED: > 0 credit, < 0 debit. Zero is rejected.
654
+ amount: z.number().finite(),
655
+ description: z.string().trim().min(1).max(500),
656
+ sourceRef: z.string().trim().min(1).max(255).optional(),
657
+ })
658
+ .superRefine((val, ctx) => {
659
+ if (val.amount === 0) {
660
+ ctx.addIssue({
661
+ code: z.ZodIssueCode.custom,
662
+ path: ['amount'],
663
+ message: 'amount must be non-zero (positive credit or negative debit)',
664
+ })
665
+ }
666
+ })
667
+
668
+ export type BillingTransactionAdjustInput = z.infer<typeof billingTransactionAdjustSchema>
669
+
670
+ export const billingTransactionListQuerySchema = z
671
+ .object({
672
+ ...listPaginationBase,
673
+ id: z.string().uuid().optional(),
674
+ billAccountId: z.string().uuid().optional(),
675
+ type: z.enum(TRANSACTION_TYPES).optional(),
676
+ createdFrom: z.coerce.date().optional(),
677
+ createdTo: z.coerce.date().optional(),
678
+ })
679
+ .passthrough()
680
+
681
+ export type BillingTransactionListQuery = z.infer<typeof billingTransactionListQuerySchema>
682
+
683
+ // ─── Prepaid: statements (SPEC-002 P3) ───────────────────────────
684
+
685
+ export const billingStatementGenerateSchema = scopedSchema.extend({
686
+ billAccountId: z.string().uuid(),
687
+ // "today" the close evaluates the due period against. Defaults to now.
688
+ asOfDate: z.coerce.date().optional(),
689
+ })
690
+
691
+ export type BillingStatementGenerateInput = z.infer<typeof billingStatementGenerateSchema>
692
+
693
+ export const billingStatementListQuerySchema = z
694
+ .object({
695
+ ...listPaginationBase,
696
+ id: z.string().uuid().optional(),
697
+ billAccountId: z.string().uuid().optional(),
698
+ status: z.enum(STATEMENT_STATUSES).optional(),
699
+ periodEndFrom: z.coerce.date().optional(),
700
+ periodEndTo: z.coerce.date().optional(),
701
+ })
702
+ .passthrough()
703
+
704
+ export type BillingStatementListQuery = z.infer<typeof billingStatementListQuerySchema>
705
+
562
706
 
@@ -34,6 +34,58 @@ const events = [
34
34
  category: 'lifecycle',
35
35
  clientBroadcast: true,
36
36
  },
37
+ // ─── Prepaid balance billing (SPEC-002) ──────────────────────
38
+ {
39
+ id: 'billing.balance.topped_up',
40
+ label: 'Balance Topped Up',
41
+ entity: 'balance',
42
+ category: 'lifecycle',
43
+ clientBroadcast: true,
44
+ },
45
+ {
46
+ id: 'billing.balance.low',
47
+ label: 'Balance Low',
48
+ entity: 'balance',
49
+ category: 'lifecycle',
50
+ clientBroadcast: true,
51
+ },
52
+ {
53
+ id: 'billing.balance.exhausted',
54
+ label: 'Balance Exhausted',
55
+ entity: 'balance',
56
+ category: 'lifecycle',
57
+ clientBroadcast: true,
58
+ },
59
+ {
60
+ // Per-debit signal — useful for a live balance UI but potentially
61
+ // high-volume, so excluded from workflow triggers (see Open Question #2).
62
+ id: 'billing.usage.rated',
63
+ label: 'Usage Rated',
64
+ entity: 'transaction',
65
+ category: 'lifecycle',
66
+ excludeFromTriggers: true,
67
+ },
68
+ {
69
+ id: 'billing.statement.generated',
70
+ label: 'Consumption Statement Generated',
71
+ entity: 'statement',
72
+ category: 'lifecycle',
73
+ clientBroadcast: true,
74
+ },
75
+ {
76
+ id: 'billing.credit.over_limit',
77
+ label: 'Credit Over Limit',
78
+ entity: 'credit',
79
+ category: 'lifecycle',
80
+ clientBroadcast: true,
81
+ },
82
+ {
83
+ id: 'billing.credit.near_limit',
84
+ label: 'Credit Near Limit',
85
+ entity: 'credit',
86
+ category: 'lifecycle',
87
+ clientBroadcast: true,
88
+ },
37
89
  ] as const
38
90
 
39
91
  export const eventsConfig = createModuleEvents({