@dainamite/billing 0.17.0 → 0.19.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 (206) hide show
  1. package/CHANGELOG.md +99 -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 +9 -9
  129. package/src/modules/billing/__tests__/commands/invoices.test.ts +3 -3
  130. package/src/modules/billing/__tests__/commands/topups.test.ts +104 -0
  131. package/src/modules/billing/__tests__/commands/transactions.test.ts +107 -0
  132. package/src/modules/billing/__tests__/helpers/prepaidMockEm.ts +235 -0
  133. package/src/modules/billing/__tests__/lib/accountStatus.test.ts +93 -0
  134. package/src/modules/billing/__tests__/lib/balanceLedger.test.ts +125 -0
  135. package/src/modules/billing/__tests__/lib/balanceStatus.test.ts +65 -0
  136. package/src/modules/billing/__tests__/lib/creditStatus.test.ts +111 -0
  137. package/src/modules/billing/__tests__/lib/money.test.ts +56 -0
  138. package/src/modules/billing/__tests__/lib/prepaidConsume.test.ts +179 -0
  139. package/src/modules/billing/__tests__/lib/prepaidPeriodClose.test.ts +232 -0
  140. package/src/modules/billing/__tests__/lib/topupCapture.test.ts +123 -0
  141. package/src/modules/billing/__tests__/lib/topupReceipt.test.ts +84 -0
  142. package/src/modules/billing/__tests__/setup.test.ts +28 -2
  143. package/src/modules/billing/acl.ts +8 -0
  144. package/src/modules/billing/api/accounts/[id]/balance/route.ts +74 -0
  145. package/src/modules/billing/api/accounts/[id]/credit-status/route.ts +71 -0
  146. package/src/modules/billing/api/accounts/[id]/topups/route.ts +49 -0
  147. package/src/modules/billing/api/accounts/route.ts +7 -0
  148. package/src/modules/billing/api/export/account/[id]/route.ts +47 -2
  149. package/src/modules/billing/api/statements/generate/route.ts +78 -0
  150. package/src/modules/billing/api/statements/route.ts +117 -0
  151. package/src/modules/billing/api/topups/route.ts +154 -0
  152. package/src/modules/billing/api/transactions/adjust/route.ts +80 -0
  153. package/src/modules/billing/api/transactions/route.ts +116 -0
  154. package/src/modules/billing/api/usage/route.ts +36 -5
  155. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +28 -0
  156. package/src/modules/billing/backend/billing/accounts/create/page.tsx +7 -0
  157. package/src/modules/billing/backend/billing/accounts/page.tsx +32 -1
  158. package/src/modules/billing/backend/billing/statements/[id]/page.meta.ts +19 -0
  159. package/src/modules/billing/backend/billing/statements/[id]/page.tsx +198 -0
  160. package/src/modules/billing/backend/billing/statements/page.meta.ts +16 -0
  161. package/src/modules/billing/backend/billing/statements/page.tsx +190 -0
  162. package/src/modules/billing/backend/billing/topups/[id]/page.meta.ts +19 -0
  163. package/src/modules/billing/backend/billing/topups/[id]/page.tsx +151 -0
  164. package/src/modules/billing/backend/billing/topups/page.meta.ts +16 -0
  165. package/src/modules/billing/backend/billing/topups/page.tsx +182 -0
  166. package/src/modules/billing/backend/billing/transactions/[id]/page.meta.ts +19 -0
  167. package/src/modules/billing/backend/billing/transactions/[id]/page.tsx +179 -0
  168. package/src/modules/billing/backend/billing/transactions/page.meta.ts +16 -0
  169. package/src/modules/billing/backend/billing/transactions/page.tsx +187 -0
  170. package/src/modules/billing/commands/accounts.ts +42 -1
  171. package/src/modules/billing/commands/index.ts +3 -0
  172. package/src/modules/billing/commands/invoices.ts +26 -2
  173. package/src/modules/billing/commands/statements.ts +101 -0
  174. package/src/modules/billing/commands/topups.ts +181 -0
  175. package/src/modules/billing/commands/transactions.ts +152 -0
  176. package/src/modules/billing/commands/usage.ts +114 -4
  177. package/src/modules/billing/components/AccountForm.tsx +66 -0
  178. package/src/modules/billing/components/PrepaidPanel.tsx +382 -0
  179. package/src/modules/billing/components/accountNames.ts +23 -0
  180. package/src/modules/billing/data/entities.ts +383 -0
  181. package/src/modules/billing/data/entityIds.ts +5 -0
  182. package/src/modules/billing/data/validators.ts +144 -0
  183. package/src/modules/billing/events.ts +52 -0
  184. package/src/modules/billing/i18n/en.json +112 -4
  185. package/src/modules/billing/i18n/pl.json +112 -4
  186. package/src/modules/billing/index.ts +1 -1
  187. package/src/modules/billing/lib/accountStatus.ts +155 -0
  188. package/src/modules/billing/lib/balanceEvents.ts +74 -0
  189. package/src/modules/billing/lib/balanceLedger.ts +258 -0
  190. package/src/modules/billing/lib/balanceStatus.ts +78 -0
  191. package/src/modules/billing/lib/billRunEngine.ts +66 -5
  192. package/src/modules/billing/lib/creditStatus.ts +153 -0
  193. package/src/modules/billing/lib/money.ts +73 -0
  194. package/src/modules/billing/lib/prepaidConfig.ts +58 -0
  195. package/src/modules/billing/lib/prepaidConsume.ts +299 -0
  196. package/src/modules/billing/lib/prepaidPeriodClose.ts +453 -0
  197. package/src/modules/billing/lib/topupCapture.ts +117 -0
  198. package/src/modules/billing/lib/topupReceipt.ts +153 -0
  199. package/src/modules/billing/lib/topupTransition.ts +31 -0
  200. package/src/modules/billing/migrations/Migration20260619000000_billing_prepaid.ts +131 -0
  201. package/src/modules/billing/setup.ts +45 -0
  202. package/src/modules/billing/subscribers/prepaid-topup-cancelled.ts +31 -0
  203. package/src/modules/billing/subscribers/prepaid-topup-captured.ts +120 -0
  204. package/src/modules/billing/subscribers/prepaid-topup-expired.ts +31 -0
  205. package/src/modules/billing/subscribers/prepaid-topup-failed.ts +32 -0
  206. package/src/modules/billing/workers/reconcile-prepaid-balances.ts +68 -0
@@ -8,8 +8,9 @@ import {
8
8
  } from '@open-mercato/shared/lib/commands/scope'
9
9
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
10
  import { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'
11
- import { DraftInvoiceEdit } from '../data/entities'
12
- import { emitBillingEvent } from '../events'
11
+ import { BillingAccount, DraftInvoiceEdit } from '../data/entities'
12
+ import { emitBillingEvent, type BillingEventId } from '../events'
13
+ import { buildPostpaidCreditCrossingEvents } from '../lib/accountStatus'
13
14
  import {
14
15
  billingInvoiceAddLineSchema,
15
16
  billingInvoiceEditLineSchema,
@@ -144,6 +145,29 @@ const postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> =
144
145
  })
145
146
  }
146
147
 
148
+ // SPEC-002 P6: a newly-posted invoice raises the account's outstanding
149
+ // exposure — emit billing.credit.over_limit on the upward crossing. Billing
150
+ // never blocks; the consuming flow decides what to do.
151
+ const billAccountId = (meta as { bill_account_id?: string }).bill_account_id
152
+ if (billAccountId) {
153
+ const account = await em.findOne(BillingAccount, {
154
+ id: billAccountId,
155
+ tenantId: parsed.tenantId,
156
+ organizationId: parsed.organizationId,
157
+ deletedAt: null,
158
+ })
159
+ if (account) {
160
+ const creditEvents = await buildPostpaidCreditCrossingEvents(
161
+ em,
162
+ account,
163
+ invoice.outstandingAmount,
164
+ )
165
+ for (const ev of creditEvents) {
166
+ await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
167
+ }
168
+ }
169
+ }
170
+
147
171
  return {
148
172
  invoiceId: invoice.id,
149
173
  invoiceNumber: invoice.invoiceNumber,
@@ -0,0 +1,101 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
4
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
5
+ import {
6
+ ensureOrganizationScope,
7
+ ensureTenantScope,
8
+ } from '@open-mercato/shared/lib/commands/scope'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import { BillingAccount } from '../data/entities'
11
+ import {
12
+ billingStatementGenerateSchema,
13
+ type BillingStatementGenerateInput,
14
+ } from '../data/validators'
15
+ import { runBillRun } from '../lib/billRunEngine'
16
+ import { TenantLockBusyError } from '../lib/tenantLock'
17
+
18
+ /**
19
+ * Manual statement generation (SPEC-002 P3).
20
+ *
21
+ * Reuses the Bill Run engine scoped to a single prepaid account — so the same
22
+ * recurring-charge + statement + anti-duplicate machinery runs, with the same
23
+ * tenant lock. If the account is due (`next_bill_date <= asOfDate`) it produces
24
+ * one statement (and advances the cycle); otherwise nothing is generated.
25
+ */
26
+
27
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
28
+ return (ctx.container.resolve('em') as EntityManager).fork()
29
+ }
30
+
31
+ function getContainer(ctx: CommandRuntimeContext): AwilixContainer {
32
+ return ctx.container as AwilixContainer
33
+ }
34
+
35
+ export type GenerateStatementResult = {
36
+ statementId: string | null
37
+ billRunId: string
38
+ status: string
39
+ }
40
+
41
+ const generateStatementCommand: CommandHandler<
42
+ BillingStatementGenerateInput,
43
+ GenerateStatementResult
44
+ > = {
45
+ id: 'billing.statements.generate',
46
+
47
+ async execute(rawInput, ctx) {
48
+ const parsed = billingStatementGenerateSchema.parse(rawInput)
49
+ ensureTenantScope(ctx, parsed.tenantId)
50
+ ensureOrganizationScope(ctx, parsed.organizationId)
51
+
52
+ const em = getEm(ctx)
53
+ const account = await em.findOne(BillingAccount, {
54
+ id: parsed.billAccountId,
55
+ tenantId: parsed.tenantId,
56
+ organizationId: parsed.organizationId,
57
+ deletedAt: null,
58
+ })
59
+ if (!account) {
60
+ throw new CrudHttpError(404, { error: 'Billing account not found' })
61
+ }
62
+ if (account.billingMode !== 'prepaid') {
63
+ throw new CrudHttpError(409, {
64
+ error: 'Statements are only generated for prepaid accounts',
65
+ code: 'billing.account.not_prepaid',
66
+ })
67
+ }
68
+
69
+ const asOfDate = parsed.asOfDate ?? new Date()
70
+ try {
71
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
72
+ tenantId: parsed.tenantId,
73
+ organizationId: parsed.organizationId,
74
+ asOfDate,
75
+ triggeredBy: 'manual',
76
+ mode: 'real',
77
+ scopedAccountIds: [account.id],
78
+ })
79
+ const outcome = outcomes.find((o) => o.billAccountId === account.id)
80
+ return {
81
+ statementId: outcome?.statementId ?? null,
82
+ billRunId: billRun.id,
83
+ status: outcome?.status ?? billRun.status,
84
+ }
85
+ } catch (err) {
86
+ if (err instanceof TenantLockBusyError) {
87
+ throw new CrudHttpError(409, {
88
+ error: 'Another Bill Run is already in progress for this tenant',
89
+ code: 'billing.run.lock_busy',
90
+ tenantId: err.tenantId,
91
+ lockName: err.lockName,
92
+ })
93
+ }
94
+ throw err
95
+ }
96
+ },
97
+ }
98
+
99
+ registerCommand(generateStatementCommand)
100
+
101
+ export { generateStatementCommand }
@@ -0,0 +1,181 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
4
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
5
+ import {
6
+ ensureOrganizationScope,
7
+ ensureTenantScope,
8
+ } from '@open-mercato/shared/lib/commands/scope'
9
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
10
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
11
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
12
+ import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
13
+ import { BillingAccount, BillingTopup } from '../data/entities'
14
+ import { billingEntityIds } from '../data/entityIds'
15
+ import {
16
+ billingTopupCreateSchema,
17
+ type BillingTopupCreateInput,
18
+ } from '../data/validators'
19
+ import { formatMoney } from '../lib/money'
20
+ import { getTopupProvider } from '../lib/prepaidConfig'
21
+
22
+ /**
23
+ * Top-up initiation (SPEC-002 P2).
24
+ *
25
+ * Registers a first-class `BillingTopup` (status=pending), then opens a
26
+ * payment session via `core/payment_gateways`. The balance is NOT credited
27
+ * here — that happens on the `payment_gateways.payment.captured` event (see
28
+ * subscribers/prepaid-topup-captured.ts), so the money trail is always
29
+ * explainable: a top-up only credits the balance once the gateway confirms
30
+ * capture.
31
+ */
32
+
33
+ const topupIndexer = { entityType: billingEntityIds.topup } as const
34
+ const topupEvents = { module: 'billing', entity: 'topup', persistent: true } as const
35
+
36
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
37
+ return (ctx.container.resolve('em') as EntityManager).fork()
38
+ }
39
+
40
+ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
41
+ return ctx.container.resolve('dataEngine') as DataEngine
42
+ }
43
+
44
+ export type CreateTopupResult = {
45
+ topupId: string
46
+ paymentId: string
47
+ status: string
48
+ transactionId: string | null
49
+ redirectUrl: string | null
50
+ clientSecret: string | null
51
+ }
52
+
53
+ const createTopupCommand: CommandHandler<BillingTopupCreateInput, CreateTopupResult> = {
54
+ id: 'billing.topups.create',
55
+
56
+ async execute(rawInput, ctx) {
57
+ const parsed = billingTopupCreateSchema.parse(rawInput)
58
+ ensureTenantScope(ctx, parsed.tenantId)
59
+ ensureOrganizationScope(ctx, parsed.organizationId)
60
+
61
+ const em = getEm(ctx)
62
+ const account = await em.findOne(BillingAccount, {
63
+ id: parsed.billAccountId,
64
+ tenantId: parsed.tenantId,
65
+ organizationId: parsed.organizationId,
66
+ deletedAt: null,
67
+ })
68
+ if (!account) {
69
+ throw new CrudHttpError(404, { error: 'Billing account not found' })
70
+ }
71
+ if (account.billingMode !== 'prepaid') {
72
+ throw new CrudHttpError(409, {
73
+ error: 'Top-ups are only available on prepaid accounts',
74
+ code: 'billing.account.not_prepaid',
75
+ })
76
+ }
77
+
78
+ // Idempotency on the initiating request: a retried POST with the same
79
+ // sourceRef returns the prior (still-pending) top-up + its session links
80
+ // rather than opening a second session.
81
+ if (parsed.sourceRef) {
82
+ const existing = await em.findOne(BillingTopup, {
83
+ tenantId: parsed.tenantId,
84
+ billAccountId: account.id,
85
+ sourceRef: parsed.sourceRef,
86
+ deletedAt: null,
87
+ })
88
+ if (existing) {
89
+ const meta = (existing.metadata as Record<string, unknown> | null) ?? {}
90
+ return {
91
+ topupId: existing.id,
92
+ paymentId: existing.paymentId,
93
+ status: existing.status,
94
+ transactionId: existing.gatewayTransactionId ?? null,
95
+ redirectUrl: (meta.redirect_url as string | null) ?? null,
96
+ clientSecret: (meta.client_secret as string | null) ?? null,
97
+ }
98
+ }
99
+ }
100
+
101
+ const providerKey = parsed.providerKey ?? (await getTopupProvider(em))
102
+ const paymentId = randomUUID()
103
+ const now = new Date()
104
+ const topup = em.create(BillingTopup, {
105
+ organizationId: parsed.organizationId,
106
+ tenantId: parsed.tenantId,
107
+ billAccountId: account.id,
108
+ status: 'pending',
109
+ amount: formatMoney(parsed.amount),
110
+ currencyCode: account.currencyCode,
111
+ providerKey,
112
+ paymentId,
113
+ sourceRef: parsed.sourceRef ?? null,
114
+ metadata: {
115
+ success_url: parsed.successUrl ?? null,
116
+ cancel_url: parsed.cancelUrl ?? null,
117
+ },
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ })
121
+ em.persist(topup)
122
+ await em.flush()
123
+
124
+ // Open the gateway session. captureMethod 'automatic' so a successful
125
+ // checkout fires `payment.captured` directly (our capture subscriber
126
+ // credits the balance + issues the receipt).
127
+ const gateway = ctx.container.resolve('paymentGatewayService') as PaymentGatewayService
128
+ const { transaction, session } = await gateway.createPaymentSession({
129
+ providerKey,
130
+ paymentId,
131
+ amount: parsed.amount,
132
+ currencyCode: account.currencyCode,
133
+ captureMethod: 'automatic',
134
+ successUrl: parsed.successUrl,
135
+ cancelUrl: parsed.cancelUrl,
136
+ metadata: {
137
+ kind: 'prepaid_topup',
138
+ billAccountId: account.id,
139
+ topupId: topup.id,
140
+ },
141
+ organizationId: parsed.organizationId,
142
+ tenantId: parsed.tenantId,
143
+ })
144
+
145
+ topup.gatewayTransactionId = transaction.id
146
+ topup.metadata = {
147
+ ...(topup.metadata as Record<string, unknown>),
148
+ redirect_url: session.redirectUrl ?? null,
149
+ client_secret: session.clientSecret ?? null,
150
+ session_id: session.sessionId ?? null,
151
+ }
152
+ topup.updatedAt = new Date()
153
+ await em.flush()
154
+
155
+ await emitCrudSideEffects({
156
+ dataEngine: getDataEngine(ctx),
157
+ action: 'created',
158
+ entity: topup,
159
+ identifiers: {
160
+ id: topup.id,
161
+ tenantId: topup.tenantId,
162
+ organizationId: topup.organizationId,
163
+ },
164
+ indexer: topupIndexer,
165
+ events: topupEvents,
166
+ })
167
+
168
+ return {
169
+ topupId: topup.id,
170
+ paymentId,
171
+ status: topup.status,
172
+ transactionId: transaction.id,
173
+ redirectUrl: session.redirectUrl ?? null,
174
+ clientSecret: session.clientSecret ?? null,
175
+ }
176
+ },
177
+ }
178
+
179
+ registerCommand(createTopupCommand)
180
+
181
+ export { createTopupCommand }
@@ -0,0 +1,152 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
3
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
4
+ import {
5
+ ensureOrganizationScope,
6
+ ensureTenantScope,
7
+ } from '@open-mercato/shared/lib/commands/scope'
8
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
11
+ import { BillingAccount, BillingAccountBalance } from '../data/entities'
12
+ import { billingEntityIds } from '../data/entityIds'
13
+ import {
14
+ billingTransactionAdjustSchema,
15
+ type BillingTransactionAdjustInput,
16
+ type BalanceStatus,
17
+ } from '../data/validators'
18
+ import { applyBalanceMovement } from '../lib/balanceLedger'
19
+ import { buildBalanceCrossingEvents } from '../lib/balanceEvents'
20
+ import { computeBalanceStatus, resolveLowBalanceThreshold } from '../lib/balanceStatus'
21
+ import { formatMoney } from '../lib/money'
22
+ import {
23
+ getLowBalanceThresholdDefault,
24
+ getNearLimitBufferDefault,
25
+ } from '../lib/prepaidConfig'
26
+ import { emitBillingEvent, type BillingEventId } from '../events'
27
+
28
+ /**
29
+ * Manual balance adjustment (SPEC-002 P5).
30
+ *
31
+ * Operator-driven credit/debit (`type='adjustment'`) audited via `user_id`.
32
+ * Non-fiscal by default — an adjustment moves already-taxed balance, so no
33
+ * receipt is issued (Open Question #3; can be revisited per jurisdiction).
34
+ * Goes through the same atomic ledger primitive as every other movement, so
35
+ * the reconciliation invariant still holds.
36
+ */
37
+
38
+ const transactionIndexer = { entityType: billingEntityIds.transaction } as const
39
+ const transactionEvents = { module: 'billing', entity: 'transaction', persistent: true } as const
40
+
41
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
42
+ return (ctx.container.resolve('em') as EntityManager).fork()
43
+ }
44
+
45
+ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
46
+ return ctx.container.resolve('dataEngine') as DataEngine
47
+ }
48
+
49
+ export type AdjustResult = {
50
+ transactionId: string
51
+ balance: string
52
+ balanceStatus: BalanceStatus
53
+ deduplicated: boolean
54
+ }
55
+
56
+ const adjustTransactionCommand: CommandHandler<BillingTransactionAdjustInput, AdjustResult> = {
57
+ id: 'billing.transactions.adjust',
58
+
59
+ async execute(rawInput, ctx) {
60
+ const parsed = billingTransactionAdjustSchema.parse(rawInput)
61
+ ensureTenantScope(ctx, parsed.tenantId)
62
+ ensureOrganizationScope(ctx, parsed.organizationId)
63
+
64
+ const em = getEm(ctx)
65
+ const account = await em.findOne(BillingAccount, {
66
+ id: parsed.billAccountId,
67
+ tenantId: parsed.tenantId,
68
+ organizationId: parsed.organizationId,
69
+ deletedAt: null,
70
+ })
71
+ if (!account) {
72
+ throw new CrudHttpError(404, { error: 'Billing account not found' })
73
+ }
74
+ if (account.billingMode !== 'prepaid') {
75
+ throw new CrudHttpError(409, {
76
+ error: 'Balance adjustments are only available on prepaid accounts',
77
+ code: 'billing.account.not_prepaid',
78
+ })
79
+ }
80
+
81
+ const balanceRow = await em.findOne(BillingAccountBalance, {
82
+ tenantId: parsed.tenantId,
83
+ billAccountId: account.id,
84
+ })
85
+ const threshold = resolveLowBalanceThreshold(
86
+ balanceRow?.lowBalanceThreshold ?? null,
87
+ await getLowBalanceThresholdDefault(em),
88
+ )
89
+ const nearLimitBuffer = await getNearLimitBufferDefault(em)
90
+ const userId = (ctx.auth?.sub as string | undefined) ?? null
91
+
92
+ const outcome = await em.transactional(async (tem) =>
93
+ applyBalanceMovement(tem as EntityManager, {
94
+ organizationId: parsed.organizationId,
95
+ tenantId: parsed.tenantId,
96
+ billAccountId: account.id,
97
+ currencyCode: account.currencyCode,
98
+ type: 'adjustment',
99
+ amount: formatMoney(parsed.amount),
100
+ description: parsed.description,
101
+ sourceRef: parsed.sourceRef ?? null,
102
+ userId,
103
+ metadata: { manual: true },
104
+ }),
105
+ )
106
+
107
+ // Index the transaction + emit threshold-crossing events after commit.
108
+ const created = outcome.transaction
109
+ await emitCrudSideEffects({
110
+ dataEngine: getDataEngine(ctx),
111
+ action: 'created',
112
+ entity: created,
113
+ identifiers: {
114
+ id: created.id,
115
+ tenantId: created.tenantId,
116
+ organizationId: created.organizationId,
117
+ },
118
+ indexer: transactionIndexer,
119
+ events: transactionEvents,
120
+ })
121
+
122
+ if (!outcome.deduplicated) {
123
+ const events = buildBalanceCrossingEvents({
124
+ scope: {
125
+ billAccountId: account.id,
126
+ tenantId: parsed.tenantId,
127
+ organizationId: parsed.organizationId,
128
+ currencyCode: account.currencyCode,
129
+ },
130
+ balanceBefore: outcome.balanceBefore,
131
+ balanceAfter: outcome.balanceAfter,
132
+ threshold,
133
+ creditLimit: account.creditLimit ?? '0',
134
+ nearLimitBuffer,
135
+ })
136
+ for (const ev of events) {
137
+ await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })
138
+ }
139
+ }
140
+
141
+ return {
142
+ transactionId: created.id,
143
+ balance: outcome.balanceAfter,
144
+ balanceStatus: computeBalanceStatus(outcome.balanceAfter, threshold),
145
+ deduplicated: outcome.deduplicated,
146
+ }
147
+ },
148
+ }
149
+
150
+ registerCommand(adjustTransactionCommand)
151
+
152
+ export { adjustTransactionCommand }
@@ -1,4 +1,5 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { UniqueConstraintViolationException } from '@mikro-orm/core'
2
3
  import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
3
4
  import { registerCommand } from '@open-mercato/shared/lib/commands'
4
5
  import {
@@ -15,6 +16,11 @@ import {
15
16
  billingUsageCreateSchema,
16
17
  type BillingUsageCreateInput,
17
18
  } from '../data/validators'
19
+ import {
20
+ consumePrepaidUsage,
21
+ type PrepaidConsumeResult,
22
+ } from '../lib/prepaidConsume'
23
+ import { emitBillingEvent, type BillingEventId } from '../events'
18
24
 
19
25
  /**
20
26
  * Usage ingest — append-only.
@@ -41,10 +47,20 @@ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
41
47
  return ctx.container.resolve('dataEngine') as DataEngine
42
48
  }
43
49
 
44
- const createUsageCommand: CommandHandler<
45
- BillingUsageCreateInput,
46
- { id: string; deduplicated: boolean }
47
- > = {
50
+ export type CreateUsageResult = {
51
+ id: string
52
+ deduplicated: boolean
53
+ // Prepaid-only extras (SPEC-002) — present only for prepaid accounts.
54
+ ratedAmount?: string
55
+ currencyCode?: string
56
+ balance?: string
57
+ balanceStatus?: PrepaidConsumeResult['balanceStatus']
58
+ creditStatus?: PrepaidConsumeResult['creditStatus']
59
+ tierBreakdown?: PrepaidConsumeResult['tierBreakdown']
60
+ warning?: string | null
61
+ }
62
+
63
+ const createUsageCommand: CommandHandler<BillingUsageCreateInput, CreateUsageResult> = {
48
64
  id: 'billing.usage.create',
49
65
 
50
66
  async execute(rawInput, ctx) {
@@ -63,6 +79,11 @@ const createUsageCommand: CommandHandler<
63
79
  throw new CrudHttpError(404, { error: 'Billing account not found' })
64
80
  }
65
81
 
82
+ // ─── Prepaid: rate + atomic debit + balance, all in one tx ────
83
+ if (account.billingMode === 'prepaid') {
84
+ return executePrepaidConsume(ctx, em, account, parsed)
85
+ }
86
+
66
87
  if (parsed.sourceRef) {
67
88
  const existing = await findBySourceRef(em, BillingAccountUsage, {
68
89
  tenantId: parsed.tenantId,
@@ -113,6 +134,95 @@ const createUsageCommand: CommandHandler<
113
134
  },
114
135
  }
115
136
 
137
+ /**
138
+ * Prepaid consume: rate + atomic debit + balance + transaction in one DB
139
+ * transaction, then index + emit domain events AFTER the commit.
140
+ */
141
+ async function executePrepaidConsume(
142
+ ctx: CommandRuntimeContext,
143
+ em: EntityManager,
144
+ account: BillingAccount,
145
+ parsed: BillingUsageCreateInput,
146
+ ): Promise<CreateUsageResult> {
147
+ const input = {
148
+ organizationId: parsed.organizationId,
149
+ tenantId: parsed.tenantId,
150
+ billAccountId: parsed.billAccountId,
151
+ uomCode: parsed.uomCode,
152
+ quantity: parsed.quantity,
153
+ periodStart: parsed.periodStart,
154
+ periodEnd: parsed.periodEnd,
155
+ lineDescription: parsed.lineDescription ?? null,
156
+ sourceRef: parsed.sourceRef ?? null,
157
+ }
158
+
159
+ let outcome
160
+ try {
161
+ outcome = await em.transactional(async (tem) =>
162
+ consumePrepaidUsage(tem as EntityManager, account, input),
163
+ )
164
+ } catch (err) {
165
+ // Concurrency: a simultaneous upload with the same sourceRef won the
166
+ // unique-constraint race and committed first, so our tx rolled back (no
167
+ // double-debit). Retry once on a fresh em — the dedup pre-check now finds
168
+ // the winner's record and returns its balance_after (idempotent 200).
169
+ if (parsed.sourceRef && err instanceof UniqueConstraintViolationException) {
170
+ outcome = await em.fork().transactional(async (tem) =>
171
+ consumePrepaidUsage(tem as EntityManager, account, input),
172
+ )
173
+ } else {
174
+ throw err
175
+ }
176
+ }
177
+
178
+ // Index the new usage record (parity with the postpaid path) — only when a
179
+ // fresh record was actually written (a dedup hit re-reads the prior one).
180
+ if (!outcome.result.deduplicated) {
181
+ const usage = await em.findOne(BillingAccountUsage, {
182
+ id: outcome.result.id,
183
+ tenantId: parsed.tenantId,
184
+ organizationId: parsed.organizationId,
185
+ })
186
+ if (usage) {
187
+ await emitCrudSideEffects({
188
+ dataEngine: getDataEngine(ctx),
189
+ action: 'created',
190
+ entity: usage,
191
+ identifiers: {
192
+ id: usage.id,
193
+ tenantId: usage.tenantId,
194
+ organizationId: usage.organizationId,
195
+ },
196
+ indexer: usageIndexer,
197
+ events: usageEvents,
198
+ })
199
+ }
200
+ }
201
+
202
+ // Domain events fire AFTER the DB commit (consistent with the post command).
203
+ // `billing.usage.rated` fires on EVERY upload — emit it ephemerally (a live
204
+ // signal, excluded from triggers) so the high-throughput consume path does
205
+ // not write a queue row per call. The meaningful crossing events
206
+ // (low/exhausted/credit) stay persistent so async consumers never miss them.
207
+ for (const ev of outcome.events) {
208
+ const persistent = ev.id !== 'billing.usage.rated'
209
+ await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent })
210
+ }
211
+
212
+ const r = outcome.result
213
+ return {
214
+ id: r.id,
215
+ deduplicated: r.deduplicated,
216
+ ratedAmount: r.ratedAmount,
217
+ currencyCode: r.currencyCode,
218
+ balance: r.balance,
219
+ balanceStatus: r.balanceStatus,
220
+ creditStatus: r.creditStatus,
221
+ tierBreakdown: r.tierBreakdown,
222
+ warning: r.warning,
223
+ }
224
+ }
225
+
116
226
  registerCommand(createUsageCommand)
117
227
 
118
228
  export { createUsageCommand }