@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.
- package/CHANGELOG.md +658 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/modules/billing/acl.js +20 -0
- package/dist/modules/billing/acl.js.map +7 -0
- package/dist/modules/billing/api/accounts/route.js +182 -0
- package/dist/modules/billing/api/accounts/route.js.map +7 -0
- package/dist/modules/billing/api/export/account/[id]/route.js +170 -0
- package/dist/modules/billing/api/export/account/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/[id]/route.js +85 -0
- package/dist/modules/billing/api/invoices/[id]/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/add-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/add-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js +78 -0
- package/dist/modules/billing/api/invoices/edit-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/post/route.js +74 -0
- package/dist/modules/billing/api/invoices/post/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js +69 -0
- package/dist/modules/billing/api/invoices/remove-line/route.js.map +7 -0
- package/dist/modules/billing/api/invoices/route.js +109 -0
- package/dist/modules/billing/api/invoices/route.js.map +7 -0
- package/dist/modules/billing/api/items/bulk/route.js +74 -0
- package/dist/modules/billing/api/items/bulk/route.js.map +7 -0
- package/dist/modules/billing/api/items/route.js +224 -0
- package/dist/modules/billing/api/items/route.js.map +7 -0
- package/dist/modules/billing/api/openapi.js +25 -0
- package/dist/modules/billing/api/openapi.js.map +7 -0
- package/dist/modules/billing/api/run-outcomes/route.js +122 -0
- package/dist/modules/billing/api/run-outcomes/route.js.map +7 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js +70 -0
- package/dist/modules/billing/api/runs/retry-failed/route.js.map +7 -0
- package/dist/modules/billing/api/runs/route.js +143 -0
- package/dist/modules/billing/api/runs/route.js.map +7 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js +65 -0
- package/dist/modules/billing/api/test-invoices/wipe/route.js.map +7 -0
- package/dist/modules/billing/api/usage/route.js +138 -0
- package/dist/modules/billing/api/usage/route.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js +402 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js +82 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/accounts/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.js +173 -0
- package/dist/modules/billing/backend/billing/accounts/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/accounts/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js +500 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/invoices/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.js +187 -0
- package/dist/modules/billing/backend/billing/invoices/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/invoices/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js +265 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.js +90 -0
- package/dist/modules/billing/backend/billing/items/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/items/create/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.js +208 -0
- package/dist/modules/billing/backend/billing/items/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/items/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js +293 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js +21 -0
- package/dist/modules/billing/backend/billing/runs/[id]/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.js +341 -0
- package/dist/modules/billing/backend/billing/runs/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js +18 -0
- package/dist/modules/billing/backend/billing/runs/page.meta.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js +100 -0
- package/dist/modules/billing/backend/billing/usage/create/page.js.map +7 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js +25 -0
- package/dist/modules/billing/backend/billing/usage/create/page.meta.js.map +7 -0
- package/dist/modules/billing/ce.js +56 -0
- package/dist/modules/billing/ce.js.map +7 -0
- package/dist/modules/billing/cli.js +67 -0
- package/dist/modules/billing/cli.js.map +7 -0
- package/dist/modules/billing/commands/accounts.js +157 -0
- package/dist/modules/billing/commands/accounts.js.map +7 -0
- package/dist/modules/billing/commands/index.js +6 -0
- package/dist/modules/billing/commands/index.js.map +7 -0
- package/dist/modules/billing/commands/invoices.js +401 -0
- package/dist/modules/billing/commands/invoices.js.map +7 -0
- package/dist/modules/billing/commands/items.js +317 -0
- package/dist/modules/billing/commands/items.js.map +7 -0
- package/dist/modules/billing/commands/runs.js +123 -0
- package/dist/modules/billing/commands/runs.js.map +7 -0
- package/dist/modules/billing/commands/usage.js +88 -0
- package/dist/modules/billing/commands/usage.js.map +7 -0
- package/dist/modules/billing/components/AccountForm.js +420 -0
- package/dist/modules/billing/components/AccountForm.js.map +7 -0
- package/dist/modules/billing/components/DetailFields.js +37 -0
- package/dist/modules/billing/components/DetailFields.js.map +7 -0
- package/dist/modules/billing/components/ItemForm.js +440 -0
- package/dist/modules/billing/components/ItemForm.js.map +7 -0
- package/dist/modules/billing/components/LineFormDialog.js +195 -0
- package/dist/modules/billing/components/LineFormDialog.js.map +7 -0
- package/dist/modules/billing/components/UsageForm.js +262 -0
- package/dist/modules/billing/components/UsageForm.js.map +7 -0
- package/dist/modules/billing/data/entities.js +433 -0
- package/dist/modules/billing/data/entities.js.map +7 -0
- package/dist/modules/billing/data/entityIds.js +12 -0
- package/dist/modules/billing/data/entityIds.js.map +7 -0
- package/dist/modules/billing/data/validators.js +396 -0
- package/dist/modules/billing/data/validators.js.map +7 -0
- package/dist/modules/billing/di.js +6 -0
- package/dist/modules/billing/di.js.map +7 -0
- package/dist/modules/billing/events.js +35 -0
- package/dist/modules/billing/events.js.map +7 -0
- package/dist/modules/billing/i18n/en.json +336 -0
- package/dist/modules/billing/i18n/pl.json +336 -0
- package/dist/modules/billing/index.js +17 -0
- package/dist/modules/billing/index.js.map +7 -0
- package/dist/modules/billing/lib/billPeriod.js +101 -0
- package/dist/modules/billing/lib/billPeriod.js.map +7 -0
- package/dist/modules/billing/lib/billRunEngine.js +256 -0
- package/dist/modules/billing/lib/billRunEngine.js.map +7 -0
- package/dist/modules/billing/lib/billRunLabel.js +8 -0
- package/dist/modules/billing/lib/billRunLabel.js.map +7 -0
- package/dist/modules/billing/lib/idempotency.js +13 -0
- package/dist/modules/billing/lib/idempotency.js.map +7 -0
- package/dist/modules/billing/lib/invoiceStatus.js +38 -0
- package/dist/modules/billing/lib/invoiceStatus.js.map +7 -0
- package/dist/modules/billing/lib/invoiceWriter.js +153 -0
- package/dist/modules/billing/lib/invoiceWriter.js.map +7 -0
- package/dist/modules/billing/lib/itemSelector.js +77 -0
- package/dist/modules/billing/lib/itemSelector.js.map +7 -0
- package/dist/modules/billing/lib/seeds/demo.js +130 -0
- package/dist/modules/billing/lib/seeds/demo.js.map +7 -0
- package/dist/modules/billing/lib/tenantLock.js +32 -0
- package/dist/modules/billing/lib/tenantLock.js.map +7 -0
- package/dist/modules/billing/lib/usageRater.js +150 -0
- package/dist/modules/billing/lib/usageRater.js.map +7 -0
- package/dist/modules/billing/lib/usageRunner.js +82 -0
- package/dist/modules/billing/lib/usageRunner.js.map +7 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js +144 -0
- package/dist/modules/billing/migrations/Migration20260519000000_billing.js.map +7 -0
- package/dist/modules/billing/setup.js +190 -0
- package/dist/modules/billing/setup.js.map +7 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js +47 -0
- package/dist/modules/billing/subscribers/payment-captured-to-paid.js.map +7 -0
- package/dist/modules/billing/translations.js +5 -0
- package/dist/modules/billing/translations.js.map +7 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js +31 -0
- package/dist/modules/billing/workers/reap-stale-bill-runs.js.map +7 -0
- package/package.json +148 -0
- package/src/modules/billing/__integration__/TC-BILL-001.spec.ts +116 -0
- package/src/modules/billing/__integration__/TC-BILL-002.spec.ts +169 -0
- package/src/modules/billing/__integration__/TC-BILL-003.spec.ts +28 -0
- package/src/modules/billing/__integration__/meta.ts +4 -0
- package/src/modules/billing/__tests__/commands/invoices.test.ts +464 -0
- package/src/modules/billing/__tests__/commands/itemsBulk.test.ts +284 -0
- package/src/modules/billing/__tests__/lib/billPeriod.test.ts +131 -0
- package/src/modules/billing/__tests__/lib/billRunEngine.test.ts +561 -0
- package/src/modules/billing/__tests__/lib/idempotency.test.ts +81 -0
- package/src/modules/billing/__tests__/lib/invoiceStatus.test.ts +97 -0
- package/src/modules/billing/__tests__/lib/invoiceWriter.test.ts +119 -0
- package/src/modules/billing/__tests__/lib/itemSelector.test.ts +192 -0
- package/src/modules/billing/__tests__/lib/seedsDemo.test.ts +86 -0
- package/src/modules/billing/__tests__/lib/tenantLock.test.ts +126 -0
- package/src/modules/billing/__tests__/lib/usageRater.test.ts +190 -0
- package/src/modules/billing/__tests__/package-isolation.test.ts +223 -0
- package/src/modules/billing/__tests__/setup.test.ts +107 -0
- package/src/modules/billing/__tests__/validators.test.ts +594 -0
- package/src/modules/billing/acl.ts +16 -0
- package/src/modules/billing/api/accounts/route.ts +198 -0
- package/src/modules/billing/api/export/account/[id]/route.ts +233 -0
- package/src/modules/billing/api/invoices/[id]/route.ts +143 -0
- package/src/modules/billing/api/invoices/add-line/route.ts +74 -0
- package/src/modules/billing/api/invoices/edit-line/route.ts +97 -0
- package/src/modules/billing/api/invoices/post/route.ts +97 -0
- package/src/modules/billing/api/invoices/remove-line/route.ts +75 -0
- package/src/modules/billing/api/invoices/route.ts +167 -0
- package/src/modules/billing/api/items/bulk/route.ts +92 -0
- package/src/modules/billing/api/items/route.ts +255 -0
- package/src/modules/billing/api/openapi.ts +33 -0
- package/src/modules/billing/api/run-outcomes/route.ts +149 -0
- package/src/modules/billing/api/runs/retry-failed/route.ts +88 -0
- package/src/modules/billing/api/runs/route.ts +170 -0
- package/src/modules/billing/api/test-invoices/wipe/route.ts +87 -0
- package/src/modules/billing/api/usage/route.ts +148 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/[id]/page.tsx +574 -0
- package/src/modules/billing/backend/billing/accounts/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/accounts/create/page.tsx +101 -0
- package/src/modules/billing/backend/billing/accounts/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/accounts/page.tsx +222 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/invoices/[id]/page.tsx +613 -0
- package/src/modules/billing/backend/billing/invoices/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/invoices/page.tsx +245 -0
- package/src/modules/billing/backend/billing/items/[id]/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/[id]/page.tsx +366 -0
- package/src/modules/billing/backend/billing/items/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/items/create/page.tsx +110 -0
- package/src/modules/billing/backend/billing/items/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/items/page.tsx +272 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.meta.ts +19 -0
- package/src/modules/billing/backend/billing/runs/[id]/page.tsx +404 -0
- package/src/modules/billing/backend/billing/runs/page.meta.ts +16 -0
- package/src/modules/billing/backend/billing/runs/page.tsx +426 -0
- package/src/modules/billing/backend/billing/usage/create/page.meta.ts +23 -0
- package/src/modules/billing/backend/billing/usage/create/page.tsx +120 -0
- package/src/modules/billing/ce.ts +74 -0
- package/src/modules/billing/cli.ts +89 -0
- package/src/modules/billing/commands/accounts.ts +205 -0
- package/src/modules/billing/commands/index.ts +13 -0
- package/src/modules/billing/commands/invoices.ts +542 -0
- package/src/modules/billing/commands/items.ts +438 -0
- package/src/modules/billing/commands/runs.ts +166 -0
- package/src/modules/billing/commands/usage.ts +118 -0
- package/src/modules/billing/components/AccountForm.tsx +517 -0
- package/src/modules/billing/components/DetailFields.tsx +60 -0
- package/src/modules/billing/components/ItemForm.tsx +558 -0
- package/src/modules/billing/components/LineFormDialog.tsx +253 -0
- package/src/modules/billing/components/UsageForm.tsx +326 -0
- package/src/modules/billing/data/entities.ts +533 -0
- package/src/modules/billing/data/entityIds.ts +25 -0
- package/src/modules/billing/data/validators.ts +562 -0
- package/src/modules/billing/di.ts +14 -0
- package/src/modules/billing/events.ts +47 -0
- package/src/modules/billing/i18n/en.json +336 -0
- package/src/modules/billing/i18n/pl.json +336 -0
- package/src/modules/billing/index.ts +22 -0
- package/src/modules/billing/lib/billPeriod.ts +181 -0
- package/src/modules/billing/lib/billRunEngine.ts +416 -0
- package/src/modules/billing/lib/billRunLabel.ts +14 -0
- package/src/modules/billing/lib/idempotency.ts +46 -0
- package/src/modules/billing/lib/invoiceStatus.ts +61 -0
- package/src/modules/billing/lib/invoiceWriter.ts +260 -0
- package/src/modules/billing/lib/itemSelector.ts +154 -0
- package/src/modules/billing/lib/seeds/demo.ts +199 -0
- package/src/modules/billing/lib/tenantLock.ts +87 -0
- package/src/modules/billing/lib/usageRater.ts +250 -0
- package/src/modules/billing/lib/usageRunner.ts +189 -0
- package/src/modules/billing/migrations/Migration20260519000000_billing.ts +175 -0
- package/src/modules/billing/setup.ts +252 -0
- package/src/modules/billing/subscribers/payment-captured-to-paid.ts +89 -0
- package/src/modules/billing/translations.ts +9 -0
- 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
|
+
}
|