@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
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/prepaidConsume.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n BillingAccount,\n BillingAccountBalance,\n BillingAccountTransaction,\n BillingAccountUsage,\n BillingItem,\n} from '../data/entities'\nimport type { BalanceStatus, CreditStatus } from '../data/validators'\nimport {\n applyBalanceMovement,\n PREPAID_USAGE_SENTINEL_RUN_ID,\n} from './balanceLedger'\nimport {\n computeBalanceStatus,\n resolveLowBalanceThreshold,\n} from './balanceStatus'\nimport {\n computeCreditStatus,\n computeAvailableCredit,\n prepaidCreditUsed,\n} from './creditStatus'\nimport { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'\nimport { absMoney, negateMoney } from './money'\nimport {\n getLowBalanceThresholdDefault,\n getNearLimitBufferDefault,\n} from './prepaidConfig'\nimport { rateUsageForItem, type TierBreakdownEntry } from './usageRater'\n\n/**\n * Real-time prepaid consume (SPEC-002 P1).\n *\n * For a prepaid account, a usage upload is rated in real time, debited from\n * the balance, and the remaining balance is returned \u2014 *\"I uploaded usage and\n * got my remaining balance back.\"* Usage is NEVER rejected: a missing price or\n * an exhausted balance still records the usage; we report status and let the\n * client throttle itself.\n *\n * The caller MUST run this inside an `em.transactional(...)` so the usage\n * insert, the atomic balance debit, and the transaction insert commit (or roll\n * back) together. Domain events are returned (not emitted) so the caller can\n * fire them AFTER the DB commit.\n */\n\nexport type PrepaidConsumeInput = {\n organizationId: string\n tenantId: string\n billAccountId: string\n uomCode: string\n quantity: number\n periodStart: Date\n periodEnd: Date\n lineDescription?: string | null\n sourceRef?: string | null\n}\n\nexport type PrepaidConsumeResult = {\n id: string\n deduplicated: boolean\n ratedAmount: string\n currencyCode: string\n balance: string\n balanceStatus: BalanceStatus\n creditStatus: CreditStatus\n tierBreakdown: TierBreakdownEntry[]\n warning: string | null\n}\n\nexport type PrepaidConsumeOutcome = {\n result: PrepaidConsumeResult\n events: DomainEvent[]\n}\n\nfunction breakdownFromMetadata(meta: Record<string, unknown> | null | undefined): TierBreakdownEntry[] {\n const b = meta?.usage_tier_breakdown\n return Array.isArray(b) ? (b as TierBreakdownEntry[]) : []\n}\n\n/**\n * Resolve the active `type=usage` Billing Item priced for a uom (exact match,\n * no conversion \u2014 same rule as the postpaid runner). Latest active item wins.\n */\nasync function findUsageItem(\n em: EntityManager,\n input: PrepaidConsumeInput,\n): Promise<BillingItem | null> {\n return em.findOne(\n BillingItem,\n {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n billAccountId: input.billAccountId,\n type: 'usage',\n uomCode: input.uomCode,\n isActive: true,\n deletedAt: null,\n } as never,\n { orderBy: { createdAt: 'DESC' } },\n )\n}\n\nexport async function consumePrepaidUsage(\n em: EntityManager,\n account: BillingAccount,\n input: PrepaidConsumeInput,\n): Promise<PrepaidConsumeOutcome> {\n const currencyCode = account.currencyCode\n const threshold = resolveLowBalanceThreshold(\n await readAccountThreshold(em, input),\n await getLowBalanceThresholdDefault(em),\n )\n const nearLimitBuffer = await getNearLimitBufferDefault(em)\n const creditLimit = account.creditLimit ?? '0'\n\n // \u2500\u2500\u2500 Idempotency: a duplicate sourceRef never double-debits \u2500\u2500\u2500\u2500\n if (input.sourceRef) {\n const existing = await em.findOne(BillingAccountUsage, {\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n sourceRef: input.sourceRef,\n deletedAt: null,\n })\n if (existing) {\n const txn = await em.findOne(BillingAccountTransaction, {\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n usageId: existing.id,\n deletedAt: null,\n })\n const balance = txn?.balanceAfter ?? (await readCurrentBalance(em, input))\n const ratedAmount = txn ? absMoney(txn.amount) : '0.0000'\n const warning = (txn?.metadata?.warning as string | undefined) ?? null\n return {\n result: {\n id: existing.id,\n deduplicated: true,\n ratedAmount,\n currencyCode,\n balance,\n balanceStatus: computeBalanceStatus(balance, threshold),\n creditStatus: computeCreditStatus(\n computeAvailableCredit(creditLimit, prepaidCreditUsed(balance)),\n nearLimitBuffer,\n prepaidCreditUsed(balance),\n ),\n tierBreakdown: breakdownFromMetadata(txn?.metadata),\n warning,\n },\n events: [],\n }\n }\n }\n\n // \u2500\u2500\u2500 3. Persist the usage record (rated at ingest = sentinel) \u2500\u2500\n const now = new Date()\n const usage = em.create(BillingAccountUsage, {\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n uomCode: input.uomCode,\n quantity: input.quantity.toFixed(4),\n periodStart: input.periodStart,\n periodEnd: input.periodEnd,\n lineDescription: input.lineDescription ?? null,\n sourceRef: input.sourceRef ?? null,\n // Stamped immediately so a postpaid-style run never re-rates it.\n ratedInBillRunId: PREPAID_USAGE_SENTINEL_RUN_ID,\n isActive: true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(usage)\n await em.flush()\n\n // \u2500\u2500\u2500 4. Rate against the matching usage item \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const item = await findUsageItem(em, input)\n let ratedAmount = '0.0000'\n let breakdown: TierBreakdownEntry[] = []\n let warning: string | null = null\n if (!item) {\n warning = 'no_matching_item'\n } else {\n const rated = rateUsageForItem(item, input.quantity)\n if (!rated) {\n warning = 'unratable'\n } else {\n ratedAmount = rated.amount\n breakdown = rated.breakdown\n }\n }\n\n // \u2500\u2500\u2500 5\u20137. Atomic debit + paired transaction (signed amount) \u2500\u2500\u2500\u2500\n const movement = await applyBalanceMovement(em, {\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n currencyCode,\n type: 'usage',\n amount: negateMoney(ratedAmount),\n description: buildDescription(item, input, warning),\n usageId: usage.id,\n billingItemId: item?.id ?? null,\n sourceRef: input.sourceRef ?? null,\n metadata: {\n uom_code: input.uomCode,\n usage_tier_breakdown: breakdown,\n ...(warning ? { warning } : {}),\n },\n })\n\n const { balanceBefore, balanceAfter } = movement\n\n // \u2500\u2500\u2500 8. Status + crossing events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const balanceStatus = computeBalanceStatus(balanceAfter, threshold)\n const scope = {\n billAccountId: input.billAccountId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n currencyCode,\n }\n const events: DomainEvent[] = buildBalanceCrossingEvents({\n scope,\n balanceBefore,\n balanceAfter,\n threshold,\n creditLimit,\n nearLimitBuffer,\n })\n\n // Optional per-debit signal (excluded from workflow triggers).\n events.push({\n id: 'billing.usage.rated',\n payload: {\n ...scope,\n balance: balanceAfter,\n usageId: usage.id,\n transactionId: movement.transaction.id,\n ratedAmount,\n balanceStatus,\n },\n })\n\n const usedAfter = prepaidCreditUsed(balanceAfter)\n const creditStatus = computeCreditStatus(\n computeAvailableCredit(creditLimit, usedAfter),\n nearLimitBuffer,\n usedAfter,\n )\n\n return {\n result: {\n id: usage.id,\n deduplicated: false,\n ratedAmount,\n currencyCode,\n balance: balanceAfter,\n balanceStatus,\n creditStatus,\n tierBreakdown: breakdown,\n warning,\n },\n events,\n }\n}\n\nfunction buildDescription(\n item: BillingItem | null,\n input: PrepaidConsumeInput,\n warning: string | null,\n): string {\n if (input.lineDescription) return input.lineDescription\n const qty = input.quantity.toLocaleString('en-US')\n if (!item) return `Usage \u2014 ${qty} ${input.uomCode} (no matching item)`\n if (warning === 'unratable') return `Usage \u2014 ${qty} ${input.uomCode} (unratable)`\n return `${item.description} \u2014 ${qty} ${input.uomCode}`\n}\n\nasync function readAccountThreshold(\n em: EntityManager,\n input: PrepaidConsumeInput,\n): Promise<string | null> {\n const row = await em.findOne(BillingAccountBalance, {\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n })\n return row?.lowBalanceThreshold ?? null\n}\n\nasync function readCurrentBalance(\n em: EntityManager,\n input: PrepaidConsumeInput,\n): Promise<string> {\n const row = await em.findOne(BillingAccountBalance, {\n tenantId: input.tenantId,\n billAccountId: input.billAccountId,\n })\n return row?.balance ?? '0.0000'\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["ratedAmount", "warning"]
7
+ }
@@ -0,0 +1,285 @@
1
+ import {
2
+ BillingAccountBalance,
3
+ BillingAccountTransaction,
4
+ BillingItem,
5
+ BillingStatement
6
+ } from "../data/entities.js";
7
+ import { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue } from "./billPeriod.js";
8
+ import { applyBalanceMovement } from "./balanceLedger.js";
9
+ import { resolveLowBalanceThreshold } from "./balanceStatus.js";
10
+ import { buildInvoiceLinesFromItems } from "./invoiceWriter.js";
11
+ import { selectItemsForPeriod } from "./itemSelector.js";
12
+ import { absMoney, addMoney, fromUnits, subMoney, toUnits } from "./money.js";
13
+ import {
14
+ getLowBalanceThresholdDefault,
15
+ getNearLimitBufferDefault,
16
+ getStatementEnabled
17
+ } from "./prepaidConfig.js";
18
+ import { buildBalanceCrossingEvents } from "./balanceEvents.js";
19
+ const SCHEDULED_CYCLE_LIMIT = 1;
20
+ const CATCH_UP_HARD_CAP = 60;
21
+ async function processPrepaidAccount(em, account, billRun, params) {
22
+ const maxCycles = params.triggeredBy === "schedule" || !params.catchUp ? SCHEDULED_CYCLE_LIMIT : CATCH_UP_HARD_CAP;
23
+ const statementEnabled = await getStatementEnabled(em);
24
+ const balanceRow = await em.findOne(BillingAccountBalance, {
25
+ tenantId: account.tenantId,
26
+ billAccountId: account.id
27
+ });
28
+ const threshold = resolveLowBalanceThreshold(
29
+ balanceRow?.lowBalanceThreshold ?? null,
30
+ await getLowBalanceThresholdDefault(em)
31
+ );
32
+ const nearLimitBuffer = await getNearLimitBufferDefault(em);
33
+ const creditLimit = account.creditLimit ?? "0";
34
+ let cyclesEmitted = 0;
35
+ let lastStatementId = null;
36
+ const statementEvents = [];
37
+ let previousClosing = null;
38
+ while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {
39
+ const period = deriveBillPeriod(account.nextBillDate, account.billCycle);
40
+ if (params.mode !== "dry") {
41
+ const existing = await em.findOne(BillingStatement, {
42
+ tenantId: account.tenantId,
43
+ billAccountId: account.id,
44
+ periodStart: period.periodStart,
45
+ periodEnd: period.periodEnd
46
+ });
47
+ if (existing) {
48
+ return {
49
+ status: "skipped_existing_statement",
50
+ warnings: null,
51
+ statementId: existing.id,
52
+ cyclesEmitted,
53
+ statementEvents
54
+ };
55
+ }
56
+ }
57
+ const cycle = await closePrepaidPeriod(em, account, period, billRun, {
58
+ mode: params.mode,
59
+ statementEnabled,
60
+ previousClosing,
61
+ threshold,
62
+ nearLimitBuffer,
63
+ creditLimit
64
+ });
65
+ previousClosing = cycle.closingBalance;
66
+ statementEvents.push(...cycle.crossingEvents);
67
+ if (cycle.statementId) {
68
+ lastStatementId = cycle.statementId;
69
+ statementEvents.push({
70
+ id: "billing.statement.generated",
71
+ payload: {
72
+ statementId: cycle.statementId,
73
+ billAccountId: account.id,
74
+ tenantId: account.tenantId,
75
+ organizationId: account.organizationId,
76
+ billRunId: billRun.id,
77
+ periodStart: period.periodStart.toISOString().slice(0, 10),
78
+ periodEnd: period.periodEnd.toISOString().slice(0, 10),
79
+ currencyCode: account.currencyCode,
80
+ openingBalance: cycle.openingBalance,
81
+ closingBalance: cycle.closingBalance,
82
+ totalTopups: cycle.totalTopups,
83
+ totalUsage: cycle.totalUsage,
84
+ totalRecurring: cycle.totalRecurring,
85
+ totalAdjustments: cycle.totalAdjustments
86
+ }
87
+ });
88
+ }
89
+ if (params.mode === "real") {
90
+ account.nextBillDate = advanceNextBillDate(
91
+ account.nextBillDate,
92
+ account.billCycle
93
+ );
94
+ account.lastBillDate = period.periodEnd;
95
+ account.updatedAt = /* @__PURE__ */ new Date();
96
+ await em.flush();
97
+ } else {
98
+ cyclesEmitted += 1;
99
+ break;
100
+ }
101
+ cyclesEmitted += 1;
102
+ }
103
+ return {
104
+ status: "success",
105
+ warnings: null,
106
+ statementId: lastStatementId,
107
+ cyclesEmitted,
108
+ statementEvents
109
+ };
110
+ }
111
+ async function closePrepaidPeriod(em, account, period, billRun, opts) {
112
+ const periodEndISO = period.periodEnd.toISOString().slice(0, 10);
113
+ const items = await em.find(BillingItem, {
114
+ tenantId: account.tenantId,
115
+ organizationId: account.organizationId,
116
+ billAccountId: account.id,
117
+ deletedAt: null
118
+ });
119
+ const { included } = selectItemsForPeriod(items, period);
120
+ const billableItems = included;
121
+ const lines = buildInvoiceLinesFromItems(billableItems);
122
+ const windowStart = period.periodStart;
123
+ const windowEnd = addDays(period.periodEnd, 1);
124
+ const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing);
125
+ const totalTopups = await sumByTypeInWindow(em, account, "topup", windowStart, windowEnd);
126
+ const totalUsage = absMoney(
127
+ await sumByTypeInWindow(em, account, "usage", windowStart, windowEnd)
128
+ );
129
+ const totalAdjustments = addMoney(
130
+ await sumByTypeInWindow(em, account, "adjustment", windowStart, windowEnd),
131
+ await sumByTypeInWindow(em, account, "reversal", windowStart, windowEnd)
132
+ );
133
+ let totalRecurring = "0.0000";
134
+ const breakdown = [];
135
+ const chargeTransactionIds = [];
136
+ let recurringBalanceBefore = null;
137
+ let recurringBalanceAfter = null;
138
+ if (opts.mode === "real") {
139
+ const now = /* @__PURE__ */ new Date();
140
+ for (const line of lines) {
141
+ const movement = await applyBalanceMovement(em, {
142
+ organizationId: account.organizationId,
143
+ tenantId: account.tenantId,
144
+ billAccountId: account.id,
145
+ currencyCode: account.currencyCode,
146
+ type: line.billingType === "one_time" ? "one_time" : "recurring",
147
+ amount: fromUnits(-toUnits(line.totalNetAmount)),
148
+ description: line.description,
149
+ billingItemId: line.billingItemId,
150
+ sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,
151
+ metadata: {
152
+ bill_period_start: period.periodStart.toISOString().slice(0, 10),
153
+ bill_period_end: periodEndISO,
154
+ bill_run_id: billRun.id
155
+ }
156
+ });
157
+ if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore;
158
+ recurringBalanceAfter = movement.balanceAfter;
159
+ totalRecurring = addMoney(totalRecurring, line.totalNetAmount);
160
+ if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id);
161
+ breakdown.push({
162
+ billing_item_id: line.billingItemId,
163
+ billing_type: line.billingType,
164
+ quantity: line.quantity,
165
+ amount: line.totalNetAmount
166
+ });
167
+ const item = billableItems.find((it) => it.id === line.billingItemId);
168
+ if (item) {
169
+ item.billedToDate = period.periodEnd;
170
+ item.updatedAt = now;
171
+ }
172
+ }
173
+ await em.flush();
174
+ } else {
175
+ for (const line of lines) {
176
+ totalRecurring = addMoney(totalRecurring, line.totalNetAmount);
177
+ breakdown.push({
178
+ billing_item_id: line.billingItemId,
179
+ billing_type: line.billingType,
180
+ quantity: line.quantity,
181
+ amount: line.totalNetAmount
182
+ });
183
+ }
184
+ }
185
+ const closingBalance = subMoney(
186
+ subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),
187
+ totalRecurring
188
+ );
189
+ const crossingEvents = recurringBalanceBefore !== null && recurringBalanceAfter !== null ? buildBalanceCrossingEvents({
190
+ scope: {
191
+ billAccountId: account.id,
192
+ tenantId: account.tenantId,
193
+ organizationId: account.organizationId,
194
+ currencyCode: account.currencyCode
195
+ },
196
+ balanceBefore: recurringBalanceBefore,
197
+ balanceAfter: recurringBalanceAfter,
198
+ threshold: opts.threshold,
199
+ creditLimit: opts.creditLimit,
200
+ nearLimitBuffer: opts.nearLimitBuffer
201
+ }) : [];
202
+ let statementId = null;
203
+ if (opts.mode === "real" && opts.statementEnabled) {
204
+ const now = /* @__PURE__ */ new Date();
205
+ const statement = em.create(BillingStatement, {
206
+ organizationId: account.organizationId,
207
+ tenantId: account.tenantId,
208
+ billAccountId: account.id,
209
+ billRunId: billRun.id,
210
+ periodStart: period.periodStart,
211
+ periodEnd: period.periodEnd,
212
+ currencyCode: account.currencyCode,
213
+ openingBalance,
214
+ totalTopups,
215
+ totalUsage,
216
+ totalRecurring,
217
+ totalAdjustments,
218
+ closingBalance,
219
+ debitBreakdown: { items: breakdown },
220
+ status: "generated",
221
+ createdAt: now,
222
+ updatedAt: now
223
+ });
224
+ em.persist(statement);
225
+ await em.flush();
226
+ statementId = statement.id;
227
+ if (chargeTransactionIds.length > 0) {
228
+ await em.nativeUpdate(
229
+ BillingAccountTransaction,
230
+ { id: { $in: chargeTransactionIds } },
231
+ { statementId }
232
+ );
233
+ }
234
+ }
235
+ return {
236
+ statementId,
237
+ openingBalance,
238
+ totalTopups,
239
+ totalUsage,
240
+ totalRecurring,
241
+ totalAdjustments,
242
+ closingBalance,
243
+ crossingEvents
244
+ };
245
+ }
246
+ async function resolveOpeningBalance(em, account, period, previousClosing) {
247
+ if (previousClosing !== null) return previousClosing;
248
+ const prior = await em.findOne(
249
+ BillingStatement,
250
+ {
251
+ tenantId: account.tenantId,
252
+ organizationId: account.organizationId,
253
+ billAccountId: account.id,
254
+ periodEnd: { $lt: period.periodStart }
255
+ },
256
+ { orderBy: { periodEnd: "DESC" } }
257
+ );
258
+ if (prior) return fromUnits(toUnits(prior.closingBalance));
259
+ return sumAmountsBefore(em, account, period.periodStart);
260
+ }
261
+ async function sumAmountsBefore(em, account, before) {
262
+ const rows = await em.execute(
263
+ `SELECT COALESCE(SUM(amount), 0) AS total
264
+ FROM billing_account_transactions
265
+ WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
266
+ AND deleted_at IS NULL AND created_at < ?`,
267
+ [account.tenantId, account.organizationId, account.id, before]
268
+ );
269
+ return fromUnits(toUnits(rows?.[0]?.total ?? 0));
270
+ }
271
+ async function sumByTypeInWindow(em, account, type, from, to) {
272
+ const rows = await em.execute(
273
+ `SELECT COALESCE(SUM(amount), 0) AS total
274
+ FROM billing_account_transactions
275
+ WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?
276
+ AND deleted_at IS NULL AND type = ?
277
+ AND created_at >= ? AND created_at < ?`,
278
+ [account.tenantId, account.organizationId, account.id, type, from, to]
279
+ );
280
+ return fromUnits(toUnits(rows?.[0]?.total ?? 0));
281
+ }
282
+ export {
283
+ processPrepaidAccount
284
+ };
285
+ //# sourceMappingURL=prepaidPeriodClose.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/prepaidPeriodClose.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n BillingAccount,\n BillingAccountBalance,\n BillingAccountTransaction,\n BillingItem,\n BillingStatement,\n BillRun,\n} from '../data/entities'\nimport type { BillCycle } from '../data/validators'\nimport { advanceNextBillDate, addDays, deriveBillPeriod, isCycleDue, type BillPeriod } from './billPeriod'\nimport { applyBalanceMovement } from './balanceLedger'\nimport { resolveLowBalanceThreshold } from './balanceStatus'\nimport { buildInvoiceLinesFromItems } from './invoiceWriter'\nimport { selectItemsForPeriod, type SelectableItem } from './itemSelector'\nimport { absMoney, addMoney, fromUnits, subMoney, toUnits } from './money'\nimport {\n getLowBalanceThresholdDefault,\n getNearLimitBufferDefault,\n getStatementEnabled,\n} from './prepaidConfig'\nimport { buildBalanceCrossingEvents, type DomainEvent } from './balanceEvents'\n\n/**\n * Prepaid period close (SPEC-002 P3).\n *\n * A prepaid account never gets a payable draft invoice. Instead the Bill Run,\n * for a due prepaid account:\n * 1. charges its `recurring` / `one_time` items to the **balance** (atomic\n * debit + transaction, deterministic `source_ref =\n * recurring-{itemId}-{periodEndISO}`, then `billed_to_date = period_end`),\n * 2. builds a non-fiscal **BillingStatement** (opening, topups, usage,\n * recurring, closing + per-item breakdown).\n *\n * Metered usage was already rated + debited in real time during the period \u2014\n * it is NOT re-rated here; the statement only summarizes it.\n *\n * Three anti-duplicate guards: the statement unique\n * `(bill_account_id, period_start, period_end)`, the per-item `billed_to_date`,\n * and the deterministic charge `source_ref`.\n */\n\nexport type PrepaidCloseMode = 'real' | 'test' | 'dry'\n\nexport type PrepaidAccountWarnings = {\n // Reserved for future per-close warnings (e.g. negative-balance close).\n closed_negative?: boolean\n}\n\nexport type PrepaidAccountResult =\n | {\n status: 'success' | 'success_with_warnings' | 'skipped_existing_statement'\n warnings: PrepaidAccountWarnings | null\n statementId: string | null\n cyclesEmitted: number\n statementEvents: DomainEvent[]\n }\n | { status: 'failed'; errorMessage: string }\n\nconst SCHEDULED_CYCLE_LIMIT = 1\nconst CATCH_UP_HARD_CAP = 60\n\nexport type ProcessPrepaidParams = {\n asOfDate: Date\n mode: PrepaidCloseMode\n triggeredBy: 'schedule' | 'manual'\n catchUp?: boolean\n}\n\n/**\n * Multi-cycle prepaid account processing (mirrors the postpaid engine's\n * catch-up loop). One statement + one set of recurring debits per missed\n * period \u2014 never combined.\n */\nexport async function processPrepaidAccount(\n em: EntityManager,\n account: BillingAccount,\n billRun: BillRun,\n params: ProcessPrepaidParams,\n): Promise<PrepaidAccountResult> {\n const maxCycles =\n params.triggeredBy === 'schedule' || !params.catchUp\n ? SCHEDULED_CYCLE_LIMIT\n : CATCH_UP_HARD_CAP\n\n const statementEnabled = await getStatementEnabled(em)\n // Resolve the crossing thresholds once \u2014 they don't change during the run.\n const balanceRow = await em.findOne(BillingAccountBalance, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n })\n const threshold = resolveLowBalanceThreshold(\n balanceRow?.lowBalanceThreshold ?? null,\n await getLowBalanceThresholdDefault(em),\n )\n const nearLimitBuffer = await getNearLimitBufferDefault(em)\n const creditLimit = account.creditLimit ?? '0'\n\n let cyclesEmitted = 0\n let lastStatementId: string | null = null\n const statementEvents: DomainEvent[] = []\n // Chain opening \u2192 closing across cycles in a catch-up run. Null on the first\n // cycle \u2192 closePrepaidPeriod resolves it from the prior statement / ledger.\n let previousClosing: string | null = null\n\n while (cyclesEmitted < maxCycles && isCycleDue(account.nextBillDate, params.asOfDate)) {\n const period = deriveBillPeriod(account.nextBillDate, account.billCycle as BillCycle)\n\n // Statement anti-duplicate (only when we would actually persist).\n if (params.mode !== 'dry') {\n const existing = await em.findOne(BillingStatement, {\n tenantId: account.tenantId,\n billAccountId: account.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n })\n if (existing) {\n return {\n status: 'skipped_existing_statement',\n warnings: null,\n statementId: existing.id,\n cyclesEmitted,\n statementEvents,\n }\n }\n }\n\n const cycle = await closePrepaidPeriod(em, account, period, billRun, {\n mode: params.mode,\n statementEnabled,\n previousClosing,\n threshold,\n nearLimitBuffer,\n creditLimit,\n })\n previousClosing = cycle.closingBalance\n // Recurring charges move the balance too, so they can trip the same\n // low/exhausted/over_limit crossings as a usage debit (SPEC-002). These\n // are accumulated and emitted by the engine AFTER the run commits.\n statementEvents.push(...cycle.crossingEvents)\n\n // A negative closing balance is NORMAL for prepaid (the customer overspent;\n // it's cleared by the next top-up), so it is not flagged as a warning.\n if (cycle.statementId) {\n lastStatementId = cycle.statementId\n statementEvents.push({\n id: 'billing.statement.generated',\n payload: {\n statementId: cycle.statementId,\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billRunId: billRun.id,\n periodStart: period.periodStart.toISOString().slice(0, 10),\n periodEnd: period.periodEnd.toISOString().slice(0, 10),\n currencyCode: account.currencyCode,\n openingBalance: cycle.openingBalance,\n closingBalance: cycle.closingBalance,\n totalTopups: cycle.totalTopups,\n totalUsage: cycle.totalUsage,\n totalRecurring: cycle.totalRecurring,\n totalAdjustments: cycle.totalAdjustments,\n },\n })\n }\n\n if (params.mode === 'real') {\n account.nextBillDate = advanceNextBillDate(\n account.nextBillDate,\n account.billCycle as BillCycle,\n )\n account.lastBillDate = period.periodEnd\n account.updatedAt = new Date()\n await em.flush()\n } else {\n // test / dry: do not advance state; one iteration only.\n cyclesEmitted += 1\n break\n }\n cyclesEmitted += 1\n }\n\n return {\n status: 'success',\n warnings: null,\n statementId: lastStatementId,\n cyclesEmitted,\n statementEvents,\n }\n}\n\ntype ClosePeriodResult = {\n statementId: string | null\n openingBalance: string\n totalTopups: string\n totalUsage: string\n totalRecurring: string\n totalAdjustments: string\n closingBalance: string\n crossingEvents: DomainEvent[]\n}\n\ntype DebitBreakdownEntry = {\n billing_item_id: string\n billing_type: string\n uom_code?: string\n quantity: string\n amount: string\n}\n\nasync function closePrepaidPeriod(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n billRun: BillRun,\n opts: {\n mode: PrepaidCloseMode\n statementEnabled: boolean\n previousClosing: string | null\n threshold: string\n nearLimitBuffer: string\n creditLimit: string\n },\n): Promise<ClosePeriodResult> {\n const periodEndISO = period.periodEnd.toISOString().slice(0, 10)\n\n // Recurring + one_time items eligible for this period (usage excluded by the\n // selector \u2014 it was rated in real time).\n const items = await em.find(BillingItem, {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n deletedAt: null,\n } as never)\n const { included } = selectItemsForPeriod(items as unknown as SelectableItem[], period)\n const billableItems = included as unknown as BillingItem[]\n const lines = buildInvoiceLinesFromItems(billableItems)\n\n // Window bounds for categorized sums (topups / usage already in the ledger).\n const windowStart = period.periodStart\n const windowEnd = addDays(period.periodEnd, 1) // exclusive upper bound\n\n // Opening chains from the prior statement's closing \u2014 NOT a created_at sum.\n // Recurring debits are stamped at run time (after period_end), so a\n // `SUM(amount WHERE created_at < period_start)` would drop the prior period's\n // recurring charge and break the chain. Resolution order:\n // 1. previous cycle's closing (catch-up, in this run),\n // 2. the prior persisted statement's closing (month-over-month),\n // 3. the ledger balance before the first-ever statement window.\n const openingBalance = await resolveOpeningBalance(em, account, period, opts.previousClosing)\n const totalTopups = await sumByTypeInWindow(em, account, 'topup', windowStart, windowEnd)\n const totalUsage = absMoney(\n await sumByTypeInWindow(em, account, 'usage', windowStart, windowEnd),\n )\n // Manual adjustments / reversals that landed in the window \u2014 SIGNED (a credit\n // is +, a debit \u2212). Kept as a first-class term so the statement is\n // self-consistent and the opening\u2192closing chain never drifts.\n const totalAdjustments = addMoney(\n await sumByTypeInWindow(em, account, 'adjustment', windowStart, windowEnd),\n await sumByTypeInWindow(em, account, 'reversal', windowStart, windowEnd),\n )\n\n let totalRecurring = '0.0000'\n const breakdown: DebitBreakdownEntry[] = []\n const chargeTransactionIds: string[] = []\n // The balance just before / after the recurring batch \u2014 for crossing events.\n let recurringBalanceBefore: string | null = null\n let recurringBalanceAfter: string | null = null\n\n if (opts.mode === 'real') {\n const now = new Date()\n for (const line of lines) {\n const movement = await applyBalanceMovement(em, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n currencyCode: account.currencyCode,\n type: line.billingType === 'one_time' ? 'one_time' : 'recurring',\n amount: fromUnits(-toUnits(line.totalNetAmount)),\n description: line.description,\n billingItemId: line.billingItemId,\n sourceRef: `recurring-${line.billingItemId}-${periodEndISO}`,\n metadata: {\n bill_period_start: period.periodStart.toISOString().slice(0, 10),\n bill_period_end: periodEndISO,\n bill_run_id: billRun.id,\n },\n })\n if (recurringBalanceBefore === null) recurringBalanceBefore = movement.balanceBefore\n recurringBalanceAfter = movement.balanceAfter\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n if (!movement.deduplicated) chargeTransactionIds.push(movement.transaction.id)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n const item = billableItems.find((it) => it.id === line.billingItemId)\n if (item) {\n item.billedToDate = period.periodEnd\n item.updatedAt = now\n }\n }\n await em.flush()\n } else {\n // test / dry: compute the would-be charges, persist nothing.\n for (const line of lines) {\n totalRecurring = addMoney(totalRecurring, line.totalNetAmount)\n breakdown.push({\n billing_item_id: line.billingItemId,\n billing_type: line.billingType,\n quantity: line.quantity,\n amount: line.totalNetAmount,\n })\n }\n }\n\n // closing = opening + topups + adjustments \u2212 usage \u2212 recurring.\n const closingBalance = subMoney(\n subMoney(addMoney(addMoney(openingBalance, totalTopups), totalAdjustments), totalUsage),\n totalRecurring,\n )\n\n // The recurring batch can push the balance low / exhausted / over-limit \u2014\n // emit those crossings (real mode only; dry/test never move the balance).\n const crossingEvents =\n recurringBalanceBefore !== null && recurringBalanceAfter !== null\n ? buildBalanceCrossingEvents({\n scope: {\n billAccountId: account.id,\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n currencyCode: account.currencyCode,\n },\n balanceBefore: recurringBalanceBefore,\n balanceAfter: recurringBalanceAfter,\n threshold: opts.threshold,\n creditLimit: opts.creditLimit,\n nearLimitBuffer: opts.nearLimitBuffer,\n })\n : []\n\n let statementId: string | null = null\n if (opts.mode === 'real' && opts.statementEnabled) {\n const now = new Date()\n const statement = em.create(BillingStatement, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n billRunId: billRun.id,\n periodStart: period.periodStart,\n periodEnd: period.periodEnd,\n currencyCode: account.currencyCode,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n debitBreakdown: { items: breakdown },\n status: 'generated',\n createdAt: now,\n updatedAt: now,\n })\n em.persist(statement)\n await em.flush()\n statementId = statement.id\n\n // Backfill statement_id on the charge transactions for attribution.\n if (chargeTransactionIds.length > 0) {\n await em.nativeUpdate(\n BillingAccountTransaction,\n { id: { $in: chargeTransactionIds } } as never,\n { statementId } as never,\n )\n }\n }\n\n return {\n statementId,\n openingBalance,\n totalTopups,\n totalUsage,\n totalRecurring,\n totalAdjustments,\n closingBalance,\n crossingEvents,\n }\n}\n\n/**\n * Resolve a period's opening balance by chaining from the prior statement's\n * closing (the spec's \"chains cleanly\"), with a ledger fallback for the very\n * first statement. See the call site for why a created_at sum alone is wrong.\n */\nasync function resolveOpeningBalance(\n em: EntityManager,\n account: BillingAccount,\n period: BillPeriod,\n previousClosing: string | null,\n): Promise<string> {\n if (previousClosing !== null) return previousClosing\n const prior = await em.findOne(\n BillingStatement,\n {\n tenantId: account.tenantId,\n organizationId: account.organizationId,\n billAccountId: account.id,\n periodEnd: { $lt: period.periodStart },\n } as never,\n { orderBy: { periodEnd: 'DESC' } },\n )\n if (prior) return fromUnits(toUnits(prior.closingBalance))\n // First-ever statement: the ledger balance just before the period window\n // (only top-ups/usage exist before the first close, so created_at is safe).\n return sumAmountsBefore(em, account, period.periodStart)\n}\n\n/** Balance at `before` = SUM(amount) of transactions created strictly before it. */\nasync function sumAmountsBefore(\n em: EntityManager,\n account: BillingAccount,\n before: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, before],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n\n/** SUM(amount) for one transaction type within [from, to). */\nasync function sumByTypeInWindow(\n em: EntityManager,\n account: BillingAccount,\n type: string,\n from: Date,\n to: Date,\n): Promise<string> {\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(amount), 0) AS total\n FROM billing_account_transactions\n WHERE tenant_id = ? AND organization_id = ? AND bill_account_id = ?\n AND deleted_at IS NULL AND type = ?\n AND created_at >= ? AND created_at < ?`,\n [account.tenantId, account.organizationId, account.id, type, from, to],\n )) as unknown as Array<{ total: string }>\n return fromUnits(toUnits(rows?.[0]?.total ?? 0))\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,62 @@
1
+ import { BillingTopup } from "../data/entities.js";
2
+ import { applyBalanceMovement } from "./balanceLedger.js";
3
+ import { createTopupReceipt } from "./topupReceipt.js";
4
+ async function captureTopup(em, deps, params) {
5
+ const { topup, account } = params;
6
+ if (topup.status === "captured") {
7
+ return { status: "already_captured" };
8
+ }
9
+ return em.transactional(async (tem) => {
10
+ const movement = await applyBalanceMovement(tem, {
11
+ organizationId: account.organizationId,
12
+ tenantId: account.tenantId,
13
+ billAccountId: account.id,
14
+ currencyCode: account.currencyCode,
15
+ type: "topup",
16
+ amount: topup.amount,
17
+ description: `Top-up \u2014 ${topup.providerKey}`,
18
+ topupId: topup.id,
19
+ sourceRef: `topup-${topup.paymentId}`,
20
+ metadata: {
21
+ provider_key: topup.providerKey,
22
+ payment_id: topup.paymentId,
23
+ gateway_transaction_id: params.gatewayTransactionId ?? topup.gatewayTransactionId ?? null
24
+ }
25
+ });
26
+ if (movement.deduplicated) {
27
+ const prior = await tem.findOne(BillingTopup, { id: topup.id });
28
+ return {
29
+ status: "already_captured",
30
+ balanceAfter: movement.balanceAfter,
31
+ transactionId: prior?.transactionId ?? movement.transaction.id,
32
+ receiptInvoiceId: prior?.receiptInvoiceId ?? void 0
33
+ };
34
+ }
35
+ const receipt = await createTopupReceipt(tem, deps, {
36
+ account,
37
+ topupId: topup.id,
38
+ paymentId: topup.paymentId,
39
+ amount: topup.amount,
40
+ taxRate: params.taxRate
41
+ });
42
+ const managed = await tem.findOne(BillingTopup, { id: topup.id });
43
+ const target = managed ?? topup;
44
+ target.status = "captured";
45
+ target.transactionId = movement.transaction.id;
46
+ target.receiptInvoiceId = receipt.invoiceId;
47
+ target.updatedAt = /* @__PURE__ */ new Date();
48
+ await tem.flush();
49
+ return {
50
+ status: "captured",
51
+ balanceAfter: movement.balanceAfter,
52
+ transactionId: movement.transaction.id,
53
+ receiptInvoiceId: receipt.invoiceId,
54
+ invoiceNumber: receipt.invoiceNumber,
55
+ grossAmount: receipt.grossAmount
56
+ };
57
+ });
58
+ }
59
+ export {
60
+ captureTopup
61
+ };
62
+ //# sourceMappingURL=topupCapture.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/topupCapture.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingTopup } from '../data/entities'\nimport { applyBalanceMovement } from './balanceLedger'\nimport { createTopupReceipt, type TopupReceiptDeps } from './topupReceipt'\n\n/**\n * Idempotent top-up capture (SPEC-002 P2).\n *\n * On `payment_gateways.payment.captured` for a top-up, in ONE DB transaction:\n * - credit the balance (atomic) + append a `type='topup'` transaction with a\n * deterministic `source_ref = topup-{paymentId}` (so a re-fired capture can\n * never double-credit),\n * - create the posted+paid VAT receipt in core/sales,\n * - transition the BillingTopup \u2192 captured, backfilling `transaction_id` +\n * `receipt_invoice_id`.\n *\n * Re-firing on an already-captured top-up is a no-op (the gateway delivers\n * webhooks at-least-once).\n */\n\nexport type CaptureTopupAccount = {\n id: string\n organizationId: string\n tenantId: string\n currencyCode: string\n}\n\nexport type CaptureTopupParams = {\n topup: BillingTopup\n account: CaptureTopupAccount\n /** VAT percent applied to the receipt. */\n taxRate: number\n /** Provider transaction id for the transaction metadata, if known. */\n gatewayTransactionId?: string | null\n}\n\nexport type CaptureTopupResult = {\n status: 'captured' | 'already_captured'\n balanceAfter?: string\n transactionId?: string\n receiptInvoiceId?: string\n invoiceNumber?: string\n grossAmount?: string\n}\n\nexport async function captureTopup(\n em: EntityManager,\n deps: TopupReceiptDeps,\n params: CaptureTopupParams,\n): Promise<CaptureTopupResult> {\n const { topup, account } = params\n\n if (topup.status === 'captured') {\n return { status: 'already_captured' }\n }\n\n return em.transactional(async (tem) => {\n // 1. Atomic credit + paired transaction (idempotent on source_ref).\n const movement = await applyBalanceMovement(tem as EntityManager, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n billAccountId: account.id,\n currencyCode: account.currencyCode,\n type: 'topup',\n amount: topup.amount,\n description: `Top-up \u2014 ${topup.providerKey}`,\n topupId: topup.id,\n sourceRef: `topup-${topup.paymentId}`,\n metadata: {\n provider_key: topup.providerKey,\n payment_id: topup.paymentId,\n gateway_transaction_id: params.gatewayTransactionId ?? topup.gatewayTransactionId ?? null,\n },\n })\n\n // A deduplicated credit means a PRIOR capture already committed (the\n // gateway delivers `payment.captured` at-least-once, and the stale\n // `status` check above can race a concurrent capture). Do NOT issue a\n // second fiscal receipt \u2014 return the existing capture's links.\n if (movement.deduplicated) {\n const prior = await tem.findOne(BillingTopup, { id: topup.id })\n return {\n status: 'already_captured' as const,\n balanceAfter: movement.balanceAfter,\n transactionId: prior?.transactionId ?? movement.transaction.id,\n receiptInvoiceId: prior?.receiptInvoiceId ?? undefined,\n }\n }\n\n // 2. Fiscal VAT receipt (posted+paid).\n const receipt = await createTopupReceipt(tem as EntityManager, deps, {\n account,\n topupId: topup.id,\n paymentId: topup.paymentId,\n amount: topup.amount,\n taxRate: params.taxRate,\n })\n\n // 3. Transition the top-up.\n const managed = await tem.findOne(BillingTopup, { id: topup.id })\n const target = managed ?? topup\n target.status = 'captured'\n target.transactionId = movement.transaction.id\n target.receiptInvoiceId = receipt.invoiceId\n target.updatedAt = new Date()\n await tem.flush()\n\n return {\n status: 'captured' as const,\n balanceAfter: movement.balanceAfter,\n transactionId: movement.transaction.id,\n receiptInvoiceId: receipt.invoiceId,\n invoiceNumber: receipt.invoiceNumber,\n grossAmount: receipt.grossAmount,\n }\n })\n}\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AACrC,SAAS,0BAAiD;AA0C1D,eAAsB,aACpB,IACA,MACA,QAC6B;AAC7B,QAAM,EAAE,OAAO,QAAQ,IAAI;AAE3B,MAAI,MAAM,WAAW,YAAY;AAC/B,WAAO,EAAE,QAAQ,mBAAmB;AAAA,EACtC;AAEA,SAAO,GAAG,cAAc,OAAO,QAAQ;AAErC,UAAM,WAAW,MAAM,qBAAqB,KAAsB;AAAA,MAChE,gBAAgB,QAAQ;AAAA,MACxB,UAAU,QAAQ;AAAA,MAClB,eAAe,QAAQ;AAAA,MACvB,cAAc,QAAQ;AAAA,MACtB,MAAM;AAAA,MACN,QAAQ,MAAM;AAAA,MACd,aAAa,iBAAY,MAAM,WAAW;AAAA,MAC1C,SAAS,MAAM;AAAA,MACf,WAAW,SAAS,MAAM,SAAS;AAAA,MACnC,UAAU;AAAA,QACR,cAAc,MAAM;AAAA,QACpB,YAAY,MAAM;AAAA,QAClB,wBAAwB,OAAO,wBAAwB,MAAM,wBAAwB;AAAA,MACvF;AAAA,IACF,CAAC;AAMD,QAAI,SAAS,cAAc;AACzB,YAAM,QAAQ,MAAM,IAAI,QAAQ,cAAc,EAAE,IAAI,MAAM,GAAG,CAAC;AAC9D,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,cAAc,SAAS;AAAA,QACvB,eAAe,OAAO,iBAAiB,SAAS,YAAY;AAAA,QAC5D,kBAAkB,OAAO,oBAAoB;AAAA,MAC/C;AAAA,IACF;AAGA,UAAM,UAAU,MAAM,mBAAmB,KAAsB,MAAM;AAAA,MACnE;AAAA,MACA,SAAS,MAAM;AAAA,MACf,WAAW,MAAM;AAAA,MACjB,QAAQ,MAAM;AAAA,MACd,SAAS,OAAO;AAAA,IAClB,CAAC;AAGD,UAAM,UAAU,MAAM,IAAI,QAAQ,cAAc,EAAE,IAAI,MAAM,GAAG,CAAC;AAChE,UAAM,SAAS,WAAW;AAC1B,WAAO,SAAS;AAChB,WAAO,gBAAgB,SAAS,YAAY;AAC5C,WAAO,mBAAmB,QAAQ;AAClC,WAAO,YAAY,oBAAI,KAAK;AAC5B,UAAM,IAAI,MAAM;AAEhB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,cAAc,SAAS;AAAA,MACvB,eAAe,SAAS,YAAY;AAAA,MACpC,kBAAkB,QAAQ;AAAA,MAC1B,eAAe,QAAQ;AAAA,MACvB,aAAa,QAAQ;AAAA,IACvB;AAAA,EACF,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ SalesInvoice,
3
+ SalesInvoiceLine
4
+ } from "@open-mercato/core/modules/sales/data/entities";
5
+ import { resolveInvoiceStatusEntryId } from "./invoiceStatus.js";
6
+ function fmt4(value) {
7
+ return (Number.isFinite(value) ? value : 0).toFixed(4);
8
+ }
9
+ async function createTopupReceipt(em, deps, params) {
10
+ const { account } = params;
11
+ const tax = await deps.taxService.calculateUnitAmounts({
12
+ amount: Number.parseFloat(params.amount),
13
+ mode: "gross",
14
+ organizationId: account.organizationId,
15
+ tenantId: account.tenantId,
16
+ taxRate: params.taxRate
17
+ });
18
+ const net = fmt4(tax.netAmount);
19
+ const gross = fmt4(tax.grossAmount);
20
+ const taxAmount = fmt4(tax.taxAmount);
21
+ const effectiveRate = tax.taxRate ?? 0;
22
+ const generated = await deps.numberGenerator.generate({
23
+ kind: "invoice",
24
+ organizationId: account.organizationId,
25
+ tenantId: account.tenantId
26
+ });
27
+ const paidStatusEntryId = await resolveInvoiceStatusEntryId(
28
+ em,
29
+ account.tenantId,
30
+ account.organizationId,
31
+ "paid"
32
+ );
33
+ const now = /* @__PURE__ */ new Date();
34
+ const invoice = em.create(SalesInvoice, {
35
+ organizationId: account.organizationId,
36
+ tenantId: account.tenantId,
37
+ invoiceNumber: generated.number,
38
+ statusEntryId: paidStatusEntryId,
39
+ status: "paid",
40
+ issueDate: now,
41
+ currencyCode: account.currencyCode,
42
+ subtotalNetAmount: net,
43
+ subtotalGrossAmount: gross,
44
+ discountTotalAmount: "0",
45
+ taxTotalAmount: taxAmount,
46
+ grandTotalNetAmount: net,
47
+ grandTotalGrossAmount: gross,
48
+ paidTotalAmount: gross,
49
+ outstandingAmount: "0",
50
+ metadata: {
51
+ kind: "prepaid_topup",
52
+ bill_account_id: account.id,
53
+ topup_id: params.topupId,
54
+ payment_id: params.paymentId
55
+ },
56
+ createdAt: now,
57
+ updatedAt: now
58
+ });
59
+ em.persist(invoice);
60
+ const line = em.create(SalesInvoiceLine, {
61
+ invoice,
62
+ organizationId: account.organizationId,
63
+ tenantId: account.tenantId,
64
+ lineNumber: 1,
65
+ kind: "service",
66
+ description: "Account top-up",
67
+ quantity: "1",
68
+ normalizedQuantity: "1",
69
+ currencyCode: account.currencyCode,
70
+ unitPriceNet: net,
71
+ unitPriceGross: gross,
72
+ discountAmount: "0",
73
+ discountPercent: "0",
74
+ taxRate: fmt4(effectiveRate),
75
+ taxAmount,
76
+ totalNetAmount: net,
77
+ totalGrossAmount: gross,
78
+ metadata: {
79
+ kind: "prepaid_topup",
80
+ topup_id: params.topupId
81
+ }
82
+ });
83
+ em.persist(line);
84
+ await em.flush();
85
+ return {
86
+ invoiceId: invoice.id,
87
+ invoiceNumber: generated.number,
88
+ netAmount: net,
89
+ taxAmount,
90
+ grossAmount: gross,
91
+ taxRate: effectiveRate
92
+ };
93
+ }
94
+ export {
95
+ createTopupReceipt
96
+ };
97
+ //# sourceMappingURL=topupReceipt.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/topupReceipt.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n SalesInvoice,\n SalesInvoiceLine,\n} from '@open-mercato/core/modules/sales/data/entities'\nimport type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'\nimport type { TaxCalculationService } from '@open-mercato/core/modules/sales/services/taxCalculationService'\nimport { resolveInvoiceStatusEntryId } from './invoiceStatus'\n\n/**\n * Creates the fiscal VAT receipt for a captured prepaid top-up (SPEC-002 P2).\n *\n * Deliberate divergence from the postpaid draft\u2192post gate: a captured top-up\n * is a completed, already-paid transaction with nothing to review, so the\n * receipt is created **posted + paid** in one shot (the gate exists for\n * outbound bills, not money already received \u2014 mirrors the xd-249 \"deviations\"\n * convention).\n *\n * **VAT at top-up.** The top-up `amount` is the GROSS the customer paid (= the\n * balance credited). The core tax service splits it into net + VAT at the\n * configured rate. Gap-free numbering reuses `salesDocumentNumberGenerator`.\n */\n\nexport type TopupReceiptParams = {\n account: {\n id: string\n organizationId: string\n tenantId: string\n currencyCode: string\n }\n topupId: string\n paymentId: string\n /** Gross amount paid (numeric(18,4) string). */\n amount: string\n /** VAT percent (e.g. 23). 0 = no VAT. */\n taxRate: number\n}\n\nexport type TopupReceiptDeps = {\n numberGenerator: Pick<SalesDocumentNumberGenerator, 'generate'>\n taxService: TaxCalculationService\n}\n\nexport type TopupReceipt = {\n invoiceId: string\n invoiceNumber: string\n netAmount: string\n taxAmount: string\n grossAmount: string\n taxRate: number\n}\n\nfunction fmt4(value: number): string {\n return (Number.isFinite(value) ? value : 0).toFixed(4)\n}\n\nexport async function createTopupReceipt(\n em: EntityManager,\n deps: TopupReceiptDeps,\n params: TopupReceiptParams,\n): Promise<TopupReceipt> {\n const { account } = params\n\n // VAT split \u2014 the top-up amount is GROSS (what was paid = balance credited).\n const tax = await deps.taxService.calculateUnitAmounts({\n amount: Number.parseFloat(params.amount),\n mode: 'gross',\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n taxRate: params.taxRate,\n })\n const net = fmt4(tax.netAmount)\n const gross = fmt4(tax.grossAmount)\n const taxAmount = fmt4(tax.taxAmount)\n const effectiveRate = tax.taxRate ?? 0\n\n const generated = await deps.numberGenerator.generate({\n kind: 'invoice',\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n })\n\n // Posted+paid in one shot \u2014 resolve the `paid` status entry directly.\n const paidStatusEntryId = await resolveInvoiceStatusEntryId(\n em,\n account.tenantId,\n account.organizationId,\n 'paid',\n )\n\n const now = new Date()\n const invoice = em.create(SalesInvoice, {\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n invoiceNumber: generated.number,\n statusEntryId: paidStatusEntryId,\n status: 'paid',\n issueDate: now,\n currencyCode: account.currencyCode,\n subtotalNetAmount: net,\n subtotalGrossAmount: gross,\n discountTotalAmount: '0',\n taxTotalAmount: taxAmount,\n grandTotalNetAmount: net,\n grandTotalGrossAmount: gross,\n paidTotalAmount: gross,\n outstandingAmount: '0',\n metadata: {\n kind: 'prepaid_topup',\n bill_account_id: account.id,\n topup_id: params.topupId,\n payment_id: params.paymentId,\n },\n createdAt: now,\n updatedAt: now,\n })\n em.persist(invoice)\n\n const line = em.create(SalesInvoiceLine, {\n invoice,\n organizationId: account.organizationId,\n tenantId: account.tenantId,\n lineNumber: 1,\n kind: 'service',\n description: 'Account top-up',\n quantity: '1',\n normalizedQuantity: '1',\n currencyCode: account.currencyCode,\n unitPriceNet: net,\n unitPriceGross: gross,\n discountAmount: '0',\n discountPercent: '0',\n taxRate: fmt4(effectiveRate),\n taxAmount,\n totalNetAmount: net,\n totalGrossAmount: gross,\n metadata: {\n kind: 'prepaid_topup',\n topup_id: params.topupId,\n },\n })\n em.persist(line)\n await em.flush()\n\n return {\n invoiceId: invoice.id,\n invoiceNumber: generated.number,\n netAmount: net,\n taxAmount,\n grossAmount: gross,\n taxRate: effectiveRate,\n }\n}\n"],
5
+ "mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAGP,SAAS,mCAAmC;AA6C5C,SAAS,KAAK,OAAuB;AACnC,UAAQ,OAAO,SAAS,KAAK,IAAI,QAAQ,GAAG,QAAQ,CAAC;AACvD;AAEA,eAAsB,mBACpB,IACA,MACA,QACuB;AACvB,QAAM,EAAE,QAAQ,IAAI;AAGpB,QAAM,MAAM,MAAM,KAAK,WAAW,qBAAqB;AAAA,IACrD,QAAQ,OAAO,WAAW,OAAO,MAAM;AAAA,IACvC,MAAM;AAAA,IACN,gBAAgB,QAAQ;AAAA,IACxB,UAAU,QAAQ;AAAA,IAClB,SAAS,OAAO;AAAA,EAClB,CAAC;AACD,QAAM,MAAM,KAAK,IAAI,SAAS;AAC9B,QAAM,QAAQ,KAAK,IAAI,WAAW;AAClC,QAAM,YAAY,KAAK,IAAI,SAAS;AACpC,QAAM,gBAAgB,IAAI,WAAW;AAErC,QAAM,YAAY,MAAM,KAAK,gBAAgB,SAAS;AAAA,IACpD,MAAM;AAAA,IACN,gBAAgB,QAAQ;AAAA,IACxB,UAAU,QAAQ;AAAA,EACpB,CAAC;AAGD,QAAM,oBAAoB,MAAM;AAAA,IAC9B;AAAA,IACA,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR;AAAA,EACF;AAEA,QAAM,MAAM,oBAAI,KAAK;AACrB,QAAM,UAAU,GAAG,OAAO,cAAc;AAAA,IACtC,gBAAgB,QAAQ;AAAA,IACxB,UAAU,QAAQ;AAAA,IAClB,eAAe,UAAU;AAAA,IACzB,eAAe;AAAA,IACf,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,cAAc,QAAQ;AAAA,IACtB,mBAAmB;AAAA,IACnB,qBAAqB;AAAA,IACrB,qBAAqB;AAAA,IACrB,gBAAgB;AAAA,IAChB,qBAAqB;AAAA,IACrB,uBAAuB;AAAA,IACvB,iBAAiB;AAAA,IACjB,mBAAmB;AAAA,IACnB,UAAU;AAAA,MACR,MAAM;AAAA,MACN,iBAAiB,QAAQ;AAAA,MACzB,UAAU,OAAO;AAAA,MACjB,YAAY,OAAO;AAAA,IACrB;AAAA,IACA,WAAW;AAAA,IACX,WAAW;AAAA,EACb,CAAC;AACD,KAAG,QAAQ,OAAO;AAElB,QAAM,OAAO,GAAG,OAAO,kBAAkB;AAAA,IACvC;AAAA,IACA,gBAAgB,QAAQ;AAAA,IACxB,UAAU,QAAQ;AAAA,IAClB,YAAY;AAAA,IACZ,MAAM;AAAA,IACN,aAAa;AAAA,IACb,UAAU;AAAA,IACV,oBAAoB;AAAA,IACpB,cAAc,QAAQ;AAAA,IACtB,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,gBAAgB;AAAA,IAChB,iBAAiB;AAAA,IACjB,SAAS,KAAK,aAAa;AAAA,IAC3B;AAAA,IACA,gBAAgB;AAAA,IAChB,kBAAkB;AAAA,IAClB,UAAU;AAAA,MACR,MAAM;AAAA,MACN,UAAU,OAAO;AAAA,IACnB;AAAA,EACF,CAAC;AACD,KAAG,QAAQ,IAAI;AACf,QAAM,GAAG,MAAM;AAEf,SAAO;AAAA,IACL,WAAW,QAAQ;AAAA,IACnB,eAAe,UAAU;AAAA,IACzB,WAAW;AAAA,IACX;AAAA,IACA,aAAa;AAAA,IACb,SAAS;AAAA,EACX;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ import { BillingTopup } from "../data/entities.js";
2
+ async function transitionTopupByPaymentId(em, scope, toStatus) {
3
+ const topup = await em.findOne(BillingTopup, {
4
+ paymentId: scope.paymentId,
5
+ tenantId: scope.tenantId,
6
+ organizationId: scope.organizationId,
7
+ deletedAt: null
8
+ });
9
+ if (!topup) return null;
10
+ if (topup.status === "captured" || topup.status === toStatus) return topup;
11
+ topup.status = toStatus;
12
+ topup.updatedAt = /* @__PURE__ */ new Date();
13
+ await em.flush();
14
+ return topup;
15
+ }
16
+ export {
17
+ transitionTopupByPaymentId
18
+ };
19
+ //# sourceMappingURL=topupTransition.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/lib/topupTransition.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { BillingTopup } from '../data/entities'\nimport type { TopupStatus } from '../data/validators'\n\n/**\n * Transition a registered top-up to a terminal non-captured status\n * (failed / cancelled / expired) in response to the sibling gateway events.\n *\n * Finding a BillingTopup by `payment_id` IS the guard: a non-top-up payment\n * (e.g. a postpaid invoice, whose paymentId is the invoice id) matches no\n * BillingTopup, so this is a silent no-op for everyone else. A captured\n * top-up is never downgraded.\n */\nexport async function transitionTopupByPaymentId(\n em: EntityManager,\n scope: { paymentId: string; tenantId: string; organizationId: string },\n toStatus: Extract<TopupStatus, 'failed' | 'cancelled' | 'expired'>,\n): Promise<BillingTopup | null> {\n const topup = await em.findOne(BillingTopup, {\n paymentId: scope.paymentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n })\n if (!topup) return null\n if (topup.status === 'captured' || topup.status === toStatus) return topup\n topup.status = toStatus\n topup.updatedAt = new Date()\n await em.flush()\n return topup\n}\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAY7B,eAAsB,2BACpB,IACA,OACA,UAC8B;AAC9B,QAAM,QAAQ,MAAM,GAAG,QAAQ,cAAc;AAAA,IAC3C,WAAW,MAAM;AAAA,IACjB,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,WAAW;AAAA,EACb,CAAC;AACD,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,MAAM,WAAW,cAAc,MAAM,WAAW,SAAU,QAAO;AACrE,QAAM,SAAS;AACf,QAAM,YAAY,oBAAI,KAAK;AAC3B,QAAM,GAAG,MAAM;AACf,SAAO;AACT;",
6
+ "names": []
7
+ }