@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,253 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from '@open-mercato/ui/primitives/dialog'
|
|
11
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
12
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
13
|
+
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
14
|
+
import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
|
|
15
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared dialog for add / edit of an invoice line.
|
|
19
|
+
*
|
|
20
|
+
* - `mode='add'` shows empty fields and calls the
|
|
21
|
+
* `billing.invoices.add_draft_line` route on submit.
|
|
22
|
+
* - `mode='edit'` pre-fills from `initial` and calls
|
|
23
|
+
* `billing.invoices.edit_draft_line`.
|
|
24
|
+
*
|
|
25
|
+
* The caller owns the network call (so we can stay simple and avoid
|
|
26
|
+
* threading apiCall context through the component). On submit, this
|
|
27
|
+
* dialog only emits `onSubmit(values)` and the parent decides which
|
|
28
|
+
* command to hit.
|
|
29
|
+
*
|
|
30
|
+
* UX conventions per `@open-mercato/ui`:
|
|
31
|
+
* - `Cmd/Ctrl + Enter` submits.
|
|
32
|
+
* - `Escape` cancels (Radix Dialog handles this for free).
|
|
33
|
+
* - Hint row at the bottom lists both shortcuts via `Kbd` /
|
|
34
|
+
* `KbdShortcut` (not raw `<span>`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type LineFormValues = {
|
|
38
|
+
description: string
|
|
39
|
+
quantity: number
|
|
40
|
+
unitPriceNet: number
|
|
41
|
+
/** Optional explicit total override (operator "I know better" path). */
|
|
42
|
+
totalNetAmount?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LineFormDialogProps = {
|
|
46
|
+
open: boolean
|
|
47
|
+
mode: 'add' | 'edit'
|
|
48
|
+
/** Pre-fill values for edit mode. */
|
|
49
|
+
initial?: Partial<LineFormValues>
|
|
50
|
+
submitting?: boolean
|
|
51
|
+
onSubmit: (values: LineFormValues) => Promise<void> | void
|
|
52
|
+
onCancel: () => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toNumber(raw: string): number | null {
|
|
56
|
+
if (raw.trim() === '') return null
|
|
57
|
+
const n = Number.parseFloat(raw)
|
|
58
|
+
return Number.isFinite(n) ? n : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function LineFormDialog({
|
|
62
|
+
open,
|
|
63
|
+
mode,
|
|
64
|
+
initial,
|
|
65
|
+
submitting = false,
|
|
66
|
+
onSubmit,
|
|
67
|
+
onCancel,
|
|
68
|
+
}: LineFormDialogProps) {
|
|
69
|
+
const t = useT()
|
|
70
|
+
const [description, setDescription] = React.useState(initial?.description ?? '')
|
|
71
|
+
const [quantity, setQuantity] = React.useState(
|
|
72
|
+
initial?.quantity != null ? String(initial.quantity) : '',
|
|
73
|
+
)
|
|
74
|
+
const [unitPriceNet, setUnitPriceNet] = React.useState(
|
|
75
|
+
initial?.unitPriceNet != null ? String(initial.unitPriceNet) : '',
|
|
76
|
+
)
|
|
77
|
+
const [totalOverride, setTotalOverride] = React.useState(
|
|
78
|
+
initial?.totalNetAmount != null ? String(initial.totalNetAmount) : '',
|
|
79
|
+
)
|
|
80
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
81
|
+
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (open) {
|
|
84
|
+
setDescription(initial?.description ?? '')
|
|
85
|
+
setQuantity(initial?.quantity != null ? String(initial.quantity) : '')
|
|
86
|
+
setUnitPriceNet(initial?.unitPriceNet != null ? String(initial.unitPriceNet) : '')
|
|
87
|
+
setTotalOverride(initial?.totalNetAmount != null ? String(initial.totalNetAmount) : '')
|
|
88
|
+
setError(null)
|
|
89
|
+
}
|
|
90
|
+
}, [open, initial])
|
|
91
|
+
|
|
92
|
+
const handleSubmit = React.useCallback(async () => {
|
|
93
|
+
const trimmedDescription = description.trim()
|
|
94
|
+
if (!trimmedDescription) {
|
|
95
|
+
setError(t('billing.invoices.lines.form.error.description_required', 'Description is required'))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const quantityValue = toNumber(quantity)
|
|
99
|
+
if (quantityValue == null || quantityValue < 0) {
|
|
100
|
+
setError(t('billing.invoices.lines.form.error.quantity_invalid', 'Quantity must be a non-negative number'))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const unitPriceValue = toNumber(unitPriceNet)
|
|
104
|
+
if (unitPriceValue == null) {
|
|
105
|
+
setError(t('billing.invoices.lines.form.error.unit_price_invalid', 'Unit price must be a number'))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const totalOverrideRaw = totalOverride.trim()
|
|
109
|
+
let totalOverrideValue: number | undefined
|
|
110
|
+
if (totalOverrideRaw !== '') {
|
|
111
|
+
const parsed = toNumber(totalOverrideRaw)
|
|
112
|
+
if (parsed == null) {
|
|
113
|
+
setError(t('billing.invoices.lines.form.error.total_invalid', 'Total override must be a number when set'))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
totalOverrideValue = parsed
|
|
117
|
+
}
|
|
118
|
+
setError(null)
|
|
119
|
+
await onSubmit({
|
|
120
|
+
description: trimmedDescription,
|
|
121
|
+
quantity: quantityValue,
|
|
122
|
+
unitPriceNet: unitPriceValue,
|
|
123
|
+
totalNetAmount: totalOverrideValue,
|
|
124
|
+
})
|
|
125
|
+
}, [description, onSubmit, quantity, t, totalOverride, unitPriceNet])
|
|
126
|
+
|
|
127
|
+
const handleKeyDown = React.useCallback(
|
|
128
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
129
|
+
// Cmd/Ctrl + Enter submits — required by the OM UX convention
|
|
130
|
+
// for every dialog. Escape is handled by Radix Dialog itself.
|
|
131
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
132
|
+
event.preventDefault()
|
|
133
|
+
void handleSubmit()
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[handleSubmit],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Auto-compute total for the helper text — purely informational, not
|
|
140
|
+
// submitted unless the operator types into `totalOverride`.
|
|
141
|
+
const computedTotal = React.useMemo(() => {
|
|
142
|
+
const q = toNumber(quantity)
|
|
143
|
+
const u = toNumber(unitPriceNet)
|
|
144
|
+
if (q == null || u == null) return null
|
|
145
|
+
return (Math.round(q * u * 100) / 100).toFixed(2)
|
|
146
|
+
}, [quantity, unitPriceNet])
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel() }}>
|
|
150
|
+
<DialogContent onKeyDown={handleKeyDown}>
|
|
151
|
+
<DialogHeader>
|
|
152
|
+
<DialogTitle>
|
|
153
|
+
{mode === 'add'
|
|
154
|
+
? t('billing.invoices.lines.form.title_add', 'Add invoice line')
|
|
155
|
+
: t('billing.invoices.lines.form.title_edit', 'Edit invoice line')}
|
|
156
|
+
</DialogTitle>
|
|
157
|
+
<DialogDescription>
|
|
158
|
+
{t(
|
|
159
|
+
'billing.invoices.lines.form.help',
|
|
160
|
+
'Changes are recorded in the draft-edit audit log.',
|
|
161
|
+
)}
|
|
162
|
+
</DialogDescription>
|
|
163
|
+
</DialogHeader>
|
|
164
|
+
<div className="flex flex-col gap-4 py-2">
|
|
165
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
166
|
+
<span className="text-muted-foreground">
|
|
167
|
+
{t('billing.invoices.lines.form.description', 'Description')}
|
|
168
|
+
</span>
|
|
169
|
+
<Textarea
|
|
170
|
+
value={description}
|
|
171
|
+
onChange={(event) => setDescription(event.currentTarget.value)}
|
|
172
|
+
rows={2}
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
175
|
+
<div className="grid grid-cols-2 gap-3">
|
|
176
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
177
|
+
<span className="text-muted-foreground">
|
|
178
|
+
{t('billing.invoices.lines.form.quantity', 'Quantity')}
|
|
179
|
+
</span>
|
|
180
|
+
<Input
|
|
181
|
+
type="number"
|
|
182
|
+
value={quantity}
|
|
183
|
+
step="0.0001"
|
|
184
|
+
onChange={(event) => setQuantity(event.currentTarget.value)}
|
|
185
|
+
/>
|
|
186
|
+
</label>
|
|
187
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
188
|
+
<span className="text-muted-foreground">
|
|
189
|
+
{t('billing.invoices.lines.form.unit_price', 'Unit price (net)')}
|
|
190
|
+
</span>
|
|
191
|
+
<Input
|
|
192
|
+
type="number"
|
|
193
|
+
value={unitPriceNet}
|
|
194
|
+
step="0.0001"
|
|
195
|
+
onChange={(event) => setUnitPriceNet(event.currentTarget.value)}
|
|
196
|
+
/>
|
|
197
|
+
</label>
|
|
198
|
+
</div>
|
|
199
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
200
|
+
<span className="text-muted-foreground">
|
|
201
|
+
{t('billing.invoices.lines.form.total_override', 'Total override (optional)')}
|
|
202
|
+
</span>
|
|
203
|
+
<Input
|
|
204
|
+
type="number"
|
|
205
|
+
value={totalOverride}
|
|
206
|
+
step="0.0001"
|
|
207
|
+
placeholder={computedTotal ?? ''}
|
|
208
|
+
onChange={(event) => setTotalOverride(event.currentTarget.value)}
|
|
209
|
+
/>
|
|
210
|
+
<span className="text-xs text-muted-foreground">
|
|
211
|
+
{computedTotal != null
|
|
212
|
+
? t(
|
|
213
|
+
'billing.invoices.lines.form.computed_total',
|
|
214
|
+
'Computed total: {computed}',
|
|
215
|
+
).replace('{computed}', computedTotal)
|
|
216
|
+
: null}
|
|
217
|
+
</span>
|
|
218
|
+
</label>
|
|
219
|
+
{error ? (
|
|
220
|
+
<div className="text-sm text-destructive">{error}</div>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
<DialogFooter>
|
|
224
|
+
<div className="flex items-center justify-between gap-3 w-full">
|
|
225
|
+
<span className="text-xs text-muted-foreground">
|
|
226
|
+
<KbdShortcut keys={['Ctrl/⌘', 'Enter']} />
|
|
227
|
+
{' '}
|
|
228
|
+
{t('billing.invoices.lines.form.submit_hint', 'to save,')}
|
|
229
|
+
{' '}
|
|
230
|
+
<Kbd>Esc</Kbd>
|
|
231
|
+
{' '}
|
|
232
|
+
{t('billing.invoices.lines.form.cancel_hint', 'to cancel')}
|
|
233
|
+
</span>
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
<Button type="button" variant="outline" onClick={onCancel}>
|
|
236
|
+
{t('billing.common.cancel', 'Cancel')}
|
|
237
|
+
</Button>
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={() => void handleSubmit()}
|
|
241
|
+
disabled={submitting}
|
|
242
|
+
>
|
|
243
|
+
{submitting
|
|
244
|
+
? t('billing.invoices.lines.form.saving', 'Saving…')
|
|
245
|
+
: t('billing.common.save', 'Save')}
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</DialogFooter>
|
|
250
|
+
</DialogContent>
|
|
251
|
+
</Dialog>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
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 { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from '@open-mercato/ui/primitives/select'
|
|
13
|
+
import { Kbd, KbdShortcut } from '@open-mercato/ui/primitives/kbd'
|
|
14
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Manual Billing Account Usage entry form.
|
|
18
|
+
*
|
|
19
|
+
* Usage is normally ingested by an external metering system via
|
|
20
|
+
* `POST /api/billing/usage` + an `X-Api-Key` scoped to
|
|
21
|
+
* `billing.usage.ingest`. This form gives operators the same path from
|
|
22
|
+
* the admin UI — for corrections, demos, or customers without an
|
|
23
|
+
* automated meter. It POSTs to the very same endpoint, so the
|
|
24
|
+
* idempotency (`sourceRef`) and append-only guarantees are identical.
|
|
25
|
+
*
|
|
26
|
+
* Usage records are immutable once written — there is no edit/delete,
|
|
27
|
+
* hence this is a create-only form (mirrors the `billing.usage.create`
|
|
28
|
+
* command, which has no update sibling).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// The accounts list API returns snake_case rows — mirror that here so
|
|
32
|
+
// the picker reads real values (camelCase would resolve to `undefined`).
|
|
33
|
+
export type UsageAccountOption = {
|
|
34
|
+
id: string
|
|
35
|
+
name: string
|
|
36
|
+
customer_id: string
|
|
37
|
+
currency_code: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type UsageFormValues = {
|
|
41
|
+
billAccountId: string
|
|
42
|
+
uomCode: string
|
|
43
|
+
quantity: string
|
|
44
|
+
periodStart: string
|
|
45
|
+
periodEnd: string
|
|
46
|
+
lineDescription: string
|
|
47
|
+
sourceRef: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AssembledUsagePayload = {
|
|
51
|
+
billAccountId: string
|
|
52
|
+
uomCode: string
|
|
53
|
+
quantity: number
|
|
54
|
+
periodStart: string
|
|
55
|
+
periodEnd: string
|
|
56
|
+
lineDescription?: string
|
|
57
|
+
sourceRef?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type UsageFormProps = {
|
|
61
|
+
accountOptions: UsageAccountOption[]
|
|
62
|
+
initial?: Partial<UsageFormValues>
|
|
63
|
+
submitting?: boolean
|
|
64
|
+
onSubmit: (assembled: AssembledUsagePayload) => Promise<void> | void
|
|
65
|
+
onCancel: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EMPTY_VALUES: UsageFormValues = {
|
|
69
|
+
billAccountId: '',
|
|
70
|
+
uomCode: '',
|
|
71
|
+
quantity: '',
|
|
72
|
+
periodStart: '',
|
|
73
|
+
periodEnd: '',
|
|
74
|
+
lineDescription: '',
|
|
75
|
+
sourceRef: '',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function firstOfMonthIso(): string {
|
|
79
|
+
const now = new Date()
|
|
80
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function todayIso(): string {
|
|
84
|
+
return new Date().toISOString().slice(0, 10)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeInitial(initial: Partial<UsageFormValues> | undefined): UsageFormValues {
|
|
88
|
+
return {
|
|
89
|
+
...EMPTY_VALUES,
|
|
90
|
+
periodStart: EMPTY_VALUES.periodStart || firstOfMonthIso(),
|
|
91
|
+
periodEnd: EMPTY_VALUES.periodEnd || todayIso(),
|
|
92
|
+
...initial,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toNumber(raw: string): number | null {
|
|
97
|
+
if (raw.trim() === '') return null
|
|
98
|
+
const n = Number.parseFloat(raw)
|
|
99
|
+
return Number.isFinite(n) ? n : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function UsageForm({
|
|
103
|
+
accountOptions,
|
|
104
|
+
initial,
|
|
105
|
+
submitting = false,
|
|
106
|
+
onSubmit,
|
|
107
|
+
onCancel,
|
|
108
|
+
}: UsageFormProps) {
|
|
109
|
+
const t = useT()
|
|
110
|
+
const [values, setValues] = React.useState<UsageFormValues>(() => mergeInitial(initial))
|
|
111
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
112
|
+
|
|
113
|
+
const initialKey = React.useMemo(() => JSON.stringify(initial ?? null), [initial])
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
// Depend on the serialized key only — `initial` is a fresh object ref on
|
|
116
|
+
// every parent render, so including it would re-run this effect (and wipe
|
|
117
|
+
// in-progress edits) whenever the parent re-renders for an unrelated reason.
|
|
118
|
+
setValues(mergeInitial(initial))
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [initialKey])
|
|
121
|
+
|
|
122
|
+
const set = React.useCallback(
|
|
123
|
+
<K extends keyof UsageFormValues>(key: K, value: UsageFormValues[K]) => {
|
|
124
|
+
setValues((prev) => ({ ...prev, [key]: value }))
|
|
125
|
+
},
|
|
126
|
+
[],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const handleSubmit = React.useCallback(async () => {
|
|
130
|
+
if (!values.billAccountId.trim()) {
|
|
131
|
+
setError(t('billing.usage.form.error.account_required', 'Billing Account is required'))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (!values.uomCode.trim()) {
|
|
135
|
+
setError(t('billing.usage.form.error.uom_required', 'UoM code is required'))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const quantity = toNumber(values.quantity)
|
|
139
|
+
if (quantity == null || quantity < 0) {
|
|
140
|
+
setError(
|
|
141
|
+
t('billing.usage.form.error.quantity_invalid', 'Quantity must be a non-negative number'),
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (!values.periodStart || !values.periodEnd) {
|
|
146
|
+
setError(t('billing.usage.form.error.period_required', 'Period start and end are required'))
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (values.periodEnd < values.periodStart) {
|
|
150
|
+
setError(
|
|
151
|
+
t('billing.usage.form.error.period_order', 'Period end must be on or after period start'),
|
|
152
|
+
)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
setError(null)
|
|
156
|
+
|
|
157
|
+
const payload: AssembledUsagePayload = {
|
|
158
|
+
billAccountId: values.billAccountId,
|
|
159
|
+
uomCode: values.uomCode.trim(),
|
|
160
|
+
quantity,
|
|
161
|
+
periodStart: values.periodStart,
|
|
162
|
+
periodEnd: values.periodEnd,
|
|
163
|
+
}
|
|
164
|
+
if (values.lineDescription.trim()) payload.lineDescription = values.lineDescription.trim()
|
|
165
|
+
if (values.sourceRef.trim()) payload.sourceRef = values.sourceRef.trim()
|
|
166
|
+
await onSubmit(payload)
|
|
167
|
+
}, [onSubmit, t, values])
|
|
168
|
+
|
|
169
|
+
const handleKeyDown = React.useCallback(
|
|
170
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
171
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
void handleSubmit()
|
|
174
|
+
} else if (event.key === 'Escape') {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
onCancel()
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
[handleSubmit, onCancel],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="rounded-lg border border-border bg-card p-6" onKeyDown={handleKeyDown}>
|
|
184
|
+
<div className="grid grid-cols-2 gap-4">
|
|
185
|
+
<label className="flex flex-col gap-1 text-sm col-span-2">
|
|
186
|
+
<span className="text-muted-foreground">
|
|
187
|
+
{t('billing.usage.form.account', 'Billing Account')}
|
|
188
|
+
<span className="text-destructive"> *</span>
|
|
189
|
+
</span>
|
|
190
|
+
<Select
|
|
191
|
+
value={values.billAccountId}
|
|
192
|
+
onValueChange={(next) => set('billAccountId', next)}
|
|
193
|
+
>
|
|
194
|
+
<SelectTrigger>
|
|
195
|
+
<SelectValue
|
|
196
|
+
placeholder={t('billing.usage.form.account.placeholder', 'Select an account')}
|
|
197
|
+
/>
|
|
198
|
+
</SelectTrigger>
|
|
199
|
+
<SelectContent>
|
|
200
|
+
{accountOptions.map((opt) => (
|
|
201
|
+
<SelectItem key={opt.id} value={opt.id}>
|
|
202
|
+
{opt.name} · {opt.customer_id} · {opt.currency_code}
|
|
203
|
+
</SelectItem>
|
|
204
|
+
))}
|
|
205
|
+
</SelectContent>
|
|
206
|
+
</Select>
|
|
207
|
+
</label>
|
|
208
|
+
|
|
209
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
210
|
+
<span className="text-muted-foreground">
|
|
211
|
+
{t('billing.usage.form.uom_code', 'UoM code')}
|
|
212
|
+
<span className="text-destructive"> *</span>
|
|
213
|
+
</span>
|
|
214
|
+
<Input
|
|
215
|
+
value={values.uomCode}
|
|
216
|
+
placeholder={t('billing.usage.form.uom_code.placeholder', 'api_request, gb, seat, …')}
|
|
217
|
+
onChange={(event) => set('uomCode', event.currentTarget.value)}
|
|
218
|
+
/>
|
|
219
|
+
<span className="text-xs text-muted-foreground">
|
|
220
|
+
{t(
|
|
221
|
+
'billing.usage.form.uom_code.help',
|
|
222
|
+
'Must exactly match the UoM on a usage Billing Item for this account — no conversion.',
|
|
223
|
+
)}
|
|
224
|
+
</span>
|
|
225
|
+
</label>
|
|
226
|
+
|
|
227
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
228
|
+
<span className="text-muted-foreground">
|
|
229
|
+
{t('billing.usage.form.quantity', 'Quantity')}
|
|
230
|
+
<span className="text-destructive"> *</span>
|
|
231
|
+
</span>
|
|
232
|
+
<Input
|
|
233
|
+
type="number"
|
|
234
|
+
step="0.0001"
|
|
235
|
+
min="0"
|
|
236
|
+
value={values.quantity}
|
|
237
|
+
onChange={(event) => set('quantity', event.currentTarget.value)}
|
|
238
|
+
/>
|
|
239
|
+
<span className="text-xs text-muted-foreground">
|
|
240
|
+
{t(
|
|
241
|
+
'billing.usage.form.quantity.help',
|
|
242
|
+
'Total units consumed in the period (pre-aggregated).',
|
|
243
|
+
)}
|
|
244
|
+
</span>
|
|
245
|
+
</label>
|
|
246
|
+
|
|
247
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
248
|
+
<span className="text-muted-foreground">
|
|
249
|
+
{t('billing.usage.form.period_start', 'Period start')}
|
|
250
|
+
<span className="text-destructive"> *</span>
|
|
251
|
+
</span>
|
|
252
|
+
<Input
|
|
253
|
+
type="date"
|
|
254
|
+
value={values.periodStart}
|
|
255
|
+
onChange={(event) => set('periodStart', event.currentTarget.value)}
|
|
256
|
+
/>
|
|
257
|
+
</label>
|
|
258
|
+
|
|
259
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
260
|
+
<span className="text-muted-foreground">
|
|
261
|
+
{t('billing.usage.form.period_end', 'Period end')}
|
|
262
|
+
<span className="text-destructive"> *</span>
|
|
263
|
+
</span>
|
|
264
|
+
<Input
|
|
265
|
+
type="date"
|
|
266
|
+
value={values.periodEnd}
|
|
267
|
+
onChange={(event) => set('periodEnd', event.currentTarget.value)}
|
|
268
|
+
/>
|
|
269
|
+
</label>
|
|
270
|
+
|
|
271
|
+
<label className="flex flex-col gap-1 text-sm col-span-2">
|
|
272
|
+
<span className="text-muted-foreground">
|
|
273
|
+
{t('billing.usage.form.description', 'Description (optional)')}
|
|
274
|
+
</span>
|
|
275
|
+
<Textarea
|
|
276
|
+
value={values.lineDescription}
|
|
277
|
+
rows={2}
|
|
278
|
+
onChange={(event) => set('lineDescription', event.currentTarget.value)}
|
|
279
|
+
/>
|
|
280
|
+
<span className="text-xs text-muted-foreground">
|
|
281
|
+
{t(
|
|
282
|
+
'billing.usage.form.description.help',
|
|
283
|
+
'Shown on its own invoice line when set; otherwise matching records are aggregated into one line.',
|
|
284
|
+
)}
|
|
285
|
+
</span>
|
|
286
|
+
</label>
|
|
287
|
+
|
|
288
|
+
<label className="flex flex-col gap-1 text-sm col-span-2">
|
|
289
|
+
<span className="text-muted-foreground">
|
|
290
|
+
{t('billing.usage.form.source_ref', 'Source reference (optional)')}
|
|
291
|
+
</span>
|
|
292
|
+
<Input
|
|
293
|
+
value={values.sourceRef}
|
|
294
|
+
onChange={(event) => set('sourceRef', event.currentTarget.value)}
|
|
295
|
+
/>
|
|
296
|
+
<span className="text-xs text-muted-foreground">
|
|
297
|
+
{t(
|
|
298
|
+
'billing.usage.form.source_ref.help',
|
|
299
|
+
'Idempotency key — re-submitting the same reference for this account is deduplicated.',
|
|
300
|
+
)}
|
|
301
|
+
</span>
|
|
302
|
+
</label>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{error ? <div className="mt-4 text-sm text-destructive">{error}</div> : null}
|
|
306
|
+
|
|
307
|
+
<div className="mt-6 flex items-center justify-between gap-3">
|
|
308
|
+
<span className="text-xs text-muted-foreground">
|
|
309
|
+
<KbdShortcut keys={['Ctrl/⌘', 'Enter']} />{' '}
|
|
310
|
+
{t('billing.usage.form.submit_hint', 'to save,')} <Kbd>Esc</Kbd>{' '}
|
|
311
|
+
{t('billing.usage.form.cancel_hint', 'to discard')}
|
|
312
|
+
</span>
|
|
313
|
+
<div className="flex items-center gap-2">
|
|
314
|
+
<Button type="button" variant="outline" onClick={onCancel}>
|
|
315
|
+
{t('billing.common.cancel', 'Cancel')}
|
|
316
|
+
</Button>
|
|
317
|
+
<Button type="button" onClick={() => void handleSubmit()} disabled={submitting}>
|
|
318
|
+
{submitting
|
|
319
|
+
? t('billing.usage.form.saving', 'Saving…')
|
|
320
|
+
: t('billing.usage.form.create', 'Record usage')}
|
|
321
|
+
</Button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
}
|