@dainamite/billing 0.17.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 (248) hide show
  1. package/CHANGELOG.md +658 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/dist/modules/billing/acl.js +20 -0
  5. package/dist/modules/billing/acl.js.map +7 -0
  6. package/dist/modules/billing/api/accounts/route.js +182 -0
  7. package/dist/modules/billing/api/accounts/route.js.map +7 -0
  8. package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
  9. package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
  10. package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
  11. package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
  12. package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
  13. package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
  14. package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
  15. package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
  16. package/dist/modules/billing/api/invoices/post/route.js +74 -0
  17. package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
  18. package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
  19. package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
  20. package/dist/modules/billing/api/invoices/route.js +109 -0
  21. package/dist/modules/billing/api/invoices/route.js.map +7 -0
  22. package/dist/modules/billing/api/items/bulk/route.js +74 -0
  23. package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
  24. package/dist/modules/billing/api/items/route.js +224 -0
  25. package/dist/modules/billing/api/items/route.js.map +7 -0
  26. package/dist/modules/billing/api/openapi.js +25 -0
  27. package/dist/modules/billing/api/openapi.js.map +7 -0
  28. package/dist/modules/billing/api/run-outcomes/route.js +122 -0
  29. package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
  30. package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
  31. package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
  32. package/dist/modules/billing/api/runs/route.js +143 -0
  33. package/dist/modules/billing/api/runs/route.js.map +7 -0
  34. package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
  35. package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
  36. package/dist/modules/billing/api/usage/route.js +138 -0
  37. package/dist/modules/billing/api/usage/route.js.map +7 -0
  38. package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
  39. package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
  40. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
  41. package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
  42. package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
  43. package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
  44. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
  45. package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
  46. package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
  47. package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
  48. package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
  49. package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
  50. package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
  51. package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
  52. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
  53. package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
  54. package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
  55. package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
  56. package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
  57. package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
  58. package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
  59. package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
  60. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
  61. package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
  62. package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
  63. package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
  64. package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
  65. package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
  66. package/dist/modules/billing/backend/billing/items/page.js +208 -0
  67. package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
  68. package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
  69. package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
  70. package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
  71. package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
  72. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
  73. package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
  74. package/dist/modules/billing/backend/billing/runs/page.js +341 -0
  75. package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
  76. package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
  77. package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
  78. package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
  79. package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
  80. package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
  81. package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
  82. package/dist/modules/billing/ce.js +56 -0
  83. package/dist/modules/billing/ce.js.map +7 -0
  84. package/dist/modules/billing/cli.js +67 -0
  85. package/dist/modules/billing/cli.js.map +7 -0
  86. package/dist/modules/billing/commands/accounts.js +157 -0
  87. package/dist/modules/billing/commands/accounts.js.map +7 -0
  88. package/dist/modules/billing/commands/index.js +6 -0
  89. package/dist/modules/billing/commands/index.js.map +7 -0
  90. package/dist/modules/billing/commands/invoices.js +401 -0
  91. package/dist/modules/billing/commands/invoices.js.map +7 -0
  92. package/dist/modules/billing/commands/items.js +317 -0
  93. package/dist/modules/billing/commands/items.js.map +7 -0
  94. package/dist/modules/billing/commands/runs.js +123 -0
  95. package/dist/modules/billing/commands/runs.js.map +7 -0
  96. package/dist/modules/billing/commands/usage.js +88 -0
  97. package/dist/modules/billing/commands/usage.js.map +7 -0
  98. package/dist/modules/billing/components/AccountForm.js +420 -0
  99. package/dist/modules/billing/components/AccountForm.js.map +7 -0
  100. package/dist/modules/billing/components/DetailFields.js +37 -0
  101. package/dist/modules/billing/components/DetailFields.js.map +7 -0
  102. package/dist/modules/billing/components/ItemForm.js +440 -0
  103. package/dist/modules/billing/components/ItemForm.js.map +7 -0
  104. package/dist/modules/billing/components/LineFormDialog.js +195 -0
  105. package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
  106. package/dist/modules/billing/components/UsageForm.js +262 -0
  107. package/dist/modules/billing/components/UsageForm.js.map +7 -0
  108. package/dist/modules/billing/data/entities.js +433 -0
  109. package/dist/modules/billing/data/entities.js.map +7 -0
  110. package/dist/modules/billing/data/entityIds.js +12 -0
  111. package/dist/modules/billing/data/entityIds.js.map +7 -0
  112. package/dist/modules/billing/data/validators.js +396 -0
  113. package/dist/modules/billing/data/validators.js.map +7 -0
  114. package/dist/modules/billing/di.js +6 -0
  115. package/dist/modules/billing/di.js.map +7 -0
  116. package/dist/modules/billing/events.js +35 -0
  117. package/dist/modules/billing/events.js.map +7 -0
  118. package/dist/modules/billing/i18n/en.json +336 -0
  119. package/dist/modules/billing/i18n/pl.json +336 -0
  120. package/dist/modules/billing/index.js +17 -0
  121. package/dist/modules/billing/index.js.map +7 -0
  122. package/dist/modules/billing/lib/billPeriod.js +101 -0
  123. package/dist/modules/billing/lib/billPeriod.js.map +7 -0
  124. package/dist/modules/billing/lib/billRunEngine.js +256 -0
  125. package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
  126. package/dist/modules/billing/lib/billRunLabel.js +8 -0
  127. package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
  128. package/dist/modules/billing/lib/idempotency.js +13 -0
  129. package/dist/modules/billing/lib/idempotency.js.map +7 -0
  130. package/dist/modules/billing/lib/invoiceStatus.js +38 -0
  131. package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
  132. package/dist/modules/billing/lib/invoiceWriter.js +153 -0
  133. package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
  134. package/dist/modules/billing/lib/itemSelector.js +77 -0
  135. package/dist/modules/billing/lib/itemSelector.js.map +7 -0
  136. package/dist/modules/billing/lib/seeds/demo.js +130 -0
  137. package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
  138. package/dist/modules/billing/lib/tenantLock.js +32 -0
  139. package/dist/modules/billing/lib/tenantLock.js.map +7 -0
  140. package/dist/modules/billing/lib/usageRater.js +150 -0
  141. package/dist/modules/billing/lib/usageRater.js.map +7 -0
  142. package/dist/modules/billing/lib/usageRunner.js +82 -0
  143. package/dist/modules/billing/lib/usageRunner.js.map +7 -0
  144. package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
  145. package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
  146. package/dist/modules/billing/setup.js +190 -0
  147. package/dist/modules/billing/setup.js.map +7 -0
  148. package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
  149. package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
  150. package/dist/modules/billing/translations.js +5 -0
  151. package/dist/modules/billing/translations.js.map +7 -0
  152. package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
  153. package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
  154. package/package.json +148 -0
  155. package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
  156. package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
  157. package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
  158. package/src/modules/billing/__integration__/meta.ts +4 -0
  159. package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
  160. package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
  161. package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
  162. package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
  163. package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
  164. package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
  165. package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
  166. package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
  167. package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
  168. package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
  169. package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
  170. package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
  171. package/src/modules/billing/__tests__/setup.test.ts +107 -0
  172. package/src/modules/billing/__tests__/validators.test.ts +594 -0
  173. package/src/modules/billing/acl.ts +16 -0
  174. package/src/modules/billing/api/accounts/route.ts +198 -0
  175. package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
  176. package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
  177. package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
  178. package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
  179. package/src/modules/billing/api/invoices/post/route.ts +97 -0
  180. package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
  181. package/src/modules/billing/api/invoices/route.ts +167 -0
  182. package/src/modules/billing/api/items/bulk/route.ts +92 -0
  183. package/src/modules/billing/api/items/route.ts +255 -0
  184. package/src/modules/billing/api/openapi.ts +33 -0
  185. package/src/modules/billing/api/run-outcomes/route.ts +149 -0
  186. package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
  187. package/src/modules/billing/api/runs/route.ts +170 -0
  188. package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
  189. package/src/modules/billing/api/usage/route.ts +148 -0
  190. package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
  191. package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
  192. package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
  193. package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
  194. package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
  195. package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
  196. package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
  197. package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
  198. package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
  199. package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
  200. package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
  201. package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
  202. package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
  203. package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
  204. package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
  205. package/src/modules/billing/backend/billing/items/page.tsx +272 -0
  206. package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
  207. package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
  208. package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
  209. package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
  210. package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
  211. package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
  212. package/src/modules/billing/ce.ts +74 -0
  213. package/src/modules/billing/cli.ts +89 -0
  214. package/src/modules/billing/commands/accounts.ts +205 -0
  215. package/src/modules/billing/commands/index.ts +13 -0
  216. package/src/modules/billing/commands/invoices.ts +542 -0
  217. package/src/modules/billing/commands/items.ts +438 -0
  218. package/src/modules/billing/commands/runs.ts +166 -0
  219. package/src/modules/billing/commands/usage.ts +118 -0
  220. package/src/modules/billing/components/AccountForm.tsx +517 -0
  221. package/src/modules/billing/components/DetailFields.tsx +60 -0
  222. package/src/modules/billing/components/ItemForm.tsx +558 -0
  223. package/src/modules/billing/components/LineFormDialog.tsx +253 -0
  224. package/src/modules/billing/components/UsageForm.tsx +326 -0
  225. package/src/modules/billing/data/entities.ts +533 -0
  226. package/src/modules/billing/data/entityIds.ts +25 -0
  227. package/src/modules/billing/data/validators.ts +562 -0
  228. package/src/modules/billing/di.ts +14 -0
  229. package/src/modules/billing/events.ts +47 -0
  230. package/src/modules/billing/i18n/en.json +336 -0
  231. package/src/modules/billing/i18n/pl.json +336 -0
  232. package/src/modules/billing/index.ts +22 -0
  233. package/src/modules/billing/lib/billPeriod.ts +181 -0
  234. package/src/modules/billing/lib/billRunEngine.ts +416 -0
  235. package/src/modules/billing/lib/billRunLabel.ts +14 -0
  236. package/src/modules/billing/lib/idempotency.ts +46 -0
  237. package/src/modules/billing/lib/invoiceStatus.ts +61 -0
  238. package/src/modules/billing/lib/invoiceWriter.ts +260 -0
  239. package/src/modules/billing/lib/itemSelector.ts +154 -0
  240. package/src/modules/billing/lib/seeds/demo.ts +199 -0
  241. package/src/modules/billing/lib/tenantLock.ts +87 -0
  242. package/src/modules/billing/lib/usageRater.ts +250 -0
  243. package/src/modules/billing/lib/usageRunner.ts +189 -0
  244. package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
  245. package/src/modules/billing/setup.ts +252 -0
  246. package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
  247. package/src/modules/billing/translations.ts +9 -0
  248. package/src/modules/billing/workers/reap-stale-bill-runs.ts +57 -0
@@ -0,0 +1,118 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands/types'
3
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
4
+ import {
5
+ ensureOrganizationScope,
6
+ ensureTenantScope,
7
+ } from '@open-mercato/shared/lib/commands/scope'
8
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
9
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
11
+ import { BillingAccount, BillingAccountUsage } from '../data/entities'
12
+ import { billingEntityIds } from '../data/entityIds'
13
+ import { findBySourceRef } from '../lib/idempotency'
14
+ import {
15
+ billingUsageCreateSchema,
16
+ type BillingUsageCreateInput,
17
+ } from '../data/validators'
18
+
19
+ /**
20
+ * Usage ingest — append-only.
21
+ *
22
+ * Usage records are immutable once persisted. The Bill Run engine
23
+ * (Phase 2) writes `rated_in_bill_run_id` on them but the data itself
24
+ * never changes. There is therefore no update or delete command — if
25
+ * an external system mis-aggregates and needs to "correct" a record,
26
+ * they POST a compensating record (negative quantity is not allowed
27
+ * by the validator; the connector posts a `one_time` Billing Item
28
+ * credit instead, which is the spec's discount/refund model).
29
+ *
30
+ * Create is idempotent via `source_ref` — same shape as items.
31
+ */
32
+
33
+ const usageIndexer = { entityType: billingEntityIds.usage } as const
34
+ const usageEvents = { module: 'billing', entity: 'usage', persistent: true } as const
35
+
36
+ function getEm(ctx: CommandRuntimeContext): EntityManager {
37
+ return (ctx.container.resolve('em') as EntityManager).fork()
38
+ }
39
+
40
+ function getDataEngine(ctx: CommandRuntimeContext): DataEngine {
41
+ return ctx.container.resolve('dataEngine') as DataEngine
42
+ }
43
+
44
+ const createUsageCommand: CommandHandler<
45
+ BillingUsageCreateInput,
46
+ { id: string; deduplicated: boolean }
47
+ > = {
48
+ id: 'billing.usage.create',
49
+
50
+ async execute(rawInput, ctx) {
51
+ const parsed = billingUsageCreateSchema.parse(rawInput)
52
+ ensureTenantScope(ctx, parsed.tenantId)
53
+ ensureOrganizationScope(ctx, parsed.organizationId)
54
+
55
+ const em = getEm(ctx)
56
+ const account = await em.findOne(BillingAccount, {
57
+ id: parsed.billAccountId,
58
+ tenantId: parsed.tenantId,
59
+ organizationId: parsed.organizationId,
60
+ deletedAt: null,
61
+ })
62
+ if (!account) {
63
+ throw new CrudHttpError(404, { error: 'Billing account not found' })
64
+ }
65
+
66
+ if (parsed.sourceRef) {
67
+ const existing = await findBySourceRef(em, BillingAccountUsage, {
68
+ tenantId: parsed.tenantId,
69
+ billAccountId: parsed.billAccountId,
70
+ sourceRef: parsed.sourceRef,
71
+ })
72
+ if (existing) {
73
+ return { id: existing.id, deduplicated: true }
74
+ }
75
+ }
76
+
77
+ const now = new Date()
78
+ const entity = em.create(BillingAccountUsage, {
79
+ organizationId: parsed.organizationId,
80
+ tenantId: parsed.tenantId,
81
+ billAccountId: parsed.billAccountId,
82
+ uomCode: parsed.uomCode,
83
+ // numeric(18,4) — MikroORM stores numeric columns as string in app
84
+ // code. JSON arrives as number; coerce here so the persisted shape
85
+ // matches what later reads expect.
86
+ quantity: parsed.quantity.toFixed(4),
87
+ periodStart: parsed.periodStart,
88
+ periodEnd: parsed.periodEnd,
89
+ lineDescription: parsed.lineDescription ?? null,
90
+ sourceRef: parsed.sourceRef ?? null,
91
+ ratedInBillRunId: null,
92
+ isActive: true,
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ })
96
+ em.persist(entity)
97
+ await em.flush()
98
+
99
+ await emitCrudSideEffects({
100
+ dataEngine: getDataEngine(ctx),
101
+ action: 'created',
102
+ entity,
103
+ identifiers: {
104
+ id: entity.id,
105
+ tenantId: entity.tenantId,
106
+ organizationId: entity.organizationId,
107
+ },
108
+ indexer: usageIndexer,
109
+ events: usageEvents,
110
+ })
111
+
112
+ return { id: entity.id, deduplicated: false }
113
+ },
114
+ }
115
+
116
+ registerCommand(createUsageCommand)
117
+
118
+ export { createUsageCommand }
@@ -0,0 +1,517 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Button } from '@open-mercato/ui/primitives/button'
4
+ import { Input } from '@open-mercato/ui/primitives/input'
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@open-mercato/ui/primitives/select'
12
+ import { Switch } from '@open-mercato/ui/primitives/switch'
13
+ import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
14
+ import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs'
15
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
16
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
17
+
18
+ /**
19
+ * Shared form for Billing Account create + edit.
20
+ *
21
+ * Hand-rolled (not CrudForm) on purpose — the form's `invoiceAddress`
22
+ * jsonb sub-object would need a custom-field renderer in the
23
+ * CrudForm DSL, which is heavier than just rendering five inputs
24
+ * inline. The trade-off is documented in `xd-249-billing-spec.md`
25
+ * Phase 4d notes.
26
+ *
27
+ * UX per the OM UI AGENTS.md:
28
+ * - `Cmd/Ctrl + Enter` submits.
29
+ * - Buttons use the `Button` / `Switch` primitives, never raw HTML.
30
+ * - Footer hint row uses `Kbd` / `KbdShortcut`.
31
+ *
32
+ * `mode='create'` shows the always-required fields. `mode='edit'`
33
+ * disables `customerId` and `currencyCode` per the spec
34
+ * ("immutable per spec — create a new account if the customer FK
35
+ * changes" — see `billingAccountUpdateSchema`).
36
+ */
37
+
38
+ export type AccountFormValues = {
39
+ customerId: string
40
+ name: string
41
+ currencyCode: string
42
+ billCycle: 'weekly' | 'monthly' | 'quarterly' | 'annually'
43
+ billCycleAnchor: string
44
+ invoiceEmail: string
45
+ invoiceLanguage: string
46
+ taxId: string
47
+ invoiceAddress: {
48
+ line1: string
49
+ line2: string
50
+ city: string
51
+ postal_code: string
52
+ country: string
53
+ }
54
+ nextBillDate: string
55
+ isActive: boolean
56
+ }
57
+
58
+ export type AccountFormProps = {
59
+ mode: 'create' | 'edit'
60
+ initial?: Partial<AccountFormValues>
61
+ submitting?: boolean
62
+ onSubmit: (values: AccountFormValues) => Promise<void> | void
63
+ onCancel: () => void
64
+ }
65
+
66
+ const EMPTY_VALUES: AccountFormValues = {
67
+ customerId: '',
68
+ name: '',
69
+ currencyCode: 'EUR',
70
+ billCycle: 'monthly',
71
+ billCycleAnchor: '1',
72
+ invoiceEmail: '',
73
+ invoiceLanguage: 'en',
74
+ taxId: '',
75
+ invoiceAddress: {
76
+ line1: '',
77
+ line2: '',
78
+ city: '',
79
+ postal_code: '',
80
+ country: 'PL',
81
+ },
82
+ nextBillDate: '',
83
+ isActive: true,
84
+ }
85
+
86
+ function todayIso(): string {
87
+ return new Date().toISOString().slice(0, 10)
88
+ }
89
+
90
+ function mergeInitial(initial: Partial<AccountFormValues> | undefined): AccountFormValues {
91
+ if (!initial) return { ...EMPTY_VALUES, nextBillDate: todayIso() }
92
+ return {
93
+ ...EMPTY_VALUES,
94
+ nextBillDate: EMPTY_VALUES.nextBillDate || todayIso(),
95
+ ...initial,
96
+ invoiceAddress: {
97
+ ...EMPTY_VALUES.invoiceAddress,
98
+ ...(initial.invoiceAddress ?? {}),
99
+ },
100
+ }
101
+ }
102
+
103
+ export function AccountForm({
104
+ mode,
105
+ initial,
106
+ submitting = false,
107
+ onSubmit,
108
+ onCancel,
109
+ }: AccountFormProps) {
110
+ const t = useT()
111
+ const [values, setValues] = React.useState<AccountFormValues>(() => mergeInitial(initial))
112
+ const [error, setError] = React.useState<string | null>(null)
113
+ const isEdit = mode === 'edit'
114
+
115
+ // Label cache for the customer combobox — populated as the user searches,
116
+ // so the picker can render "Acme Telecom" after selection instead of the
117
+ // raw UUID we send to the API.
118
+ const customerLabelCacheRef = React.useRef<Map<string, ComboboxOption>>(new Map())
119
+
120
+ const loadCustomerOptions = React.useCallback(
121
+ async (query?: string): Promise<ComboboxOption[]> => {
122
+ const params = new URLSearchParams({ page: '1', pageSize: '20' })
123
+ const q = (query ?? '').trim()
124
+ if (q) params.set('search', q)
125
+ const call = await apiCall<unknown>(`/api/customers/companies?${params.toString()}`)
126
+ if (!call.ok || call.result == null) return []
127
+ const raw = call.result as { items?: unknown } | unknown[]
128
+ const items: Array<Record<string, unknown>> = Array.isArray(raw)
129
+ ? (raw as Array<Record<string, unknown>>)
130
+ : Array.isArray((raw as { items?: unknown }).items)
131
+ ? ((raw as { items: unknown[] }).items as Array<Record<string, unknown>>)
132
+ : []
133
+ const options: ComboboxOption[] = []
134
+ for (const c of items) {
135
+ const id = typeof c.id === 'string' ? c.id : c.id != null ? String(c.id) : ''
136
+ if (!id) continue
137
+ const label = String(c.display_name ?? c.displayName ?? c.name ?? id)
138
+ const emailRaw = c.primary_email ?? c.email
139
+ const description = typeof emailRaw === 'string' && emailRaw ? emailRaw : null
140
+ const opt: ComboboxOption = { value: id, label, description }
141
+ customerLabelCacheRef.current.set(id, opt)
142
+ options.push(opt)
143
+ }
144
+ return options
145
+ },
146
+ [],
147
+ )
148
+
149
+ const resolveCustomerLabel = React.useCallback(
150
+ (id: string) => customerLabelCacheRef.current.get(id)?.label ?? id,
151
+ [],
152
+ )
153
+ const resolveCustomerDescription = React.useCallback(
154
+ (id: string) => customerLabelCacheRef.current.get(id)?.description ?? null,
155
+ [],
156
+ )
157
+
158
+ // Reset when the parent swaps `initial` (e.g. after loading the edit row).
159
+ const initialKey = React.useMemo(
160
+ () => JSON.stringify(initial ?? null),
161
+ [initial],
162
+ )
163
+ React.useEffect(() => {
164
+ setValues(mergeInitial(initial))
165
+ }, [initialKey, initial])
166
+
167
+ const set = React.useCallback(
168
+ <K extends keyof AccountFormValues>(key: K, value: AccountFormValues[K]) => {
169
+ setValues((prev) => ({ ...prev, [key]: value }))
170
+ },
171
+ [],
172
+ )
173
+
174
+ const setAddress = React.useCallback(
175
+ <K extends keyof AccountFormValues['invoiceAddress']>(
176
+ key: K,
177
+ value: AccountFormValues['invoiceAddress'][K],
178
+ ) => {
179
+ setValues((prev) => ({
180
+ ...prev,
181
+ invoiceAddress: { ...prev.invoiceAddress, [key]: value },
182
+ }))
183
+ },
184
+ [],
185
+ )
186
+
187
+ const handleSubmit = React.useCallback(async () => {
188
+ if (!isEdit && !values.customerId.trim()) {
189
+ setError(t('billing.accounts.form.error.customerId_required', 'Please select a customer'))
190
+ return
191
+ }
192
+ if (!values.name.trim()) {
193
+ setError(t('billing.accounts.form.error.name_required', 'Name is required'))
194
+ return
195
+ }
196
+ if (!isEdit && values.currencyCode.trim().length !== 3) {
197
+ setError(t('billing.accounts.form.error.currency_invalid', 'Currency must be a 3-letter ISO code'))
198
+ return
199
+ }
200
+ if (!values.invoiceEmail.includes('@')) {
201
+ setError(t('billing.accounts.form.error.email_invalid', 'Invoice email looks invalid'))
202
+ return
203
+ }
204
+ if (
205
+ !values.invoiceAddress.line1.trim() ||
206
+ !values.invoiceAddress.city.trim() ||
207
+ !values.invoiceAddress.postal_code.trim()
208
+ ) {
209
+ setError(
210
+ t(
211
+ 'billing.accounts.form.error.address_required',
212
+ 'Invoice address (line 1 + city + postal code) is required',
213
+ ),
214
+ )
215
+ return
216
+ }
217
+ const country = values.invoiceAddress.country.trim()
218
+ if (country.length < 2 || country.length > 3) {
219
+ setError(
220
+ t(
221
+ 'billing.accounts.form.error.country_invalid',
222
+ 'Country must be a 2- or 3-letter ISO 3166 code',
223
+ ),
224
+ )
225
+ return
226
+ }
227
+ if (!values.nextBillDate) {
228
+ setError(t('billing.accounts.form.error.next_bill_date_required', 'Next bill date is required'))
229
+ return
230
+ }
231
+ setError(null)
232
+ await onSubmit(values)
233
+ }, [isEdit, onSubmit, t, values])
234
+
235
+ const handleKeyDown = React.useCallback(
236
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
237
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
238
+ event.preventDefault()
239
+ void handleSubmit()
240
+ }
241
+ },
242
+ [handleSubmit],
243
+ )
244
+
245
+ return (
246
+ <div className="rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown}>
247
+ <div className="grid grid-cols-2 gap-4">
248
+ <label className="flex flex-col gap-1 text-sm">
249
+ <span className="text-muted-foreground">
250
+ {isEdit
251
+ ? t('billing.accounts.form.customer_id', 'Customer ID')
252
+ : t('billing.accounts.form.customer', 'Customer')}
253
+ {!isEdit ? <span className="text-destructive"> *</span> : null}
254
+ </span>
255
+ {isEdit ? (
256
+ <Input value={values.customerId} disabled />
257
+ ) : (
258
+ <ComboboxInput
259
+ value={values.customerId}
260
+ onChange={(next) => {
261
+ setValues((prev) => {
262
+ const customerLabel = customerLabelCacheRef.current.get(next)?.label ?? ''
263
+ // Auto-fill Name with the customer's display name on the first
264
+ // pick. We leave it alone if the operator has already typed
265
+ // something — one customer can have multiple billing accounts
266
+ // (e.g. "Acme — production" / "Acme — staging").
267
+ const shouldFillName = customerLabel && !prev.name.trim()
268
+ return {
269
+ ...prev,
270
+ customerId: next,
271
+ name: shouldFillName ? customerLabel : prev.name,
272
+ }
273
+ })
274
+ }}
275
+ loadSuggestions={loadCustomerOptions}
276
+ resolveLabel={resolveCustomerLabel}
277
+ resolveDescription={resolveCustomerDescription}
278
+ placeholder={t(
279
+ 'billing.accounts.form.customer.placeholder',
280
+ 'Search customers by name or email…',
281
+ )}
282
+ allowCustomValues={false}
283
+ />
284
+ )}
285
+ {isEdit ? (
286
+ <span className="text-xs text-muted-foreground">
287
+ {t('billing.accounts.form.customer_id.locked', 'Immutable — create a new account to re-link the customer.')}
288
+ </span>
289
+ ) : null}
290
+ </label>
291
+
292
+ <label className="flex flex-col gap-1 text-sm">
293
+ <span className="text-muted-foreground">
294
+ {t('billing.accounts.form.name', 'Name')}
295
+ <span className="text-destructive"> *</span>
296
+ </span>
297
+ <Input
298
+ value={values.name}
299
+ placeholder={
300
+ !isEdit
301
+ ? t(
302
+ 'billing.accounts.form.name.placeholder',
303
+ 'Auto-filled from customer — override e.g. "Acme — production"',
304
+ )
305
+ : undefined
306
+ }
307
+ onChange={(event) => set('name', event.currentTarget.value)}
308
+ />
309
+ </label>
310
+
311
+ <label className="flex flex-col gap-1 text-sm">
312
+ <span className="text-muted-foreground">
313
+ {t('billing.accounts.form.currency', 'Currency (ISO 4217)')}
314
+ {!isEdit ? <span className="text-destructive"> *</span> : null}
315
+ </span>
316
+ <Input
317
+ value={values.currencyCode}
318
+ disabled={isEdit}
319
+ maxLength={3}
320
+ onChange={(event) =>
321
+ set('currencyCode', event.currentTarget.value.toUpperCase())
322
+ }
323
+ />
324
+ {isEdit ? (
325
+ <span className="text-xs text-muted-foreground">
326
+ {t('billing.accounts.form.currency.locked', 'Immutable — frozen at account create time.')}
327
+ </span>
328
+ ) : null}
329
+ </label>
330
+
331
+ <label className="flex flex-col gap-1 text-sm">
332
+ <span className="text-muted-foreground">
333
+ {t('billing.accounts.form.bill_cycle', 'Bill cycle')}
334
+ </span>
335
+ <Select
336
+ value={values.billCycle}
337
+ onValueChange={(next) =>
338
+ set('billCycle', next as AccountFormValues['billCycle'])
339
+ }
340
+ >
341
+ <SelectTrigger>
342
+ <SelectValue />
343
+ </SelectTrigger>
344
+ <SelectContent>
345
+ <SelectItem value="weekly">{t('billing.cycle.weekly', 'Weekly')}</SelectItem>
346
+ <SelectItem value="monthly">{t('billing.cycle.monthly', 'Monthly')}</SelectItem>
347
+ <SelectItem value="quarterly">{t('billing.cycle.quarterly', 'Quarterly')}</SelectItem>
348
+ <SelectItem value="annually">{t('billing.cycle.annually', 'Annually')}</SelectItem>
349
+ </SelectContent>
350
+ </Select>
351
+ </label>
352
+
353
+ <label className="flex flex-col gap-1 text-sm">
354
+ <span className="text-muted-foreground">
355
+ {t('billing.accounts.form.cycle_anchor', 'Cycle anchor')}
356
+ </span>
357
+ <Input
358
+ value={values.billCycleAnchor}
359
+ placeholder={
360
+ values.billCycle === 'weekly'
361
+ ? t('billing.accounts.form.cycle_anchor.weekly_hint', 'mon | tue | … | sun')
362
+ : t('billing.accounts.form.cycle_anchor.month_hint', '1 — 28')
363
+ }
364
+ onChange={(event) => set('billCycleAnchor', event.currentTarget.value)}
365
+ />
366
+ </label>
367
+
368
+ <label className="flex flex-col gap-1 text-sm">
369
+ <span className="text-muted-foreground">
370
+ {t('billing.accounts.form.next_bill_date', 'Next bill date')}
371
+ <span className="text-destructive"> *</span>
372
+ </span>
373
+ <Input
374
+ type="date"
375
+ value={values.nextBillDate}
376
+ onChange={(event) => set('nextBillDate', event.currentTarget.value)}
377
+ />
378
+ </label>
379
+
380
+ <label className="flex flex-col gap-1 text-sm">
381
+ <span className="text-muted-foreground">
382
+ {t('billing.accounts.form.invoice_email', 'Invoice email')}
383
+ <span className="text-destructive"> *</span>
384
+ </span>
385
+ <Input
386
+ type="email"
387
+ value={values.invoiceEmail}
388
+ onChange={(event) => set('invoiceEmail', event.currentTarget.value)}
389
+ />
390
+ </label>
391
+
392
+ <label className="flex flex-col gap-1 text-sm">
393
+ <span className="text-muted-foreground">
394
+ {t('billing.accounts.form.invoice_language', 'Invoice language (ISO 639-1)')}
395
+ </span>
396
+ <Input
397
+ value={values.invoiceLanguage}
398
+ maxLength={5}
399
+ onChange={(event) =>
400
+ set('invoiceLanguage', event.currentTarget.value.toLowerCase())
401
+ }
402
+ />
403
+ </label>
404
+
405
+ <label className="flex flex-col gap-1 text-sm col-span-2">
406
+ <span className="text-muted-foreground">
407
+ {t('billing.accounts.form.tax_id', 'Tax ID (NIP / VATIN, optional)')}
408
+ </span>
409
+ <Input
410
+ value={values.taxId}
411
+ onChange={(event) => set('taxId', event.currentTarget.value)}
412
+ />
413
+ </label>
414
+ </div>
415
+
416
+ <h3 className="text-sm font-semibold mt-6 mb-2">
417
+ {t('billing.accounts.form.address.title', 'Invoice address')}
418
+ </h3>
419
+ <div className="grid grid-cols-2 gap-4">
420
+ <label className="flex flex-col gap-1 text-sm col-span-2">
421
+ <span className="text-muted-foreground">
422
+ {t('billing.accounts.form.address.line1', 'Line 1')}
423
+ <span className="text-destructive"> *</span>
424
+ </span>
425
+ <Input
426
+ value={values.invoiceAddress.line1}
427
+ onChange={(event) => setAddress('line1', event.currentTarget.value)}
428
+ />
429
+ </label>
430
+ <label className="flex flex-col gap-1 text-sm col-span-2">
431
+ <span className="text-muted-foreground">
432
+ {t('billing.accounts.form.address.line2', 'Line 2')}
433
+ </span>
434
+ <Input
435
+ value={values.invoiceAddress.line2}
436
+ onChange={(event) => setAddress('line2', event.currentTarget.value)}
437
+ />
438
+ </label>
439
+ <label className="flex flex-col gap-1 text-sm">
440
+ <span className="text-muted-foreground">
441
+ {t('billing.accounts.form.address.city', 'City')}
442
+ <span className="text-destructive"> *</span>
443
+ </span>
444
+ <Input
445
+ value={values.invoiceAddress.city}
446
+ onChange={(event) => setAddress('city', event.currentTarget.value)}
447
+ />
448
+ </label>
449
+ <label className="flex flex-col gap-1 text-sm">
450
+ <span className="text-muted-foreground">
451
+ {t('billing.accounts.form.address.postal_code', 'Postal code')}
452
+ <span className="text-destructive"> *</span>
453
+ </span>
454
+ <Input
455
+ value={values.invoiceAddress.postal_code}
456
+ onChange={(event) => setAddress('postal_code', event.currentTarget.value)}
457
+ />
458
+ </label>
459
+ <label className="flex flex-col gap-1 text-sm">
460
+ <span className="text-muted-foreground">
461
+ {t('billing.accounts.form.address.country', 'Country (ISO 3166)')}
462
+ <span className="text-destructive"> *</span>
463
+ </span>
464
+ <Input
465
+ value={values.invoiceAddress.country}
466
+ maxLength={3}
467
+ onChange={(event) =>
468
+ setAddress('country', event.currentTarget.value.toUpperCase())
469
+ }
470
+ />
471
+ </label>
472
+ </div>
473
+
474
+ {isEdit ? (
475
+ <div className="mt-6 flex items-center justify-between gap-3">
476
+ <div>
477
+ <div className="text-sm font-medium">
478
+ {t('billing.accounts.form.active', 'Active')}
479
+ </div>
480
+ <div className="text-xs text-muted-foreground">
481
+ {t(
482
+ 'billing.accounts.form.active.help',
483
+ 'Inactive accounts stay billable until soft-deleted — toggle for visibility filtering only.',
484
+ )}
485
+ </div>
486
+ </div>
487
+ <Switch
488
+ checked={values.isActive}
489
+ onCheckedChange={(next) => set('isActive', next)}
490
+ />
491
+ </div>
492
+ ) : null}
493
+
494
+ {error ? <div className="mt-4 text-sm text-destructive">{error}</div> : null}
495
+
496
+ <div className="mt-6 flex items-center justify-between gap-3">
497
+ <span className="text-xs text-muted-foreground">
498
+ <KbdShortcut keys={['Ctrl/⌘', 'Enter']} />{' '}
499
+ {t('billing.accounts.form.submit_hint', 'to save,')} <Kbd>Esc</Kbd>{' '}
500
+ {t('billing.accounts.form.cancel_hint', 'to discard')}
501
+ </span>
502
+ <div className="flex items-center gap-2">
503
+ <Button type="button" variant="outline" onClick={onCancel}>
504
+ {t('billing.common.cancel', 'Cancel')}
505
+ </Button>
506
+ <Button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
507
+ {submitting
508
+ ? t('billing.accounts.form.saving', 'Saving…')
509
+ : isEdit
510
+ ? t('billing.accounts.form.save', 'Save changes')
511
+ : t('billing.accounts.form.create', 'Create account')}
512
+ </Button>
513
+ </div>
514
+ </div>
515
+ </div>
516
+ )
517
+ }
@@ -0,0 +1,60 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+
4
+ /**
5
+ * OM/CPQ-standard read-only detail rendering.
6
+ *
7
+ * One bordered card holding a 2-col grid of label-above-value blocks —
8
+ * mirrors the CPQ offering detail "General" section so billing detail
9
+ * pages match the rest of the admin shell.
10
+ */
11
+
12
+ export function DetailCard({ children }: { children: React.ReactNode }) {
13
+ return (
14
+ <div className="rounded-lg border bg-card p-6">
15
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">{children}</div>
16
+ </div>
17
+ )
18
+ }
19
+
20
+ export function DetailField({
21
+ label,
22
+ children,
23
+ fullWidth = false,
24
+ }: {
25
+ label: string
26
+ children: React.ReactNode
27
+ fullWidth?: boolean
28
+ }) {
29
+ return (
30
+ <div className={fullWidth ? 'sm:col-span-2' : undefined}>
31
+ <label className="block text-sm font-medium mb-1">{label}</label>
32
+ <div className="text-sm py-1">{children}</div>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ /** Compact stat block — card with an optional heading + metric tiles. */
38
+ export function StatCard({
39
+ title,
40
+ children,
41
+ }: {
42
+ title?: string
43
+ children: React.ReactNode
44
+ }) {
45
+ return (
46
+ <div className="rounded-lg border bg-card p-4">
47
+ {title ? <h3 className="text-sm font-medium mb-3">{title}</h3> : null}
48
+ <div className="flex flex-wrap gap-x-10 gap-y-4">{children}</div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function Stat({ label, value }: { label: string; value: React.ReactNode }) {
54
+ return (
55
+ <div>
56
+ <p className="text-xs text-muted-foreground">{label}</p>
57
+ <p className="text-lg font-semibold">{value}</p>
58
+ </div>
59
+ )
60
+ }