@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,13 +8,15 @@ import {
8
8
  requireId
9
9
  } from "@open-mercato/shared/lib/commands/helpers";
10
10
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
11
- import { BillingAccount } from "../data/entities.js";
11
+ import { BillingAccount, BillingAccountBalance } from "../data/entities.js";
12
12
  import { billingEntityIds } from "../data/entityIds.js";
13
13
  import {
14
14
  billingAccountCreateSchema,
15
15
  billingAccountDeleteSchema,
16
16
  billingAccountUpdateSchema
17
17
  } from "../data/validators.js";
18
+ import { ensureBalanceRow } from "../lib/balanceLedger.js";
19
+ import { formatMoney } from "../lib/money.js";
18
20
  const accountIndexer = { entityType: billingEntityIds.account };
19
21
  const accountEvents = { module: "billing", entity: "account", persistent: true };
20
22
  function getEm(ctx) {
@@ -31,6 +33,7 @@ const createAccountCommand = {
31
33
  ensureOrganizationScope(ctx, parsed.organizationId);
32
34
  const em = getEm(ctx);
33
35
  const now = /* @__PURE__ */ new Date();
36
+ const billingMode = parsed.billingMode ?? "postpaid";
34
37
  const entity = em.create(BillingAccount, {
35
38
  organizationId: parsed.organizationId,
36
39
  tenantId: parsed.tenantId,
@@ -45,12 +48,23 @@ const createAccountCommand = {
45
48
  invoiceAddress: parsed.invoiceAddress,
46
49
  nextBillDate: parsed.nextBillDate,
47
50
  lastBillDate: parsed.lastBillDate ?? null,
51
+ billingMode,
52
+ creditLimit: parsed.creditLimit !== void 0 ? formatMoney(parsed.creditLimit) : "0",
48
53
  isActive: parsed.isActive ?? true,
49
54
  createdAt: now,
50
55
  updatedAt: now
51
56
  });
52
57
  em.persist(entity);
53
58
  await em.flush();
59
+ if (billingMode === "prepaid") {
60
+ await ensureBalanceRow(em, {
61
+ organizationId: entity.organizationId,
62
+ tenantId: entity.tenantId,
63
+ billAccountId: entity.id,
64
+ currencyCode: entity.currencyCode,
65
+ lowBalanceThreshold: parsed.lowBalanceThreshold !== void 0 && parsed.lowBalanceThreshold !== null ? formatMoney(parsed.lowBalanceThreshold) : null
66
+ });
67
+ }
54
68
  await emitCrudSideEffects({
55
69
  dataEngine: getDataEngine(ctx),
56
70
  action: "created",
@@ -94,7 +108,18 @@ const updateAccountCommand = {
94
108
  if (parsed.nextBillDate !== void 0) record.nextBillDate = parsed.nextBillDate;
95
109
  if (parsed.lastBillDate !== void 0) record.lastBillDate = parsed.lastBillDate;
96
110
  if (parsed.isActive !== void 0) record.isActive = parsed.isActive;
111
+ if (parsed.creditLimit !== void 0) record.creditLimit = formatMoney(parsed.creditLimit);
97
112
  record.updatedAt = /* @__PURE__ */ new Date();
113
+ if (parsed.lowBalanceThreshold !== void 0 && record.billingMode === "prepaid") {
114
+ const balanceRow = await em.findOne(BillingAccountBalance, {
115
+ tenantId: parsed.tenantId,
116
+ billAccountId: record.id
117
+ });
118
+ if (balanceRow) {
119
+ balanceRow.lowBalanceThreshold = parsed.lowBalanceThreshold === null ? null : formatMoney(parsed.lowBalanceThreshold);
120
+ balanceRow.updatedAt = /* @__PURE__ */ new Date();
121
+ }
122
+ }
98
123
  await em.flush();
99
124
  await emitCrudSideEffects({
100
125
  dataEngine: getDataEngine(ctx),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/commands/accounts.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport {\n emitCrudSideEffects,\n requireId,\n} from '@open-mercato/shared/lib/commands/helpers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { BillingAccount } from '../data/entities'\nimport { billingEntityIds } from '../data/entityIds'\nimport {\n billingAccountCreateSchema,\n billingAccountDeleteSchema,\n billingAccountUpdateSchema,\n type BillingAccountCreateInput,\n type BillingAccountDeleteInput,\n type BillingAccountUpdateInput,\n} from '../data/validators'\n\n/**\n * BillingAccount writes \u2014 create / update / soft-delete.\n *\n * Phase 1 keeps the command surface lean \u2014 no undo / snapshot / audit\n * log. Phase 4 (admin UI + posting flow) revisits to add operator-driven\n * undo. The patterns to add:\n * - `prepare`: capture BEFORE snapshot for update / delete\n * - `captureAfter`: capture AFTER snapshot\n * - `buildLog`: emit a structured audit log entry\n * - `undo`: restore from snapshots + reverse side effects\n * See `node_modules/@open-mercato/core/src/modules/customers/commands/people.ts`\n * for the canonical full-feature shape.\n */\n\nconst accountIndexer = { entityType: billingEntityIds.account } as const\nconst accountEvents = { module: 'billing', entity: 'account', persistent: true } as const\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getDataEngine(ctx: CommandRuntimeContext): DataEngine {\n return ctx.container.resolve('dataEngine') as DataEngine\n}\n\n// \u2500\u2500\u2500 Create \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\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\nconst createAccountCommand: CommandHandler<BillingAccountCreateInput, { id: string }> = {\n id: 'billing.accounts.create',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const now = new Date()\n const entity = em.create(BillingAccount, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n customerId: parsed.customerId,\n name: parsed.name,\n currencyCode: parsed.currencyCode,\n billCycle: parsed.billCycle,\n billCycleAnchor: parsed.billCycleAnchor,\n invoiceEmail: parsed.invoiceEmail,\n invoiceLanguage: parsed.invoiceLanguage,\n taxId: parsed.taxId ?? null,\n invoiceAddress: parsed.invoiceAddress as Record<string, unknown>,\n nextBillDate: parsed.nextBillDate,\n lastBillDate: parsed.lastBillDate ?? null,\n isActive: parsed.isActive ?? true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'created',\n entity,\n identifiers: {\n id: entity.id,\n tenantId: entity.tenantId,\n organizationId: entity.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: entity.id }\n },\n}\n\n// \u2500\u2500\u2500 Update \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\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\nconst updateAccountCommand: CommandHandler<BillingAccountUpdateInput, { id: string }> = {\n id: 'billing.accounts.update',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountUpdateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingAccount, {\n id: parsed.id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n\n if (parsed.name !== undefined) record.name = parsed.name\n if (parsed.billCycle !== undefined) record.billCycle = parsed.billCycle\n if (parsed.billCycleAnchor !== undefined) record.billCycleAnchor = parsed.billCycleAnchor\n if (parsed.invoiceEmail !== undefined) record.invoiceEmail = parsed.invoiceEmail\n if (parsed.invoiceLanguage !== undefined) record.invoiceLanguage = parsed.invoiceLanguage\n if (parsed.taxId !== undefined) record.taxId = parsed.taxId\n if (parsed.invoiceAddress !== undefined) {\n record.invoiceAddress = parsed.invoiceAddress as Record<string, unknown>\n }\n if (parsed.nextBillDate !== undefined) record.nextBillDate = parsed.nextBillDate\n if (parsed.lastBillDate !== undefined) record.lastBillDate = parsed.lastBillDate\n if (parsed.isActive !== undefined) record.isActive = parsed.isActive\n\n record.updatedAt = new Date()\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'updated',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: record.id }\n },\n}\n\n// \u2500\u2500\u2500 Delete (soft) \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst deleteAccountCommand: CommandHandler<BillingAccountDeleteInput, { id: string }> = {\n id: 'billing.accounts.delete',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountDeleteSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const id = requireId(parsed)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingAccount, {\n id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n\n record.deletedAt = new Date()\n record.updatedAt = record.deletedAt\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: record.id }\n },\n}\n\nregisterCommand(createAccountCommand)\nregisterCommand(updateAccountCommand)\nregisterCommand(deleteAccountCommand)\n\nexport {\n createAccountCommand,\n updateAccountCommand,\n deleteAccountCommand,\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport {\n emitCrudSideEffects,\n requireId,\n} from '@open-mercato/shared/lib/commands/helpers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { BillingAccount, BillingAccountBalance } from '../data/entities'\nimport { billingEntityIds } from '../data/entityIds'\nimport {\n billingAccountCreateSchema,\n billingAccountDeleteSchema,\n billingAccountUpdateSchema,\n type BillingAccountCreateInput,\n type BillingAccountDeleteInput,\n type BillingAccountUpdateInput,\n} from '../data/validators'\nimport { ensureBalanceRow } from '../lib/balanceLedger'\nimport { formatMoney } from '../lib/money'\n\n/**\n * BillingAccount writes \u2014 create / update / soft-delete.\n *\n * Phase 1 keeps the command surface lean \u2014 no undo / snapshot / audit\n * log. Phase 4 (admin UI + posting flow) revisits to add operator-driven\n * undo. The patterns to add:\n * - `prepare`: capture BEFORE snapshot for update / delete\n * - `captureAfter`: capture AFTER snapshot\n * - `buildLog`: emit a structured audit log entry\n * - `undo`: restore from snapshots + reverse side effects\n * See `node_modules/@open-mercato/core/src/modules/customers/commands/people.ts`\n * for the canonical full-feature shape.\n */\n\nconst accountIndexer = { entityType: billingEntityIds.account } as const\nconst accountEvents = { module: 'billing', entity: 'account', persistent: true } as const\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getDataEngine(ctx: CommandRuntimeContext): DataEngine {\n return ctx.container.resolve('dataEngine') as DataEngine\n}\n\n// \u2500\u2500\u2500 Create \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\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\nconst createAccountCommand: CommandHandler<BillingAccountCreateInput, { id: string }> = {\n id: 'billing.accounts.create',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const now = new Date()\n const billingMode = parsed.billingMode ?? 'postpaid'\n const entity = em.create(BillingAccount, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n customerId: parsed.customerId,\n name: parsed.name,\n currencyCode: parsed.currencyCode,\n billCycle: parsed.billCycle,\n billCycleAnchor: parsed.billCycleAnchor,\n invoiceEmail: parsed.invoiceEmail,\n invoiceLanguage: parsed.invoiceLanguage,\n taxId: parsed.taxId ?? null,\n invoiceAddress: parsed.invoiceAddress as Record<string, unknown>,\n nextBillDate: parsed.nextBillDate,\n lastBillDate: parsed.lastBillDate ?? null,\n billingMode,\n creditLimit:\n parsed.creditLimit !== undefined ? formatMoney(parsed.creditLimit) : '0',\n isActive: parsed.isActive ?? true,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n await em.flush()\n\n // SPEC-002: a prepaid account gets its 1:1 balance row (starting at 0)\n // at create time, so the consume path never races a missing row.\n if (billingMode === 'prepaid') {\n await ensureBalanceRow(em, {\n organizationId: entity.organizationId,\n tenantId: entity.tenantId,\n billAccountId: entity.id,\n currencyCode: entity.currencyCode,\n lowBalanceThreshold:\n parsed.lowBalanceThreshold !== undefined && parsed.lowBalanceThreshold !== null\n ? formatMoney(parsed.lowBalanceThreshold)\n : null,\n })\n }\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'created',\n entity,\n identifiers: {\n id: entity.id,\n tenantId: entity.tenantId,\n organizationId: entity.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: entity.id }\n },\n}\n\n// \u2500\u2500\u2500 Update \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\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\nconst updateAccountCommand: CommandHandler<BillingAccountUpdateInput, { id: string }> = {\n id: 'billing.accounts.update',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountUpdateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingAccount, {\n id: parsed.id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n\n if (parsed.name !== undefined) record.name = parsed.name\n if (parsed.billCycle !== undefined) record.billCycle = parsed.billCycle\n if (parsed.billCycleAnchor !== undefined) record.billCycleAnchor = parsed.billCycleAnchor\n if (parsed.invoiceEmail !== undefined) record.invoiceEmail = parsed.invoiceEmail\n if (parsed.invoiceLanguage !== undefined) record.invoiceLanguage = parsed.invoiceLanguage\n if (parsed.taxId !== undefined) record.taxId = parsed.taxId\n if (parsed.invoiceAddress !== undefined) {\n record.invoiceAddress = parsed.invoiceAddress as Record<string, unknown>\n }\n if (parsed.nextBillDate !== undefined) record.nextBillDate = parsed.nextBillDate\n if (parsed.lastBillDate !== undefined) record.lastBillDate = parsed.lastBillDate\n if (parsed.isActive !== undefined) record.isActive = parsed.isActive\n // SPEC-002: credit_limit is editable in both modes (billing_mode is not).\n if (parsed.creditLimit !== undefined) record.creditLimit = formatMoney(parsed.creditLimit)\n\n record.updatedAt = new Date()\n\n // SPEC-002: edit the prepaid balance row's low-balance threshold when\n // supplied (null clears it back to the tenant config default). No-op for\n // postpaid accounts (they have no balance row).\n if (parsed.lowBalanceThreshold !== undefined && record.billingMode === 'prepaid') {\n const balanceRow = await em.findOne(BillingAccountBalance, {\n tenantId: parsed.tenantId,\n billAccountId: record.id,\n })\n if (balanceRow) {\n balanceRow.lowBalanceThreshold =\n parsed.lowBalanceThreshold === null\n ? null\n : formatMoney(parsed.lowBalanceThreshold)\n balanceRow.updatedAt = new Date()\n }\n }\n\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'updated',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: record.id }\n },\n}\n\n// \u2500\u2500\u2500 Delete (soft) \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst deleteAccountCommand: CommandHandler<BillingAccountDeleteInput, { id: string }> = {\n id: 'billing.accounts.delete',\n\n async execute(rawInput, ctx) {\n const parsed = billingAccountDeleteSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n const id = requireId(parsed)\n\n const em = getEm(ctx)\n const record = await em.findOne(BillingAccount, {\n id,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!record) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n\n record.deletedAt = new Date()\n record.updatedAt = record.deletedAt\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'deleted',\n entity: record,\n identifiers: {\n id: record.id,\n tenantId: record.tenantId,\n organizationId: record.organizationId,\n },\n indexer: accountIndexer,\n events: accountEvents,\n })\n\n return { id: record.id }\n },\n}\n\nregisterCommand(createAccountCommand)\nregisterCommand(updateAccountCommand)\nregisterCommand(deleteAccountCommand)\n\nexport {\n createAccountCommand,\n updateAccountCommand,\n deleteAccountCommand,\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,4 +3,7 @@ import "./items.js";
3
3
  import "./usage.js";
4
4
  import "./runs.js";
5
5
  import "./invoices.js";
6
+ import "./topups.js";
7
+ import "./transactions.js";
8
+ import "./statements.js";
6
9
  //# sourceMappingURL=index.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/commands/index.ts"],
4
- "sourcesContent": ["/**\n * Billing module command registry.\n *\n * Each file below side-effect-registers its commands via\n * `registerCommand(...)` from `@open-mercato/shared/lib/commands`.\n * Importing this barrel forces the registrations to run. The CRUD\n * factory's `commandId` lookups then resolve at request time.\n */\nimport './accounts'\nimport './items'\nimport './usage'\nimport './runs'\nimport './invoices'\n"],
5
- "mappings": "AAQA,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;",
4
+ "sourcesContent": ["/**\n * Billing module command registry.\n *\n * Each file below side-effect-registers its commands via\n * `registerCommand(...)` from `@open-mercato/shared/lib/commands`.\n * Importing this barrel forces the registrations to run. The CRUD\n * factory's `commandId` lookups then resolve at request time.\n */\nimport './accounts'\nimport './items'\nimport './usage'\nimport './runs'\nimport './invoices'\nimport './topups'\nimport './transactions'\nimport './statements'\n"],
5
+ "mappings": "AAQA,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;",
6
6
  "names": []
7
7
  }
@@ -6,8 +6,9 @@ import {
6
6
  } from "@open-mercato/shared/lib/commands/scope";
7
7
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
8
8
  import { SalesInvoice, SalesInvoiceLine } from "@open-mercato/core/modules/sales/data/entities";
9
- import { DraftInvoiceEdit } from "../data/entities.js";
9
+ import { BillingAccount, DraftInvoiceEdit } from "../data/entities.js";
10
10
  import { emitBillingEvent } from "../events.js";
11
+ import { buildPostpaidCreditCrossingEvents } from "../lib/accountStatus.js";
11
12
  import {
12
13
  billingInvoiceAddLineSchema,
13
14
  billingInvoiceEditLineSchema,
@@ -96,6 +97,25 @@ const postInvoiceCommand = {
96
97
  organizationId: invoice.organizationId
97
98
  });
98
99
  }
100
+ const billAccountId = meta.bill_account_id;
101
+ if (billAccountId) {
102
+ const account = await em.findOne(BillingAccount, {
103
+ id: billAccountId,
104
+ tenantId: parsed.tenantId,
105
+ organizationId: parsed.organizationId,
106
+ deletedAt: null
107
+ });
108
+ if (account) {
109
+ const creditEvents = await buildPostpaidCreditCrossingEvents(
110
+ em,
111
+ account,
112
+ invoice.outstandingAmount
113
+ );
114
+ for (const ev of creditEvents) {
115
+ await emitBillingEvent(ev.id, ev.payload, { persistent: true });
116
+ }
117
+ }
118
+ }
99
119
  return {
100
120
  invoiceId: invoice.id,
101
121
  invoiceNumber: invoice.invoiceNumber,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/billing/commands/invoices.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'\nimport { DraftInvoiceEdit } from '../data/entities'\nimport { emitBillingEvent } from '../events'\nimport {\n billingInvoiceAddLineSchema,\n billingInvoiceEditLineSchema,\n billingInvoicePostSchema,\n billingInvoiceRemoveLineSchema,\n billingWipeTestInvoicesSchema,\n type BillingInvoiceAddLineInput,\n type BillingInvoiceEditLineInput,\n type BillingInvoicePostInput,\n type BillingInvoiceRemoveLineInput,\n type BillingWipeTestInvoicesInput,\n} from '../data/validators'\nimport { resolveInvoiceStatusEntryId } from '../lib/invoiceStatus'\n\n/**\n * Posts a `core/sales` draft invoice that the Bill Run engine\n * created.\n *\n * Phase 2 deviation note: the invoice number is already set at\n * draft-create time (because `SalesInvoice.invoice_number` is NOT\n * NULL upstream). \"Post\" therefore only:\n * 1. Asserts the invoice is currently a draft (refuse otherwise).\n * 2. Refuses test-mode invoices (`metadata.test_run=true`) \u2014 they\n * should never reach production posting.\n * 3. Flips `status` (and `status_entry_id`) to `posted`.\n * 4. Fires `billing.invoice.posted` (one) + `billing.invoice.line_posted`\n * per line (for downstream consumers \u2014 accounting, analytics).\n *\n * Once posted, the invoice is frozen at the billing layer; corrections\n * go through `core/sales`'s manual edit flow.\n */\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\ntype PostResult = {\n invoiceId: string\n invoiceNumber: string\n status: 'posted'\n lineCount: number\n}\n\nconst postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> = {\n id: 'billing.invoices.post',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoicePostSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await em.findOne(SalesInvoice, {\n id: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!invoice) {\n throw new CrudHttpError(404, { error: 'Invoice not found' })\n }\n if (invoice.status !== 'draft') {\n throw new CrudHttpError(409, {\n error: 'Only draft invoices can be posted',\n code: 'billing.invoice.not_draft',\n currentStatus: invoice.status,\n })\n }\n const meta = (invoice.metadata as Record<string, unknown> | null) ?? {}\n if (meta.test_run === true) {\n // Test invoices live for inspection only \u2014 posting them would\n // leak test data into accounting. Force-delete the test invoice\n // instead (Phase 4b admin UI exposes a \"Wipe test drafts\"\n // button against `DELETE /api/billing/test-invoices`).\n throw new CrudHttpError(409, {\n error: 'Test-mode invoices cannot be posted',\n code: 'billing.invoice.test_run',\n })\n }\n\n const postedStatusEntryId = await resolveInvoiceStatusEntryId(\n em,\n parsed.tenantId,\n parsed.organizationId,\n 'posted',\n )\n\n await withAtomicFlush(em, [\n () => {\n invoice.status = 'posted'\n invoice.statusEntryId = postedStatusEntryId\n invoice.updatedAt = new Date()\n },\n ])\n\n const lines = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n } as never)\n\n // Side effects fire AFTER the DB commit so downstream subscribers\n // (mailer, accounting bridge, analytics) never observe a \"posted\"\n // event for an invoice the DB doesn't reflect.\n await emitBillingEvent('billing.invoice.posted', {\n invoiceId: invoice.id,\n invoiceNumber: invoice.invoiceNumber,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n grandTotalNet: invoice.grandTotalNetAmount,\n grandTotalGross: invoice.grandTotalGrossAmount,\n currencyCode: invoice.currencyCode,\n billRunId: (meta as { bill_run_id?: string }).bill_run_id ?? null,\n billAccountId: (meta as { bill_account_id?: string }).bill_account_id ?? null,\n billPeriodStart: (meta as { bill_period_start?: string }).bill_period_start ?? null,\n billPeriodEnd: (meta as { bill_period_end?: string }).bill_period_end ?? null,\n lineCount: lines.length,\n })\n for (const line of lines) {\n const lineMeta = (line.metadata as Record<string, unknown> | null) ?? {}\n await emitBillingEvent('billing.invoice.line_posted', {\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n billingItemId: lineMeta.billing_item_id ?? null,\n billingType: lineMeta.billing_type ?? null,\n description: line.description ?? null,\n unitPriceNet: line.unitPriceNet,\n quantity: line.quantity,\n totalNetAmount: line.totalNetAmount,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n })\n }\n\n return {\n invoiceId: invoice.id,\n invoiceNumber: invoice.invoiceNumber,\n status: 'posted',\n lineCount: lines.length,\n }\n },\n}\n\n// \u2500\u2500\u2500 Draft edit / add / remove (Phase 4b) \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//\n// Three commands sharing the same shape:\n// 1. Assert the invoice is still a draft (refuse otherwise).\n// 2. Mutate the line / collection.\n// 3. Write a `DraftInvoiceEdit` audit row carrying before+after\n// snapshots (`null` on the \"no prior state\" / \"no resulting state\"\n// side per the spec \u2014 see entity column docs).\n// 4. Recompute the invoice totals from the surviving lines.\n//\n// The audit row is append-only; once written, never updated. Soft-\n// deleting the invoice does NOT delete the audit rows (compliance).\n\nfunction roundHalfUp2dp(value: number): number {\n return Math.round((value + Number.EPSILON) * 100) / 100\n}\n\nfunction format4dp(value: number): string {\n return roundHalfUp2dp(value).toFixed(4)\n}\n\nasync function assertDraftInvoice(\n em: EntityManager,\n params: { invoiceId: string; tenantId: string; organizationId: string },\n): Promise<SalesInvoice> {\n const invoice = await em.findOne(SalesInvoice, {\n id: params.invoiceId,\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n })\n if (!invoice) {\n throw new CrudHttpError(404, { error: 'Invoice not found' })\n }\n if (invoice.status !== 'draft') {\n throw new CrudHttpError(409, {\n error: 'Only draft invoices can be edited',\n code: 'billing.invoice.not_draft',\n currentStatus: invoice.status,\n })\n }\n return invoice\n}\n\ntype LineSnapshot = {\n id: string\n description: string | null\n quantity: string\n unitPriceNet: string\n totalNetAmount: string\n totalGrossAmount: string\n metadata: Record<string, unknown> | null\n}\n\nfunction snapshotLine(line: SalesInvoiceLine): LineSnapshot {\n return {\n id: line.id,\n description: line.description ?? null,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet,\n totalNetAmount: line.totalNetAmount,\n totalGrossAmount: line.totalGrossAmount,\n metadata: (line.metadata as Record<string, unknown> | null) ?? null,\n }\n}\n\nasync function recomputeInvoiceTotals(\n em: EntityManager,\n invoice: SalesInvoice,\n): Promise<void> {\n const lines = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n } as never)\n let totalNet = 0\n for (const line of lines) {\n totalNet += Number.parseFloat(line.totalNetAmount)\n }\n const formatted = format4dp(totalNet)\n invoice.subtotalNetAmount = formatted\n invoice.subtotalGrossAmount = formatted\n invoice.grandTotalNetAmount = formatted\n invoice.grandTotalGrossAmount = formatted\n invoice.outstandingAmount = formatted\n invoice.updatedAt = new Date()\n}\n\nfunction getUserIdFromCtx(ctx: CommandRuntimeContext): string {\n const userId = (ctx.auth?.sub as string | undefined) ?? null\n // The DraftInvoiceEdit.user_id column is NOT NULL. For system-driven\n // edits (e.g. catch-up flow that revises a value), we fall back to\n // a sentinel UUID. Operator-driven edits always have a real\n // ctx.auth.sub.\n return userId ?? '00000000-0000-0000-0000-000000000000'\n}\n\nconst editDraftLineCommand: CommandHandler<\n BillingInvoiceEditLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.edit_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceEditLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n\n const line = await em.findOne(SalesInvoiceLine, {\n id: parsed.invoiceLineId,\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n if (!line) {\n throw new CrudHttpError(404, { error: 'Invoice line not found' })\n }\n\n const before = snapshotLine(line)\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n if (parsed.changes.description !== undefined) {\n line.description = parsed.changes.description\n }\n if (parsed.changes.quantity !== undefined) {\n line.quantity = format4dp(parsed.changes.quantity)\n line.normalizedQuantity = line.quantity\n }\n if (parsed.changes.unitPriceNet !== undefined) {\n line.unitPriceNet = format4dp(parsed.changes.unitPriceNet)\n line.unitPriceGross = line.unitPriceNet\n }\n\n // Recompute the line total. If the operator supplied an explicit\n // `totalNetAmount` override, honour it; otherwise compute from\n // unit_price \u00D7 quantity.\n if (parsed.changes.totalNetAmount !== undefined) {\n const total = format4dp(parsed.changes.totalNetAmount)\n line.totalNetAmount = total\n line.totalGrossAmount = total\n } else {\n const total = format4dp(\n Number.parseFloat(line.unitPriceNet) * Number.parseFloat(line.quantity),\n )\n line.totalNetAmount = total\n line.totalGrossAmount = total\n }\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n const after = snapshotLine(line)\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n userId: getUserIdFromCtx(ctx),\n action: 'line_edited',\n beforeJson: before as Record<string, unknown>,\n afterJson: after as Record<string, unknown>,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }\n },\n}\n\nconst addDraftLineCommand: CommandHandler<\n BillingInvoiceAddLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.add_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceAddLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n\n const existing = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n } as never)\n const nextLineNumber = existing.length + 1\n\n const total = format4dp(parsed.unitPriceNet * parsed.quantity)\n const line = em.create(SalesInvoiceLine, {\n invoice,\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n lineNumber: nextLineNumber,\n kind: 'product',\n description: parsed.description,\n quantity: format4dp(parsed.quantity),\n normalizedQuantity: format4dp(parsed.quantity),\n currencyCode: invoice.currencyCode,\n unitPriceNet: format4dp(parsed.unitPriceNet),\n unitPriceGross: format4dp(parsed.unitPriceNet),\n discountAmount: '0',\n discountPercent: '0',\n taxRate: '0',\n taxAmount: '0',\n totalNetAmount: total,\n totalGrossAmount: total,\n metadata: {\n billing_item_id: parsed.billingItemId ?? null,\n billing_type: parsed.billingType ?? 'manual',\n operator_added: true,\n },\n })\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n em.persist(line)\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n const after = snapshotLine(line)\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n userId: getUserIdFromCtx(ctx),\n action: 'line_added',\n beforeJson: null,\n afterJson: after as Record<string, unknown>,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }\n },\n}\n\nconst removeDraftLineCommand: CommandHandler<\n BillingInvoiceRemoveLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.remove_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceRemoveLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n const line = await em.findOne(SalesInvoiceLine, {\n id: parsed.invoiceLineId,\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n if (!line) {\n throw new CrudHttpError(404, { error: 'Invoice line not found' })\n }\n\n const before = snapshotLine(line)\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n em.remove(line)\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: null,\n userId: getUserIdFromCtx(ctx),\n action: 'line_removed',\n beforeJson: before as Record<string, unknown>,\n afterJson: null,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: parsed.invoiceLineId, auditId: audit!.id }\n },\n}\n\n// \u2500\u2500\u2500 Wipe test invoices (Phase 4b) \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\u2500\n//\n// Hard-deletes invoices flagged `metadata.test_run=true` (and their\n// lines via cascade \u2014 the invoice's lines collection points at it\n// with ON DELETE CASCADE). Optionally scoped to a single `bill_run_id`\n// via the param so operators can wipe a specific test run without\n// risking other test data.\n\nconst wipeTestInvoicesCommand: CommandHandler<\n BillingWipeTestInvoicesInput,\n { invoicesRemoved: number }\n> = {\n id: 'billing.invoices.wipe_test',\n\n async execute(rawInput, ctx) {\n const parsed = billingWipeTestInvoicesSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n // The metadata->>'test_run' = 'true' predicate uses Postgres'\n // JSONB ->> operator + the literal string 'true' (jsonb true is\n // serialized as the JSON literal `true`, which ->> returns as the\n // text \"true\").\n const params: unknown[] = [parsed.tenantId, parsed.organizationId]\n let billRunClause = ''\n if (parsed.billRunId) {\n billRunClause = ` AND metadata->>'bill_run_id' = ?`\n params.push(parsed.billRunId)\n }\n const rows = (await em.execute(\n `SELECT id FROM sales_invoices\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND metadata->>'test_run' = 'true'${billRunClause}`,\n params,\n )) as unknown as Array<{ id: string }> | undefined\n if (!rows || rows.length === 0) {\n return { invoicesRemoved: 0 }\n }\n const ids = rows.map((r) => r.id)\n // Hard-delete lines first then invoices to avoid relying on\n // cascade semantics that the migration may or may not encode.\n await em.execute(\n `DELETE FROM sales_invoice_lines WHERE invoice_id IN (${ids.map(() => '?').join(',')})`,\n ids,\n )\n await em.execute(\n `DELETE FROM sales_invoices WHERE id IN (${ids.map(() => '?').join(',')})`,\n ids,\n )\n return { invoicesRemoved: ids.length }\n },\n}\n\nregisterCommand(postInvoiceCommand)\nregisterCommand(editDraftLineCommand)\nregisterCommand(addDraftLineCommand)\nregisterCommand(removeDraftLineCommand)\nregisterCommand(wipeTestInvoicesCommand)\n\nexport {\n postInvoiceCommand,\n editDraftLineCommand,\n addDraftLineCommand,\n removeDraftLineCommand,\n wipeTestInvoicesCommand,\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { SalesInvoice, SalesInvoiceLine } from '@open-mercato/core/modules/sales/data/entities'\nimport { BillingAccount, DraftInvoiceEdit } from '../data/entities'\nimport { emitBillingEvent, type BillingEventId } from '../events'\nimport { buildPostpaidCreditCrossingEvents } from '../lib/accountStatus'\nimport {\n billingInvoiceAddLineSchema,\n billingInvoiceEditLineSchema,\n billingInvoicePostSchema,\n billingInvoiceRemoveLineSchema,\n billingWipeTestInvoicesSchema,\n type BillingInvoiceAddLineInput,\n type BillingInvoiceEditLineInput,\n type BillingInvoicePostInput,\n type BillingInvoiceRemoveLineInput,\n type BillingWipeTestInvoicesInput,\n} from '../data/validators'\nimport { resolveInvoiceStatusEntryId } from '../lib/invoiceStatus'\n\n/**\n * Posts a `core/sales` draft invoice that the Bill Run engine\n * created.\n *\n * Phase 2 deviation note: the invoice number is already set at\n * draft-create time (because `SalesInvoice.invoice_number` is NOT\n * NULL upstream). \"Post\" therefore only:\n * 1. Asserts the invoice is currently a draft (refuse otherwise).\n * 2. Refuses test-mode invoices (`metadata.test_run=true`) \u2014 they\n * should never reach production posting.\n * 3. Flips `status` (and `status_entry_id`) to `posted`.\n * 4. Fires `billing.invoice.posted` (one) + `billing.invoice.line_posted`\n * per line (for downstream consumers \u2014 accounting, analytics).\n *\n * Once posted, the invoice is frozen at the billing layer; corrections\n * go through `core/sales`'s manual edit flow.\n */\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\ntype PostResult = {\n invoiceId: string\n invoiceNumber: string\n status: 'posted'\n lineCount: number\n}\n\nconst postInvoiceCommand: CommandHandler<BillingInvoicePostInput, PostResult> = {\n id: 'billing.invoices.post',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoicePostSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await em.findOne(SalesInvoice, {\n id: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!invoice) {\n throw new CrudHttpError(404, { error: 'Invoice not found' })\n }\n if (invoice.status !== 'draft') {\n throw new CrudHttpError(409, {\n error: 'Only draft invoices can be posted',\n code: 'billing.invoice.not_draft',\n currentStatus: invoice.status,\n })\n }\n const meta = (invoice.metadata as Record<string, unknown> | null) ?? {}\n if (meta.test_run === true) {\n // Test invoices live for inspection only \u2014 posting them would\n // leak test data into accounting. Force-delete the test invoice\n // instead (Phase 4b admin UI exposes a \"Wipe test drafts\"\n // button against `DELETE /api/billing/test-invoices`).\n throw new CrudHttpError(409, {\n error: 'Test-mode invoices cannot be posted',\n code: 'billing.invoice.test_run',\n })\n }\n\n const postedStatusEntryId = await resolveInvoiceStatusEntryId(\n em,\n parsed.tenantId,\n parsed.organizationId,\n 'posted',\n )\n\n await withAtomicFlush(em, [\n () => {\n invoice.status = 'posted'\n invoice.statusEntryId = postedStatusEntryId\n invoice.updatedAt = new Date()\n },\n ])\n\n const lines = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n } as never)\n\n // Side effects fire AFTER the DB commit so downstream subscribers\n // (mailer, accounting bridge, analytics) never observe a \"posted\"\n // event for an invoice the DB doesn't reflect.\n await emitBillingEvent('billing.invoice.posted', {\n invoiceId: invoice.id,\n invoiceNumber: invoice.invoiceNumber,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n grandTotalNet: invoice.grandTotalNetAmount,\n grandTotalGross: invoice.grandTotalGrossAmount,\n currencyCode: invoice.currencyCode,\n billRunId: (meta as { bill_run_id?: string }).bill_run_id ?? null,\n billAccountId: (meta as { bill_account_id?: string }).bill_account_id ?? null,\n billPeriodStart: (meta as { bill_period_start?: string }).bill_period_start ?? null,\n billPeriodEnd: (meta as { bill_period_end?: string }).bill_period_end ?? null,\n lineCount: lines.length,\n })\n for (const line of lines) {\n const lineMeta = (line.metadata as Record<string, unknown> | null) ?? {}\n await emitBillingEvent('billing.invoice.line_posted', {\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n billingItemId: lineMeta.billing_item_id ?? null,\n billingType: lineMeta.billing_type ?? null,\n description: line.description ?? null,\n unitPriceNet: line.unitPriceNet,\n quantity: line.quantity,\n totalNetAmount: line.totalNetAmount,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n })\n }\n\n // SPEC-002 P6: a newly-posted invoice raises the account's outstanding\n // exposure \u2014 emit billing.credit.over_limit on the upward crossing. Billing\n // never blocks; the consuming flow decides what to do.\n const billAccountId = (meta as { bill_account_id?: string }).bill_account_id\n if (billAccountId) {\n const account = await em.findOne(BillingAccount, {\n id: billAccountId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (account) {\n const creditEvents = await buildPostpaidCreditCrossingEvents(\n em,\n account,\n invoice.outstandingAmount,\n )\n for (const ev of creditEvents) {\n await emitBillingEvent(ev.id as BillingEventId, ev.payload, { persistent: true })\n }\n }\n }\n\n return {\n invoiceId: invoice.id,\n invoiceNumber: invoice.invoiceNumber,\n status: 'posted',\n lineCount: lines.length,\n }\n },\n}\n\n// \u2500\u2500\u2500 Draft edit / add / remove (Phase 4b) \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//\n// Three commands sharing the same shape:\n// 1. Assert the invoice is still a draft (refuse otherwise).\n// 2. Mutate the line / collection.\n// 3. Write a `DraftInvoiceEdit` audit row carrying before+after\n// snapshots (`null` on the \"no prior state\" / \"no resulting state\"\n// side per the spec \u2014 see entity column docs).\n// 4. Recompute the invoice totals from the surviving lines.\n//\n// The audit row is append-only; once written, never updated. Soft-\n// deleting the invoice does NOT delete the audit rows (compliance).\n\nfunction roundHalfUp2dp(value: number): number {\n return Math.round((value + Number.EPSILON) * 100) / 100\n}\n\nfunction format4dp(value: number): string {\n return roundHalfUp2dp(value).toFixed(4)\n}\n\nasync function assertDraftInvoice(\n em: EntityManager,\n params: { invoiceId: string; tenantId: string; organizationId: string },\n): Promise<SalesInvoice> {\n const invoice = await em.findOne(SalesInvoice, {\n id: params.invoiceId,\n tenantId: params.tenantId,\n organizationId: params.organizationId,\n deletedAt: null,\n })\n if (!invoice) {\n throw new CrudHttpError(404, { error: 'Invoice not found' })\n }\n if (invoice.status !== 'draft') {\n throw new CrudHttpError(409, {\n error: 'Only draft invoices can be edited',\n code: 'billing.invoice.not_draft',\n currentStatus: invoice.status,\n })\n }\n return invoice\n}\n\ntype LineSnapshot = {\n id: string\n description: string | null\n quantity: string\n unitPriceNet: string\n totalNetAmount: string\n totalGrossAmount: string\n metadata: Record<string, unknown> | null\n}\n\nfunction snapshotLine(line: SalesInvoiceLine): LineSnapshot {\n return {\n id: line.id,\n description: line.description ?? null,\n quantity: line.quantity,\n unitPriceNet: line.unitPriceNet,\n totalNetAmount: line.totalNetAmount,\n totalGrossAmount: line.totalGrossAmount,\n metadata: (line.metadata as Record<string, unknown> | null) ?? null,\n }\n}\n\nasync function recomputeInvoiceTotals(\n em: EntityManager,\n invoice: SalesInvoice,\n): Promise<void> {\n const lines = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: invoice.tenantId,\n organizationId: invoice.organizationId,\n } as never)\n let totalNet = 0\n for (const line of lines) {\n totalNet += Number.parseFloat(line.totalNetAmount)\n }\n const formatted = format4dp(totalNet)\n invoice.subtotalNetAmount = formatted\n invoice.subtotalGrossAmount = formatted\n invoice.grandTotalNetAmount = formatted\n invoice.grandTotalGrossAmount = formatted\n invoice.outstandingAmount = formatted\n invoice.updatedAt = new Date()\n}\n\nfunction getUserIdFromCtx(ctx: CommandRuntimeContext): string {\n const userId = (ctx.auth?.sub as string | undefined) ?? null\n // The DraftInvoiceEdit.user_id column is NOT NULL. For system-driven\n // edits (e.g. catch-up flow that revises a value), we fall back to\n // a sentinel UUID. Operator-driven edits always have a real\n // ctx.auth.sub.\n return userId ?? '00000000-0000-0000-0000-000000000000'\n}\n\nconst editDraftLineCommand: CommandHandler<\n BillingInvoiceEditLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.edit_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceEditLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n\n const line = await em.findOne(SalesInvoiceLine, {\n id: parsed.invoiceLineId,\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n if (!line) {\n throw new CrudHttpError(404, { error: 'Invoice line not found' })\n }\n\n const before = snapshotLine(line)\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n if (parsed.changes.description !== undefined) {\n line.description = parsed.changes.description\n }\n if (parsed.changes.quantity !== undefined) {\n line.quantity = format4dp(parsed.changes.quantity)\n line.normalizedQuantity = line.quantity\n }\n if (parsed.changes.unitPriceNet !== undefined) {\n line.unitPriceNet = format4dp(parsed.changes.unitPriceNet)\n line.unitPriceGross = line.unitPriceNet\n }\n\n // Recompute the line total. If the operator supplied an explicit\n // `totalNetAmount` override, honour it; otherwise compute from\n // unit_price \u00D7 quantity.\n if (parsed.changes.totalNetAmount !== undefined) {\n const total = format4dp(parsed.changes.totalNetAmount)\n line.totalNetAmount = total\n line.totalGrossAmount = total\n } else {\n const total = format4dp(\n Number.parseFloat(line.unitPriceNet) * Number.parseFloat(line.quantity),\n )\n line.totalNetAmount = total\n line.totalGrossAmount = total\n }\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n const after = snapshotLine(line)\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n userId: getUserIdFromCtx(ctx),\n action: 'line_edited',\n beforeJson: before as Record<string, unknown>,\n afterJson: after as Record<string, unknown>,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }\n },\n}\n\nconst addDraftLineCommand: CommandHandler<\n BillingInvoiceAddLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.add_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceAddLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n\n const existing = await em.find(SalesInvoiceLine, {\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n } as never)\n const nextLineNumber = existing.length + 1\n\n const total = format4dp(parsed.unitPriceNet * parsed.quantity)\n const line = em.create(SalesInvoiceLine, {\n invoice,\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n lineNumber: nextLineNumber,\n kind: 'product',\n description: parsed.description,\n quantity: format4dp(parsed.quantity),\n normalizedQuantity: format4dp(parsed.quantity),\n currencyCode: invoice.currencyCode,\n unitPriceNet: format4dp(parsed.unitPriceNet),\n unitPriceGross: format4dp(parsed.unitPriceNet),\n discountAmount: '0',\n discountPercent: '0',\n taxRate: '0',\n taxAmount: '0',\n totalNetAmount: total,\n totalGrossAmount: total,\n metadata: {\n billing_item_id: parsed.billingItemId ?? null,\n billing_type: parsed.billingType ?? 'manual',\n operator_added: true,\n },\n })\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n em.persist(line)\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n const after = snapshotLine(line)\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: line.id,\n userId: getUserIdFromCtx(ctx),\n action: 'line_added',\n beforeJson: null,\n afterJson: after as Record<string, unknown>,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: line.id, auditId: audit!.id }\n },\n}\n\nconst removeDraftLineCommand: CommandHandler<\n BillingInvoiceRemoveLineInput,\n { invoiceId: string; invoiceLineId: string; auditId: string }\n> = {\n id: 'billing.invoices.remove_draft_line',\n\n async execute(rawInput, ctx) {\n const parsed = billingInvoiceRemoveLineSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const invoice = await assertDraftInvoice(em, {\n invoiceId: parsed.invoiceId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n const line = await em.findOne(SalesInvoiceLine, {\n id: parsed.invoiceLineId,\n invoice,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n })\n if (!line) {\n throw new CrudHttpError(404, { error: 'Invoice line not found' })\n }\n\n const before = snapshotLine(line)\n let audit: DraftInvoiceEdit | null = null\n\n await withAtomicFlush(em, [\n () => {\n em.remove(line)\n },\n async () => {\n await recomputeInvoiceTotals(em, invoice)\n },\n () => {\n audit = em.create(DraftInvoiceEdit, {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n invoiceId: invoice.id,\n invoiceLineId: null,\n userId: getUserIdFromCtx(ctx),\n action: 'line_removed',\n beforeJson: before as Record<string, unknown>,\n afterJson: null,\n createdAt: new Date(),\n })\n em.persist(audit)\n },\n ])\n\n return { invoiceId: invoice.id, invoiceLineId: parsed.invoiceLineId, auditId: audit!.id }\n },\n}\n\n// \u2500\u2500\u2500 Wipe test invoices (Phase 4b) \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\u2500\n//\n// Hard-deletes invoices flagged `metadata.test_run=true` (and their\n// lines via cascade \u2014 the invoice's lines collection points at it\n// with ON DELETE CASCADE). Optionally scoped to a single `bill_run_id`\n// via the param so operators can wipe a specific test run without\n// risking other test data.\n\nconst wipeTestInvoicesCommand: CommandHandler<\n BillingWipeTestInvoicesInput,\n { invoicesRemoved: number }\n> = {\n id: 'billing.invoices.wipe_test',\n\n async execute(rawInput, ctx) {\n const parsed = billingWipeTestInvoicesSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n // The metadata->>'test_run' = 'true' predicate uses Postgres'\n // JSONB ->> operator + the literal string 'true' (jsonb true is\n // serialized as the JSON literal `true`, which ->> returns as the\n // text \"true\").\n const params: unknown[] = [parsed.tenantId, parsed.organizationId]\n let billRunClause = ''\n if (parsed.billRunId) {\n billRunClause = ` AND metadata->>'bill_run_id' = ?`\n params.push(parsed.billRunId)\n }\n const rows = (await em.execute(\n `SELECT id FROM sales_invoices\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND metadata->>'test_run' = 'true'${billRunClause}`,\n params,\n )) as unknown as Array<{ id: string }> | undefined\n if (!rows || rows.length === 0) {\n return { invoicesRemoved: 0 }\n }\n const ids = rows.map((r) => r.id)\n // Hard-delete lines first then invoices to avoid relying on\n // cascade semantics that the migration may or may not encode.\n await em.execute(\n `DELETE FROM sales_invoice_lines WHERE invoice_id IN (${ids.map(() => '?').join(',')})`,\n ids,\n )\n await em.execute(\n `DELETE FROM sales_invoices WHERE id IN (${ids.map(() => '?').join(',')})`,\n ids,\n )\n return { invoicesRemoved: ids.length }\n },\n}\n\nregisterCommand(postInvoiceCommand)\nregisterCommand(editDraftLineCommand)\nregisterCommand(addDraftLineCommand)\nregisterCommand(removeDraftLineCommand)\nregisterCommand(wipeTestInvoicesCommand)\n\nexport {\n postInvoiceCommand,\n editDraftLineCommand,\n addDraftLineCommand,\n removeDraftLineCommand,\n wipeTestInvoicesCommand,\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,74 @@
1
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import {
3
+ ensureOrganizationScope,
4
+ ensureTenantScope
5
+ } from "@open-mercato/shared/lib/commands/scope";
6
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
7
+ import { BillingAccount } from "../data/entities.js";
8
+ import {
9
+ billingStatementGenerateSchema
10
+ } from "../data/validators.js";
11
+ import { runBillRun } from "../lib/billRunEngine.js";
12
+ import { TenantLockBusyError } from "../lib/tenantLock.js";
13
+ function getEm(ctx) {
14
+ return ctx.container.resolve("em").fork();
15
+ }
16
+ function getContainer(ctx) {
17
+ return ctx.container;
18
+ }
19
+ const generateStatementCommand = {
20
+ id: "billing.statements.generate",
21
+ async execute(rawInput, ctx) {
22
+ const parsed = billingStatementGenerateSchema.parse(rawInput);
23
+ ensureTenantScope(ctx, parsed.tenantId);
24
+ ensureOrganizationScope(ctx, parsed.organizationId);
25
+ const em = getEm(ctx);
26
+ const account = await em.findOne(BillingAccount, {
27
+ id: parsed.billAccountId,
28
+ tenantId: parsed.tenantId,
29
+ organizationId: parsed.organizationId,
30
+ deletedAt: null
31
+ });
32
+ if (!account) {
33
+ throw new CrudHttpError(404, { error: "Billing account not found" });
34
+ }
35
+ if (account.billingMode !== "prepaid") {
36
+ throw new CrudHttpError(409, {
37
+ error: "Statements are only generated for prepaid accounts",
38
+ code: "billing.account.not_prepaid"
39
+ });
40
+ }
41
+ const asOfDate = parsed.asOfDate ?? /* @__PURE__ */ new Date();
42
+ try {
43
+ const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {
44
+ tenantId: parsed.tenantId,
45
+ organizationId: parsed.organizationId,
46
+ asOfDate,
47
+ triggeredBy: "manual",
48
+ mode: "real",
49
+ scopedAccountIds: [account.id]
50
+ });
51
+ const outcome = outcomes.find((o) => o.billAccountId === account.id);
52
+ return {
53
+ statementId: outcome?.statementId ?? null,
54
+ billRunId: billRun.id,
55
+ status: outcome?.status ?? billRun.status
56
+ };
57
+ } catch (err) {
58
+ if (err instanceof TenantLockBusyError) {
59
+ throw new CrudHttpError(409, {
60
+ error: "Another Bill Run is already in progress for this tenant",
61
+ code: "billing.run.lock_busy",
62
+ tenantId: err.tenantId,
63
+ lockName: err.lockName
64
+ });
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+ };
70
+ registerCommand(generateStatementCommand);
71
+ export {
72
+ generateStatementCommand
73
+ };
74
+ //# sourceMappingURL=statements.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/commands/statements.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { BillingAccount } from '../data/entities'\nimport {\n billingStatementGenerateSchema,\n type BillingStatementGenerateInput,\n} from '../data/validators'\nimport { runBillRun } from '../lib/billRunEngine'\nimport { TenantLockBusyError } from '../lib/tenantLock'\n\n/**\n * Manual statement generation (SPEC-002 P3).\n *\n * Reuses the Bill Run engine scoped to a single prepaid account \u2014 so the same\n * recurring-charge + statement + anti-duplicate machinery runs, with the same\n * tenant lock. If the account is due (`next_bill_date <= asOfDate`) it produces\n * one statement (and advances the cycle); otherwise nothing is generated.\n */\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getContainer(ctx: CommandRuntimeContext): AwilixContainer {\n return ctx.container as AwilixContainer\n}\n\nexport type GenerateStatementResult = {\n statementId: string | null\n billRunId: string\n status: string\n}\n\nconst generateStatementCommand: CommandHandler<\n BillingStatementGenerateInput,\n GenerateStatementResult\n> = {\n id: 'billing.statements.generate',\n\n async execute(rawInput, ctx) {\n const parsed = billingStatementGenerateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const account = await em.findOne(BillingAccount, {\n id: parsed.billAccountId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!account) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n if (account.billingMode !== 'prepaid') {\n throw new CrudHttpError(409, {\n error: 'Statements are only generated for prepaid accounts',\n code: 'billing.account.not_prepaid',\n })\n }\n\n const asOfDate = parsed.asOfDate ?? new Date()\n try {\n const { billRun, outcomes } = await runBillRun(em, getContainer(ctx), {\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n asOfDate,\n triggeredBy: 'manual',\n mode: 'real',\n scopedAccountIds: [account.id],\n })\n const outcome = outcomes.find((o) => o.billAccountId === account.id)\n return {\n statementId: outcome?.statementId ?? null,\n billRunId: billRun.id,\n status: outcome?.status ?? billRun.status,\n }\n } catch (err) {\n if (err instanceof TenantLockBusyError) {\n throw new CrudHttpError(409, {\n error: 'Another Bill Run is already in progress for this tenant',\n code: 'billing.run.lock_busy',\n tenantId: err.tenantId,\n lockName: err.lockName,\n })\n }\n throw err\n }\n },\n}\n\nregisterCommand(generateStatementCommand)\n\nexport { generateStatementCommand }\n"],
5
+ "mappings": "AAGA,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAC9B,SAAS,sBAAsB;AAC/B;AAAA,EACE;AAAA,OAEK;AACP,SAAS,kBAAkB;AAC3B,SAAS,2BAA2B;AAWpC,SAAS,MAAM,KAA2C;AACxD,SAAQ,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC7D;AAEA,SAAS,aAAa,KAA6C;AACjE,SAAO,IAAI;AACb;AAQA,MAAM,2BAGF;AAAA,EACF,IAAI;AAAA,EAEJ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,SAAS,+BAA+B,MAAM,QAAQ;AAC5D,sBAAkB,KAAK,OAAO,QAAQ;AACtC,4BAAwB,KAAK,OAAO,cAAc;AAElD,UAAM,KAAK,MAAM,GAAG;AACpB,UAAM,UAAU,MAAM,GAAG,QAAQ,gBAAgB;AAAA,MAC/C,IAAI,OAAO;AAAA,MACX,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO;AAAA,MACvB,WAAW;AAAA,IACb,CAAC;AACD,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,4BAA4B,CAAC;AAAA,IACrE;AACA,QAAI,QAAQ,gBAAgB,WAAW;AACrC,YAAM,IAAI,cAAc,KAAK;AAAA,QAC3B,OAAO;AAAA,QACP,MAAM;AAAA,MACR,CAAC;AAAA,IACH;AAEA,UAAM,WAAW,OAAO,YAAY,oBAAI,KAAK;AAC7C,QAAI;AACF,YAAM,EAAE,SAAS,SAAS,IAAI,MAAM,WAAW,IAAI,aAAa,GAAG,GAAG;AAAA,QACpE,UAAU,OAAO;AAAA,QACjB,gBAAgB,OAAO;AAAA,QACvB;AAAA,QACA,aAAa;AAAA,QACb,MAAM;AAAA,QACN,kBAAkB,CAAC,QAAQ,EAAE;AAAA,MAC/B,CAAC;AACD,YAAM,UAAU,SAAS,KAAK,CAAC,MAAM,EAAE,kBAAkB,QAAQ,EAAE;AACnE,aAAO;AAAA,QACL,aAAa,SAAS,eAAe;AAAA,QACrC,WAAW,QAAQ;AAAA,QACnB,QAAQ,SAAS,UAAU,QAAQ;AAAA,MACrC;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,eAAe,qBAAqB;AACtC,cAAM,IAAI,cAAc,KAAK;AAAA,UAC3B,OAAO;AAAA,UACP,MAAM;AAAA,UACN,UAAU,IAAI;AAAA,UACd,UAAU,IAAI;AAAA,QAChB,CAAC;AAAA,MACH;AACA,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,gBAAgB,wBAAwB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
3
+ import {
4
+ ensureOrganizationScope,
5
+ ensureTenantScope
6
+ } from "@open-mercato/shared/lib/commands/scope";
7
+ import { emitCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
8
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
9
+ import { BillingAccount, BillingTopup } from "../data/entities.js";
10
+ import { billingEntityIds } from "../data/entityIds.js";
11
+ import {
12
+ billingTopupCreateSchema
13
+ } from "../data/validators.js";
14
+ import { formatMoney } from "../lib/money.js";
15
+ import { getTopupProvider } from "../lib/prepaidConfig.js";
16
+ const topupIndexer = { entityType: billingEntityIds.topup };
17
+ const topupEvents = { module: "billing", entity: "topup", persistent: true };
18
+ function getEm(ctx) {
19
+ return ctx.container.resolve("em").fork();
20
+ }
21
+ function getDataEngine(ctx) {
22
+ return ctx.container.resolve("dataEngine");
23
+ }
24
+ const createTopupCommand = {
25
+ id: "billing.topups.create",
26
+ async execute(rawInput, ctx) {
27
+ const parsed = billingTopupCreateSchema.parse(rawInput);
28
+ ensureTenantScope(ctx, parsed.tenantId);
29
+ ensureOrganizationScope(ctx, parsed.organizationId);
30
+ const em = getEm(ctx);
31
+ const account = await em.findOne(BillingAccount, {
32
+ id: parsed.billAccountId,
33
+ tenantId: parsed.tenantId,
34
+ organizationId: parsed.organizationId,
35
+ deletedAt: null
36
+ });
37
+ if (!account) {
38
+ throw new CrudHttpError(404, { error: "Billing account not found" });
39
+ }
40
+ if (account.billingMode !== "prepaid") {
41
+ throw new CrudHttpError(409, {
42
+ error: "Top-ups are only available on prepaid accounts",
43
+ code: "billing.account.not_prepaid"
44
+ });
45
+ }
46
+ if (parsed.sourceRef) {
47
+ const existing = await em.findOne(BillingTopup, {
48
+ tenantId: parsed.tenantId,
49
+ billAccountId: account.id,
50
+ sourceRef: parsed.sourceRef,
51
+ deletedAt: null
52
+ });
53
+ if (existing) {
54
+ const meta = existing.metadata ?? {};
55
+ return {
56
+ topupId: existing.id,
57
+ paymentId: existing.paymentId,
58
+ status: existing.status,
59
+ transactionId: existing.gatewayTransactionId ?? null,
60
+ redirectUrl: meta.redirect_url ?? null,
61
+ clientSecret: meta.client_secret ?? null
62
+ };
63
+ }
64
+ }
65
+ const providerKey = parsed.providerKey ?? await getTopupProvider(em);
66
+ const paymentId = randomUUID();
67
+ const now = /* @__PURE__ */ new Date();
68
+ const topup = em.create(BillingTopup, {
69
+ organizationId: parsed.organizationId,
70
+ tenantId: parsed.tenantId,
71
+ billAccountId: account.id,
72
+ status: "pending",
73
+ amount: formatMoney(parsed.amount),
74
+ currencyCode: account.currencyCode,
75
+ providerKey,
76
+ paymentId,
77
+ sourceRef: parsed.sourceRef ?? null,
78
+ metadata: {
79
+ success_url: parsed.successUrl ?? null,
80
+ cancel_url: parsed.cancelUrl ?? null
81
+ },
82
+ createdAt: now,
83
+ updatedAt: now
84
+ });
85
+ em.persist(topup);
86
+ await em.flush();
87
+ const gateway = ctx.container.resolve("paymentGatewayService");
88
+ const { transaction, session } = await gateway.createPaymentSession({
89
+ providerKey,
90
+ paymentId,
91
+ amount: parsed.amount,
92
+ currencyCode: account.currencyCode,
93
+ captureMethod: "automatic",
94
+ successUrl: parsed.successUrl,
95
+ cancelUrl: parsed.cancelUrl,
96
+ metadata: {
97
+ kind: "prepaid_topup",
98
+ billAccountId: account.id,
99
+ topupId: topup.id
100
+ },
101
+ organizationId: parsed.organizationId,
102
+ tenantId: parsed.tenantId
103
+ });
104
+ topup.gatewayTransactionId = transaction.id;
105
+ topup.metadata = {
106
+ ...topup.metadata,
107
+ redirect_url: session.redirectUrl ?? null,
108
+ client_secret: session.clientSecret ?? null,
109
+ session_id: session.sessionId ?? null
110
+ };
111
+ topup.updatedAt = /* @__PURE__ */ new Date();
112
+ await em.flush();
113
+ await emitCrudSideEffects({
114
+ dataEngine: getDataEngine(ctx),
115
+ action: "created",
116
+ entity: topup,
117
+ identifiers: {
118
+ id: topup.id,
119
+ tenantId: topup.tenantId,
120
+ organizationId: topup.organizationId
121
+ },
122
+ indexer: topupIndexer,
123
+ events: topupEvents
124
+ });
125
+ return {
126
+ topupId: topup.id,
127
+ paymentId,
128
+ status: topup.status,
129
+ transactionId: transaction.id,
130
+ redirectUrl: session.redirectUrl ?? null,
131
+ clientSecret: session.clientSecret ?? null
132
+ };
133
+ }
134
+ };
135
+ registerCommand(createTopupCommand);
136
+ export {
137
+ createTopupCommand
138
+ };
139
+ //# sourceMappingURL=topups.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/billing/commands/topups.ts"],
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport {\n ensureOrganizationScope,\n ensureTenantScope,\n} from '@open-mercato/shared/lib/commands/scope'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'\nimport { BillingAccount, BillingTopup } from '../data/entities'\nimport { billingEntityIds } from '../data/entityIds'\nimport {\n billingTopupCreateSchema,\n type BillingTopupCreateInput,\n} from '../data/validators'\nimport { formatMoney } from '../lib/money'\nimport { getTopupProvider } from '../lib/prepaidConfig'\n\n/**\n * Top-up initiation (SPEC-002 P2).\n *\n * Registers a first-class `BillingTopup` (status=pending), then opens a\n * payment session via `core/payment_gateways`. The balance is NOT credited\n * here \u2014 that happens on the `payment_gateways.payment.captured` event (see\n * subscribers/prepaid-topup-captured.ts), so the money trail is always\n * explainable: a top-up only credits the balance once the gateway confirms\n * capture.\n */\n\nconst topupIndexer = { entityType: billingEntityIds.topup } as const\nconst topupEvents = { module: 'billing', entity: 'topup', persistent: true } as const\n\nfunction getEm(ctx: CommandRuntimeContext): EntityManager {\n return (ctx.container.resolve('em') as EntityManager).fork()\n}\n\nfunction getDataEngine(ctx: CommandRuntimeContext): DataEngine {\n return ctx.container.resolve('dataEngine') as DataEngine\n}\n\nexport type CreateTopupResult = {\n topupId: string\n paymentId: string\n status: string\n transactionId: string | null\n redirectUrl: string | null\n clientSecret: string | null\n}\n\nconst createTopupCommand: CommandHandler<BillingTopupCreateInput, CreateTopupResult> = {\n id: 'billing.topups.create',\n\n async execute(rawInput, ctx) {\n const parsed = billingTopupCreateSchema.parse(rawInput)\n ensureTenantScope(ctx, parsed.tenantId)\n ensureOrganizationScope(ctx, parsed.organizationId)\n\n const em = getEm(ctx)\n const account = await em.findOne(BillingAccount, {\n id: parsed.billAccountId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n deletedAt: null,\n })\n if (!account) {\n throw new CrudHttpError(404, { error: 'Billing account not found' })\n }\n if (account.billingMode !== 'prepaid') {\n throw new CrudHttpError(409, {\n error: 'Top-ups are only available on prepaid accounts',\n code: 'billing.account.not_prepaid',\n })\n }\n\n // Idempotency on the initiating request: a retried POST with the same\n // sourceRef returns the prior (still-pending) top-up + its session links\n // rather than opening a second session.\n if (parsed.sourceRef) {\n const existing = await em.findOne(BillingTopup, {\n tenantId: parsed.tenantId,\n billAccountId: account.id,\n sourceRef: parsed.sourceRef,\n deletedAt: null,\n })\n if (existing) {\n const meta = (existing.metadata as Record<string, unknown> | null) ?? {}\n return {\n topupId: existing.id,\n paymentId: existing.paymentId,\n status: existing.status,\n transactionId: existing.gatewayTransactionId ?? null,\n redirectUrl: (meta.redirect_url as string | null) ?? null,\n clientSecret: (meta.client_secret as string | null) ?? null,\n }\n }\n }\n\n const providerKey = parsed.providerKey ?? (await getTopupProvider(em))\n const paymentId = randomUUID()\n const now = new Date()\n const topup = em.create(BillingTopup, {\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n billAccountId: account.id,\n status: 'pending',\n amount: formatMoney(parsed.amount),\n currencyCode: account.currencyCode,\n providerKey,\n paymentId,\n sourceRef: parsed.sourceRef ?? null,\n metadata: {\n success_url: parsed.successUrl ?? null,\n cancel_url: parsed.cancelUrl ?? null,\n },\n createdAt: now,\n updatedAt: now,\n })\n em.persist(topup)\n await em.flush()\n\n // Open the gateway session. captureMethod 'automatic' so a successful\n // checkout fires `payment.captured` directly (our capture subscriber\n // credits the balance + issues the receipt).\n const gateway = ctx.container.resolve('paymentGatewayService') as PaymentGatewayService\n const { transaction, session } = await gateway.createPaymentSession({\n providerKey,\n paymentId,\n amount: parsed.amount,\n currencyCode: account.currencyCode,\n captureMethod: 'automatic',\n successUrl: parsed.successUrl,\n cancelUrl: parsed.cancelUrl,\n metadata: {\n kind: 'prepaid_topup',\n billAccountId: account.id,\n topupId: topup.id,\n },\n organizationId: parsed.organizationId,\n tenantId: parsed.tenantId,\n })\n\n topup.gatewayTransactionId = transaction.id\n topup.metadata = {\n ...(topup.metadata as Record<string, unknown>),\n redirect_url: session.redirectUrl ?? null,\n client_secret: session.clientSecret ?? null,\n session_id: session.sessionId ?? null,\n }\n topup.updatedAt = new Date()\n await em.flush()\n\n await emitCrudSideEffects({\n dataEngine: getDataEngine(ctx),\n action: 'created',\n entity: topup,\n identifiers: {\n id: topup.id,\n tenantId: topup.tenantId,\n organizationId: topup.organizationId,\n },\n indexer: topupIndexer,\n events: topupEvents,\n })\n\n return {\n topupId: topup.id,\n paymentId,\n status: topup.status,\n transactionId: transaction.id,\n redirectUrl: session.redirectUrl ?? null,\n clientSecret: session.clientSecret ?? null,\n }\n },\n}\n\nregisterCommand(createTopupCommand)\n\nexport { createTopupCommand }\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }