Expand AITURK Turkish settings and desktop surfaces for beta 5
This commit is contained in:
@@ -11,6 +11,7 @@ import { RowValue } from './account-row-value'
|
||||
import type { BillingRefusal } from './api'
|
||||
import { useBillingApi } from './api'
|
||||
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
|
||||
import { useBillingCopy } from './copy'
|
||||
import { BillingRefusalInline } from './inline-feedback'
|
||||
import type { BillingAutoReload, BillingStateResponse } from './types'
|
||||
import type { BillingAccountRowView } from './use-billing-state'
|
||||
@@ -24,6 +25,8 @@ export function AutoReloadRow({
|
||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
||||
row: BillingAccountRowView
|
||||
}) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const api = useBillingApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
@@ -45,7 +48,7 @@ export function AutoReloadRow({
|
||||
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
|
||||
)
|
||||
|
||||
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
|
||||
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds, copy)
|
||||
const busy = saving
|
||||
const maxBound = bounds.max_usd ?? undefined
|
||||
const minBound = bounds.min_usd ?? undefined
|
||||
@@ -95,7 +98,7 @@ export function AutoReloadRow({
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
|
||||
setMessage({ kind: 'success', text: copy.refillUpdated })
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
@@ -125,7 +128,7 @@ export function AutoReloadRow({
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
|
||||
setMessage({ kind: 'success', text: copy.refillOff })
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
@@ -178,9 +181,9 @@ export function AutoReloadRow({
|
||||
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
|
||||
<div className="grid gap-2 @2xl:grid-cols-2">
|
||||
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Threshold
|
||||
{copy.threshold}
|
||||
<Input
|
||||
aria-label="Auto-refill threshold"
|
||||
aria-label={copy.refillThreshold}
|
||||
className="mt-1 py-[3px]"
|
||||
disabled={busy || !editing}
|
||||
inputMode="decimal"
|
||||
@@ -195,9 +198,9 @@ export function AutoReloadRow({
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Reload to
|
||||
{copy.reloadTo}
|
||||
<Input
|
||||
aria-label="Auto-refill reload-to amount"
|
||||
aria-label={copy.refillAmount}
|
||||
className="mt-1 py-[3px]"
|
||||
disabled={busy || !editing}
|
||||
inputMode="decimal"
|
||||
@@ -218,9 +221,9 @@ export function AutoReloadRow({
|
||||
</div>
|
||||
{confirmDisable ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span>Turn off auto-refill?</span>
|
||||
<span>{copy.turnOffQuestion}</span>
|
||||
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
|
||||
Turn off
|
||||
{copy.turnOff}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
@@ -229,7 +232,7 @@ export function AutoReloadRow({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -241,7 +244,7 @@ export function AutoReloadRow({
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Disable
|
||||
{copy.disable}
|
||||
</Button>
|
||||
)}
|
||||
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
|
||||
@@ -261,15 +264,15 @@ export function AutoReloadRow({
|
||||
{editing ? (
|
||||
<>
|
||||
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
{busy ? copy.saving : copy.save}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
|
||||
Cancel
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={openEdit} size="sm" type="button" variant="outline">
|
||||
Manage
|
||||
{copy.manage}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||
import type { BillingStateResponse } from './types'
|
||||
import { EMPTY_BILLING_VALUE } from './use-billing-state'
|
||||
|
||||
@@ -49,22 +50,23 @@ export function initialAutoReloadAmount(...candidates: Array<null | string | und
|
||||
export function validateAutoReloadInputs(
|
||||
thresholdRaw: string,
|
||||
reloadToRaw: string,
|
||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): { error?: string; values?: { reloadTo: string; threshold: string } } {
|
||||
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
|
||||
const threshold = validateBillingAmount(copy.threshold, thresholdRaw, bounds, copy)
|
||||
|
||||
if (threshold.error || threshold.amount == null) {
|
||||
return { error: threshold.error }
|
||||
}
|
||||
|
||||
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
|
||||
const reloadTo = validateBillingAmount(copy.reloadLabel, reloadToRaw, bounds, copy)
|
||||
|
||||
if (reloadTo.error || reloadTo.amount == null) {
|
||||
return { error: reloadTo.error }
|
||||
}
|
||||
|
||||
if (reloadTo.amount <= threshold.amount) {
|
||||
return { error: 'Reload-to amount must be greater than the threshold.' }
|
||||
return { error: copy.reloadGreater }
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -78,30 +80,31 @@ export function validateAutoReloadInputs(
|
||||
export function validateBillingAmount(
|
||||
label: string,
|
||||
raw: string,
|
||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
|
||||
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): { amount?: number; error?: string } {
|
||||
const cleaned = raw.trim().replace(/^\$/, '').trim()
|
||||
|
||||
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
|
||||
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
|
||||
return { error: copy.invalidAmount(label) }
|
||||
}
|
||||
|
||||
const amount = Number(cleaned)
|
||||
|
||||
if (!(amount > 0)) {
|
||||
return { error: `${label}: amount must be greater than $0.` }
|
||||
return { error: copy.positiveAmount(label) }
|
||||
}
|
||||
|
||||
const min = parseAmount(bounds.min_usd)
|
||||
|
||||
if (min != null && amount < min) {
|
||||
return { error: `${label}: minimum is ${formatMoney(min)}.` }
|
||||
return { error: copy.minAmount(label, formatMoney(min)) }
|
||||
}
|
||||
|
||||
const max = parseAmount(bounds.max_usd)
|
||||
|
||||
if (max != null && amount > max) {
|
||||
return { error: `${label}: maximum is ${formatMoney(max)}.` }
|
||||
return { error: copy.maxAmount(label, formatMoney(max)) }
|
||||
}
|
||||
|
||||
return { amount }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { EN_BILLING } from '@/i18n/en-billing'
|
||||
import { TR_BILLING } from '@/i18n/tr-billing'
|
||||
|
||||
export type BillingCopy = typeof EN_BILLING
|
||||
export const DEFAULT_BILLING_COPY: BillingCopy = EN_BILLING
|
||||
|
||||
export function useBillingCopy(): BillingCopy {
|
||||
const { locale } = useI18n()
|
||||
|
||||
return locale === 'tr' ? TR_BILLING : EN_BILLING
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/lib/icons'
|
||||
|
||||
import { useBillingCopy } from './copy'
|
||||
import { BillingRefusalInline } from './inline-feedback'
|
||||
import { openExternal } from './open-external'
|
||||
import { TierArt } from './tier-art'
|
||||
@@ -8,6 +9,8 @@ import type { BillingPlanCardView } from './use-billing-state'
|
||||
import { useResumeFlow } from './use-subscription-change'
|
||||
|
||||
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const resumeFlow = useResumeFlow()
|
||||
|
||||
return (
|
||||
@@ -22,7 +25,8 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
|
||||
</span>
|
||||
{plan.price && (
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{plan.price}/mo
|
||||
{plan.price}
|
||||
{copy.monthSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -40,7 +44,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
|
||||
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
|
||||
{plan.pending && (
|
||||
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
|
||||
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
|
||||
{resumeFlow.busy ? copy.undoing : copy.undo}
|
||||
</Button>
|
||||
)}
|
||||
{plan.link && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BillingRefusal } from './api'
|
||||
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||
|
||||
export interface BillingRefusalPresentation {
|
||||
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
|
||||
@@ -8,51 +9,51 @@ export interface BillingRefusalPresentation {
|
||||
|
||||
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
|
||||
|
||||
const retryMessage = (refusal: BillingRefusal): string => {
|
||||
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
||||
const retryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
|
||||
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
|
||||
|
||||
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
|
||||
return copy.rateLimited(mins)
|
||||
}
|
||||
|
||||
const stripeRetryMessage = (refusal: BillingRefusal): string => {
|
||||
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
||||
const stripeRetryMessage = (refusal: BillingRefusal, copy: BillingCopy): string => {
|
||||
const mins = refusal.retryAfter ? copy.retryAfter(Math.max(1, Math.round(refusal.retryAfter / 60))) : ''
|
||||
|
||||
return `Stripe is having trouble — try again shortly${mins}`
|
||||
return copy.stripeRetry(mins)
|
||||
}
|
||||
|
||||
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
|
||||
export const resolveRefusal = (
|
||||
refusal: BillingRefusal,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingRefusalPresentation => {
|
||||
switch (refusal.kind) {
|
||||
case 'consent_required':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: 'Confirm this card for terminal charges in the portal',
|
||||
title: 'Card confirmation needed'
|
||||
message: copy.confirmCardMessage,
|
||||
title: copy.confirmCardTitle
|
||||
}
|
||||
|
||||
case 'insufficient_scope':
|
||||
return {
|
||||
action: { type: 'step_up' },
|
||||
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
|
||||
title: 'Remote Spending needs approval'
|
||||
message: copy.spendingApprovalMessage,
|
||||
title: copy.spendingApprovalTitle
|
||||
}
|
||||
case 'remote_spending_revoked': {
|
||||
const who =
|
||||
refusal.actor === 'admin'
|
||||
? 'An admin stopped remote spending for this terminal.'
|
||||
: 'You stopped remote spending for this terminal.'
|
||||
const who = refusal.actor === 'admin' ? copy.adminRevoked : copy.userRevoked
|
||||
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
|
||||
title: 'Remote spending was stopped'
|
||||
message: copy.reauthorize(who),
|
||||
title: copy.spendingRevoked
|
||||
}
|
||||
}
|
||||
|
||||
case 'session_revoked':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
|
||||
title: 'Session logged out'
|
||||
message: copy.sessionRevokedMessage,
|
||||
title: copy.sessionRevokedTitle
|
||||
}
|
||||
|
||||
case 'cli_billing_disabled':
|
||||
@@ -60,50 +61,44 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
||||
case 'remote_spending_disabled':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message:
|
||||
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
|
||||
title: 'Remote spending is off'
|
||||
message: copy.spendingDisabledMessage,
|
||||
title: copy.spendingDisabledTitle
|
||||
}
|
||||
|
||||
case 'role_required':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
|
||||
title: 'Admin role required'
|
||||
message: copy.adminRequiredMessage,
|
||||
title: copy.adminRequiredTitle
|
||||
}
|
||||
|
||||
case 'idempotency_conflict':
|
||||
return {
|
||||
action: { type: 'none' },
|
||||
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
|
||||
title: 'Start a fresh top-up'
|
||||
message: copy.conflictingChargeMessage,
|
||||
title: copy.conflictingChargeTitle
|
||||
}
|
||||
|
||||
case 'no_payment_method':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message:
|
||||
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
|
||||
"(one-time credit buys don't save a reusable card).",
|
||||
title: 'No saved card'
|
||||
message: copy.noSavedCardMessage,
|
||||
title: copy.noSavedCard
|
||||
}
|
||||
|
||||
case 'org_access_denied':
|
||||
return {
|
||||
action: { type: 'none' },
|
||||
message: "This token isn't bound to an org you can manage",
|
||||
title: 'Org access denied'
|
||||
message: copy.orgDeniedMessage,
|
||||
title: copy.orgDeniedTitle
|
||||
}
|
||||
case 'monthly_cap_exceeded': {
|
||||
const remaining = refusal.payload?.remainingUsd
|
||||
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message:
|
||||
remaining != null
|
||||
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
|
||||
: '🔴 Monthly spend cap reached.',
|
||||
title: 'Monthly spend cap reached'
|
||||
message: remaining != null ? copy.monthlyRemaining(String(remaining)) : copy.monthlyReached,
|
||||
title: copy.monthlyReachedTitle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,52 +107,50 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
||||
case 'temporarily_unavailable':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
message: retryMessage(refusal),
|
||||
title: 'Too many charges right now'
|
||||
message: retryMessage(refusal, copy),
|
||||
title: copy.rateLimitedTitle
|
||||
}
|
||||
|
||||
case 'stripe_unavailable':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
message: stripeRetryMessage(refusal),
|
||||
title: 'Stripe is having trouble'
|
||||
message: stripeRetryMessage(refusal, copy),
|
||||
title: copy.stripeTitle
|
||||
}
|
||||
|
||||
case 'upgrade_cap_exceeded':
|
||||
return {
|
||||
action: { type: 'none' },
|
||||
message: 'Daily plan-change limit reached — try again tomorrow',
|
||||
title: 'Daily plan-change limit reached'
|
||||
message: copy.dailyLimitMessage,
|
||||
title: copy.dailyLimitTitle
|
||||
}
|
||||
|
||||
case 'endpoint_unavailable':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
message:
|
||||
refusal.message ||
|
||||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
|
||||
title: 'Billing endpoint unavailable'
|
||||
message: refusal.message || copy.endpointMessage,
|
||||
title: copy.endpointTitle
|
||||
}
|
||||
|
||||
case 'timeout':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
message: refusal.message || 'Billing request timed out.',
|
||||
title: 'Billing request timed out'
|
||||
message: refusal.message || copy.requestTimeoutMessage,
|
||||
title: copy.requestTimeoutTitle
|
||||
}
|
||||
|
||||
case 'transport':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
message: refusal.message || 'Billing request failed before reaching the gateway.',
|
||||
title: 'Billing connection failed'
|
||||
message: refusal.message || copy.connectionFailedMessage,
|
||||
title: copy.connectionFailedTitle
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
action: { type: 'none' },
|
||||
message: refusal.message || 'Billing request failed.',
|
||||
title: 'Billing request failed'
|
||||
message: refusal.message || copy.requestFailedMessage,
|
||||
title: copy.requestFailedTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render as renderUi, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider, useI18n } from '@/i18n'
|
||||
|
||||
import { renderWithEnglish as render } from '../test-locale'
|
||||
|
||||
import { formatMoney } from './billing-amounts'
|
||||
import {
|
||||
billingDevFixtures,
|
||||
@@ -692,3 +696,38 @@ describe('BillingSettings', () => {
|
||||
expect(screen.queryByText(/Updated/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function BillingLocaleSwitch() {
|
||||
const { setLocale } = useI18n()
|
||||
|
||||
return <button onClick={() => void setLocale('tr')}>Türkçe</button>
|
||||
}
|
||||
|
||||
it('switches loaded billing content to Turkish and preserves auto-refill request values', async () => {
|
||||
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
renderUi(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<BillingLocaleSwitch />
|
||||
<MemoryRouter initialEntries={['/settings?tab=billing']}>
|
||||
<QueryClientProvider client={client}>
|
||||
<BillingSettings />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
)
|
||||
await screen.findByText('Buy credits now')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Türkçe' }))
|
||||
expect(await screen.findByText('Şimdi kredi satın al')).toBeTruthy()
|
||||
expect(screen.queryByText('Buy credits now')).toBeNull()
|
||||
expect(screen.getByText('Bakiye')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Yönet' }))
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme eşiği' }), { target: { value: '15' } })
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'Otomatik yükleme tutarı' }), { target: { value: '20' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() =>
|
||||
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: true, threshold_usd: '15', reload_to_usd: '20' })
|
||||
)
|
||||
expect(await screen.findByText('Otomatik yükleme güncellendi.')).toBeTruthy()
|
||||
expect(apiMocks.charge).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
import { AutoReloadRow } from './auto-reload-row'
|
||||
import { clampAmount, formatMoney } from './billing-amounts'
|
||||
import { useBillingCopy } from './copy'
|
||||
import { CurrentPlanCard } from './current-plan-card'
|
||||
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
|
||||
import { StepUpInlineAction } from './inline-feedback'
|
||||
@@ -155,6 +156,8 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
|
||||
}
|
||||
|
||||
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const presets = useMemo(
|
||||
() =>
|
||||
billing.charge_presets.map((amount, index) => ({
|
||||
@@ -192,7 +195,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
value={amount}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Custom credit amount"
|
||||
aria-label={copy.customCredit}
|
||||
containerClassName="w-16"
|
||||
disabled={controlsDisabled}
|
||||
inputMode="decimal"
|
||||
@@ -211,7 +214,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
value={amount}
|
||||
/>
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
||||
Buy
|
||||
{copy.buy}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -250,12 +253,14 @@ function BuyCreditsOutcome({
|
||||
onRetry: () => void
|
||||
outcome: ReturnType<typeof useChargeFlow>['outcome']
|
||||
}) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const stepUp = useStepUpFlow()
|
||||
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Processing… checking settlement
|
||||
{copy.processing}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,7 +272,7 @@ function BuyCreditsOutcome({
|
||||
if (outcome.kind === 'success') {
|
||||
return (
|
||||
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
|
||||
{copy.balanceAdded(formatMoney(outcome.amountUsd ?? amount))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -280,7 +285,7 @@ function BuyCreditsOutcome({
|
||||
</span>
|
||||
{outcome.portalUrl && (
|
||||
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
|
||||
Open portal
|
||||
{copy.portal}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -297,13 +302,13 @@ function BuyCreditsOutcome({
|
||||
</span>
|
||||
{outcome.action?.type === 'retry' && (
|
||||
<Button onClick={onRetry} size="sm" type="button" variant="outline">
|
||||
Retry
|
||||
{copy.retry}
|
||||
</Button>
|
||||
)}
|
||||
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
||||
{portalUrl && (
|
||||
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
|
||||
Open portal
|
||||
{copy.portal}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -312,8 +317,10 @@ function BuyCreditsOutcome({
|
||||
}
|
||||
|
||||
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const resolvedBar = bar ?? {
|
||||
label: `${fallbackLabel} usage`,
|
||||
label: copy.usageLabel(fallbackLabel),
|
||||
state: 'neutral',
|
||||
tone: 'topup',
|
||||
value: 0
|
||||
@@ -407,11 +414,13 @@ function BillingHeader({
|
||||
fixtureName?: BillingFixtureSelection
|
||||
onFixtureChange?: (value: BillingFixtureSelection) => void
|
||||
}) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
return (
|
||||
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Billing</span>
|
||||
<span>{copy.billing}</span>
|
||||
</div>
|
||||
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
|
||||
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
|
||||
@@ -455,6 +464,8 @@ function BillingSettingsContent({
|
||||
fixtureName?: BillingFixtureSelection
|
||||
onFixtureChange?: (value: BillingFixtureSelection) => void
|
||||
}) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
|
||||
|
||||
// Fixture mode flows through the SAME query path — the simulated api (supplied by
|
||||
@@ -476,7 +487,7 @@ function BillingSettingsContent({
|
||||
|
||||
const billingResult = billingState.data
|
||||
const subscriptionResult = subscriptionState.data
|
||||
const view = deriveBillingView(billingResult, subscriptionResult)
|
||||
const view = deriveBillingView(billingResult, subscriptionResult, copy)
|
||||
const billing = billingResult?.ok ? billingResult.data : undefined
|
||||
|
||||
const { paymentRow, refillRow, topupRow } = view
|
||||
@@ -515,7 +526,7 @@ function BillingSettingsContent({
|
||||
</div>
|
||||
|
||||
{view.plan && (
|
||||
<SettingsSection icon={Package} title="Plan">
|
||||
<SettingsSection icon={Package} title={copy.plan}>
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
@@ -524,7 +535,7 @@ function BillingSettingsContent({
|
||||
<SettingsSection
|
||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||
icon={CreditCard}
|
||||
title="Payment & credits"
|
||||
title={copy.paymentCredits}
|
||||
>
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
@@ -533,7 +544,7 @@ function BillingSettingsContent({
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<SettingsSection icon={BarChart3} title="Usage">
|
||||
<SettingsSection icon={BarChart3} title={copy.usage}>
|
||||
<div className="@container">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
@@ -544,7 +555,7 @@ function BillingSettingsContent({
|
||||
|
||||
{
|
||||
// no endpoint yet — NAS capability-board gap
|
||||
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
|
||||
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title={copy.invoices} /> : null
|
||||
}
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -3,16 +3,19 @@ import { openExternalLink } from '@/lib/external-link'
|
||||
import { ExternalLink } from '@/lib/icons'
|
||||
|
||||
import type { BillingRefusal } from './api'
|
||||
import { useBillingCopy } from './copy'
|
||||
import { resolveRefusal } from './errors'
|
||||
import { useStepUpFlow } from './use-step-up'
|
||||
|
||||
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
if (flow.verification) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
|
||||
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
|
||||
Open verification page
|
||||
{copy.verificationPage}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -26,31 +29,33 @@ export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUp
|
||||
{flow.message.title}: {flow.message.text}
|
||||
</span>
|
||||
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
|
||||
Dismiss
|
||||
{copy.dismiss}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.phase === 'waiting') {
|
||||
return <span>Waiting for verification link…</span>
|
||||
return <span>{copy.waitingVerification}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
|
||||
Verify to continue
|
||||
{copy.verifyContinue}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const stepUp = useStepUpFlow()
|
||||
|
||||
if (!refusal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolveRefusal(refusal)
|
||||
const resolved = resolveRefusal(refusal, copy)
|
||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
||||
|
||||
return (
|
||||
@@ -61,7 +66,7 @@ export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | nu
|
||||
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
|
||||
{portalUrl && (
|
||||
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
|
||||
Open portal
|
||||
{copy.portal}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { TR_BILLING } from '@/i18n/tr-billing'
|
||||
|
||||
import { validateAutoReloadInputs } from './billing-amounts'
|
||||
import { resolveRefusal } from './errors'
|
||||
import { okBilling, okSubscription, postTrainBillingState, postTrainSubscriptionState } from './fixtures.test-util'
|
||||
import { deriveBillingView } from './use-billing-state'
|
||||
|
||||
describe('Turkish billing contracts', () => {
|
||||
it('keeps routes, amount bounds and action policy unchanged across languages', () => {
|
||||
for (const kind of [
|
||||
'consent_required',
|
||||
'insufficient_scope',
|
||||
'remote_spending_revoked',
|
||||
'session_revoked',
|
||||
'cli_billing_disabled',
|
||||
'remote_spending_disabled',
|
||||
'role_required',
|
||||
'idempotency_conflict',
|
||||
'no_payment_method',
|
||||
'org_access_denied',
|
||||
'monthly_cap_exceeded',
|
||||
'rate_limited',
|
||||
'temporarily_unavailable',
|
||||
'stripe_unavailable',
|
||||
'upgrade_cap_exceeded',
|
||||
'endpoint_unavailable',
|
||||
'timeout',
|
||||
'transport',
|
||||
'unknown'
|
||||
]) {
|
||||
const refusal = { kind, message: '', portalUrl: 'https://portal.nousresearch.com/billing', retryAfter: 120 }
|
||||
const english = resolveRefusal(refusal)
|
||||
const turkish = resolveRefusal(refusal, TR_BILLING)
|
||||
expect(turkish.action).toEqual(english.action)
|
||||
expect(turkish.title).not.toBe(english.title)
|
||||
expect(turkish.message).not.toBe(english.message)
|
||||
}
|
||||
|
||||
const bounds = { min_usd: '10', max_usd: '100' }
|
||||
expect(validateAutoReloadInputs('15', '20', bounds, TR_BILLING)).toEqual(
|
||||
validateAutoReloadInputs('15', '20', bounds)
|
||||
)
|
||||
expect(validateAutoReloadInputs('20', '15', bounds, TR_BILLING).error).toBe(TR_BILLING.reloadGreater)
|
||||
expect(resolveRefusal({ kind: 'transport', message: 'upstream diagnostic' }, TR_BILLING).message).toBe(
|
||||
'upstream diagnostic'
|
||||
)
|
||||
})
|
||||
|
||||
it('translates the derived overview without altering prices or plan identities', () => {
|
||||
const args = [okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)] as const
|
||||
const english = deriveBillingView(...args)
|
||||
const turkish = deriveBillingView(...args, TR_BILLING)
|
||||
expect(turkish.summary[0].label).toBe('Bakiye')
|
||||
expect(turkish.summary[0].value).toBe(english.summary[0].value)
|
||||
expect(turkish.plan?.tierName).toBe(english.plan?.tierName)
|
||||
expect(turkish.plan?.price).toBe(english.plan?.price)
|
||||
expect(turkish.paymentRow?.action?.url).toBe(english.paymentRow?.action?.url)
|
||||
expect(turkish.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])).toEqual(
|
||||
english.tiers.map(tier => [tier.tierId, tier.state, tier.priceDisplay])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { Pill } from '../primitives'
|
||||
|
||||
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
|
||||
import { BillingRefusalInline } from './inline-feedback'
|
||||
import { TierArt } from './tier-art'
|
||||
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
|
||||
@@ -16,9 +17,13 @@ type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
|
||||
|
||||
// The human sentence for the panel body, derived purely from the phase. `null` while
|
||||
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
|
||||
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
|
||||
function previewMessage(
|
||||
phase: DowngradePhase,
|
||||
fallbackTierName: string,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): null | string {
|
||||
if (phase.kind === 'previewing') {
|
||||
return 'Checking this change…'
|
||||
return copy.checkingChange
|
||||
}
|
||||
|
||||
if (phase.kind === 'previewFailed') {
|
||||
@@ -27,28 +32,27 @@ function previewMessage(phase: DowngradePhase, fallbackTierName: string): null |
|
||||
|
||||
const { preview } = phase
|
||||
const targetName = preview.target_tier_name ?? fallbackTierName
|
||||
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
|
||||
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta, copy)
|
||||
|
||||
switch (preview.effect) {
|
||||
case 'blocked':
|
||||
return preview.reason ?? 'That change cannot be made here.'
|
||||
return preview.reason ?? copy.cannotChange
|
||||
|
||||
case 'no_op':
|
||||
return `You are already on ${targetName} — nothing to change.`
|
||||
return copy.alreadyPlan(targetName)
|
||||
|
||||
case 'scheduled':
|
||||
return (
|
||||
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
|
||||
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
|
||||
)
|
||||
return copy.scheduledChange(targetName, formatBillingDate(preview.effective_at, copy), creditsDelta)
|
||||
|
||||
default:
|
||||
return 'This change cannot be scheduled here.'
|
||||
return copy.cannotSchedule
|
||||
}
|
||||
}
|
||||
|
||||
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
|
||||
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const active = flow.active
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const open = active?.target.tierId === tier.tierId
|
||||
@@ -69,7 +73,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
||||
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
|
||||
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
|
||||
const message = previewMessage(phase, tier.name)
|
||||
const message = previewMessage(phase, tier.name, copy)
|
||||
|
||||
const canConfirm =
|
||||
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
|
||||
@@ -91,19 +95,19 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{phase.kind === 'previewFailed' ? (
|
||||
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
|
||||
Try again
|
||||
{copy.tryAgain}
|
||||
</Button>
|
||||
) : canConfirm ? (
|
||||
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
|
||||
{phase.kind === 'scheduling'
|
||||
? 'Scheduling…'
|
||||
? copy.scheduling
|
||||
: phase.kind === 'scheduleFailed'
|
||||
? 'Try again'
|
||||
: 'Confirm downgrade'}
|
||||
? copy.tryAgain
|
||||
: copy.confirmDowngrade}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
|
||||
Cancel
|
||||
{copy.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +115,8 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
||||
}
|
||||
|
||||
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
const isCurrent = tier.state === 'current'
|
||||
const confirming = flow.active?.target.tierId === tier.tierId
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
@@ -143,7 +149,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
{tier.name}
|
||||
</div>
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{tier.priceDisplay}/mo
|
||||
{tier.priceDisplay}
|
||||
{copy.monthSuffix}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,9 +162,9 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
)}
|
||||
|
||||
<div className="mt-auto min-w-0 pt-1">
|
||||
{isCurrent && <Pill tone="primary">Current plan</Pill>}
|
||||
{isCurrent && <Pill tone="primary">{copy.currentPlan}</Pill>}
|
||||
|
||||
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
|
||||
{tier.state === 'scheduled' && <Pill>{copy.scheduled}</Pill>}
|
||||
|
||||
{tier.state === 'upgrade' && (
|
||||
<Button
|
||||
@@ -183,7 +190,7 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Downgrade
|
||||
{copy.downgrade}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -192,6 +199,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
}
|
||||
|
||||
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
|
||||
const copy = useBillingCopy()
|
||||
|
||||
// A scheduled downgrade lands the user back on the overview, where the plan card
|
||||
// now shows the pending state with its undo.
|
||||
const flow = useDowngradeFlow({ onScheduled: onBack })
|
||||
@@ -200,7 +209,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
||||
<div className="@container">
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Button
|
||||
aria-label="Back to billing"
|
||||
aria-label={copy.backBilling}
|
||||
className="size-7 p-0 text-(--ui-text-tertiary)"
|
||||
disabled={flow.mutating}
|
||||
onClick={onBack}
|
||||
@@ -210,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span>Plans</span>
|
||||
<span>{copy.plans}</span>
|
||||
</div>
|
||||
|
||||
{tiers.length > 0 ? (
|
||||
@@ -221,7 +230,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No plans are available to change to right now.
|
||||
{copy.noPlans}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { fmtDate } from '@/lib/time'
|
||||
|
||||
import type { BillingRefusal, BillingResult } from './api'
|
||||
import { useBillingApi } from './api'
|
||||
import { type BillingCopy, DEFAULT_BILLING_COPY } from './copy'
|
||||
import { resolveRefusal } from './errors'
|
||||
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
|
||||
|
||||
@@ -30,7 +29,7 @@ const BILLING_QUERY_OPTIONS = {
|
||||
} as const
|
||||
|
||||
export interface BillingSummaryItemView {
|
||||
label: 'Auto-refill' | 'Balance' | 'Plan'
|
||||
label: string
|
||||
tone?: 'muted' | 'primary'
|
||||
value: string
|
||||
}
|
||||
@@ -172,12 +171,13 @@ export function useSubscriptionState(enabled = true) {
|
||||
|
||||
export function deriveBillingView(
|
||||
stateResult?: BillingResult<BillingStateResponse>,
|
||||
subscriptionResult?: BillingResult<SubscriptionStateResponse>
|
||||
subscriptionResult?: BillingResult<SubscriptionStateResponse>,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingView {
|
||||
if (!stateResult) {
|
||||
return {
|
||||
status: 'loading',
|
||||
summary: emptySummary(),
|
||||
summary: emptySummary(copy),
|
||||
tiers: [],
|
||||
usageRows: []
|
||||
}
|
||||
@@ -185,9 +185,9 @@ export function deriveBillingView(
|
||||
|
||||
if (!stateResult.ok) {
|
||||
return {
|
||||
notice: refusalNotice(stateResult.refusal),
|
||||
notice: refusalNotice(stateResult.refusal, copy),
|
||||
status: 'refusal',
|
||||
summary: emptySummary(),
|
||||
summary: emptySummary(copy),
|
||||
tiers: [],
|
||||
usageRows: []
|
||||
}
|
||||
@@ -199,12 +199,12 @@ export function deriveBillingView(
|
||||
if (!billing.logged_in || subscription?.logged_in === false) {
|
||||
return {
|
||||
notice: {
|
||||
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
|
||||
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
|
||||
title: 'Connect your Nous account'
|
||||
action: { label: copy.portalArrow, url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
|
||||
message: copy.connectMessage,
|
||||
title: copy.connectTitle
|
||||
},
|
||||
status: 'logged_out',
|
||||
summary: emptySummary(),
|
||||
summary: emptySummary(copy),
|
||||
tiers: [],
|
||||
usageRows: []
|
||||
}
|
||||
@@ -216,27 +216,27 @@ export function deriveBillingView(
|
||||
const capable = plansCapable(subscription, subscriptionResult)
|
||||
// Computed once and threaded to both the card (caption + undo) and the grid
|
||||
// (Scheduled marker), so the two never disagree about what's pending.
|
||||
const pending = pendingTransition(subscription?.current)
|
||||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
|
||||
const pending = pendingTransition(subscription?.current, copy)
|
||||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending, copy)
|
||||
|
||||
return {
|
||||
notice: noCardNotice(billing),
|
||||
paymentRow: paymentMethodRow(billing),
|
||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
|
||||
refillRow: autoReloadRow(billing),
|
||||
notice: noCardNotice(billing, copy),
|
||||
paymentRow: paymentMethodRow(billing, copy),
|
||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending, copy),
|
||||
refillRow: autoReloadRow(billing, copy),
|
||||
status: 'normal',
|
||||
summary: [
|
||||
{ label: 'Balance', value: displayBalance(billing) },
|
||||
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
|
||||
{ label: copy.balance, value: displayBalance(billing) },
|
||||
{ label: copy.plan, value: displayPlan(subscription, billing.usage, copy) },
|
||||
{
|
||||
label: 'Auto-refill',
|
||||
label: copy.autoRefill,
|
||||
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
|
||||
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
|
||||
value: billing.auto_reload ? (billing.auto_reload.enabled ? copy.enabled : copy.off) : EMPTY_BILLING_VALUE
|
||||
}
|
||||
],
|
||||
tiers,
|
||||
topupRow: buyCreditsRow(billing),
|
||||
usageRows: deriveUsageRows(billing, subscription)
|
||||
topupRow: buyCreditsRow(billing, copy),
|
||||
usageRows: deriveUsageRows(billing, subscription, copy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function buildManageSubscriptionUrl(
|
||||
return FALLBACK_PORTAL_BILLING_URL
|
||||
}
|
||||
|
||||
export function formatBillingDate(value?: null | string): string {
|
||||
export function formatBillingDate(value?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||
if (!value) {
|
||||
return EMPTY_BILLING_VALUE
|
||||
}
|
||||
@@ -286,23 +286,23 @@ export function formatBillingDate(value?: null | string): string {
|
||||
return EMPTY_BILLING_VALUE
|
||||
}
|
||||
|
||||
return fmtDate.format(date)
|
||||
return copy.formatDate(date)
|
||||
}
|
||||
|
||||
function emptySummary(): BillingSummaryItemView[] {
|
||||
function emptySummary(copy: BillingCopy = DEFAULT_BILLING_COPY): BillingSummaryItemView[] {
|
||||
return [
|
||||
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
|
||||
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
|
||||
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
|
||||
{ label: copy.balance, value: EMPTY_BILLING_VALUE },
|
||||
{ label: copy.plan, value: EMPTY_BILLING_VALUE },
|
||||
{ label: copy.autoRefill, value: EMPTY_BILLING_VALUE }
|
||||
]
|
||||
}
|
||||
|
||||
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
||||
const resolved = resolveRefusal(refusal)
|
||||
function refusalNotice(refusal: BillingRefusal, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingNoticeView {
|
||||
const resolved = resolveRefusal(refusal, copy)
|
||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
|
||||
|
||||
return {
|
||||
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
|
||||
action: portalUrl ? { label: copy.portalArrow, url: portalUrl } : undefined,
|
||||
message: resolved.message,
|
||||
title: resolved.title,
|
||||
tone: 'warn'
|
||||
@@ -312,15 +312,18 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
||||
// A logged-in account with no card can't buy credits or manage auto-refill, and
|
||||
// every one of those controls disables silently — so lead the page with a single
|
||||
// warn banner that names the blocker and links straight to the fix.
|
||||
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
|
||||
function noCardNotice(
|
||||
billing: BillingStateResponse,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingNoticeView | undefined {
|
||||
if (billing.card) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
||||
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
|
||||
title: 'No payment method on file',
|
||||
action: { label: copy.addCard, url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
|
||||
message: copy.noCardMessage,
|
||||
title: copy.noCardTitle,
|
||||
tone: 'warn'
|
||||
}
|
||||
}
|
||||
@@ -348,10 +351,13 @@ function plansCapable(
|
||||
|
||||
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
|
||||
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
|
||||
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
|
||||
function creditsPerMonthDisplay(
|
||||
monthlyCredits: null | string,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): string | undefined {
|
||||
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
|
||||
|
||||
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
|
||||
return Number.isFinite(credits) && credits > 0 ? copy.creditsMonthly(credits.toLocaleString('en-US')) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,14 +366,17 @@ function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefin
|
||||
* ("−$88/mo"), never the raw number. Zero / absent → null so the caller hides
|
||||
* the line entirely.
|
||||
*/
|
||||
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
|
||||
export function formatMonthlyCreditsDelta(
|
||||
delta?: null | string,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): null | string {
|
||||
const amount = parseAmount(delta)
|
||||
|
||||
if (amount == null || amount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${amount < 0 ? '−' : '+'}${formatMoney(Math.abs(amount))}/mo`
|
||||
return `${amount < 0 ? '−' : '+'}${formatMoney(Math.abs(amount))}${copy.monthSuffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,25 +393,26 @@ function derivePlanCard(
|
||||
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
|
||||
tiers: BillingPlanTierView[],
|
||||
capable: boolean,
|
||||
pending: PendingPlanTransition | undefined
|
||||
pending: PendingPlanTransition | undefined,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingPlanCardView {
|
||||
const current = subscription?.current
|
||||
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
|
||||
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? copy.free
|
||||
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
|
||||
// still shows its price.
|
||||
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
||||
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
|
||||
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at, copy)
|
||||
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
|
||||
|
||||
const caption = unavailable
|
||||
? 'Subscription details are unavailable; opening the portal is still available.'
|
||||
? copy.subscriptionUnavailable
|
||||
: pending
|
||||
? pending.kind === 'downgrade'
|
||||
? `Changes to ${pending.tierName} on ${pending.when}.`
|
||||
: `Cancels on ${pending.when}.`
|
||||
? copy.changesPlan(pending.tierName, pending.when)
|
||||
: copy.cancelsOn(pending.when)
|
||||
: current
|
||||
? `Renews ${renewal}`
|
||||
: 'No active subscription — paid models draw down top-up credits.'
|
||||
? copy.renewsOn(renewal)
|
||||
: copy.noSubscription
|
||||
|
||||
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
|
||||
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
|
||||
@@ -410,14 +420,14 @@ function derivePlanCard(
|
||||
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
|
||||
|
||||
if (capable && hasActionableTier) {
|
||||
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
|
||||
return { action: { label: current ? copy.changePlan : copy.viewPlans }, caption, pending, price, tierName }
|
||||
}
|
||||
|
||||
return {
|
||||
caption,
|
||||
// No in-app action → always hand off to the portal so the user isn't stranded.
|
||||
link: {
|
||||
label: 'Adjust plan ↗',
|
||||
label: copy.adjustPlan,
|
||||
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
|
||||
},
|
||||
pending,
|
||||
@@ -432,20 +442,21 @@ function derivePlanCard(
|
||||
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
|
||||
// tier, the stronger, more specific signal, and is what the grid marks.
|
||||
function pendingTransition(
|
||||
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
|
||||
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): PendingPlanTransition | undefined {
|
||||
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
|
||||
return {
|
||||
kind: 'downgrade',
|
||||
tierName: current.pending_downgrade_tier_name,
|
||||
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
|
||||
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at, copy)
|
||||
}
|
||||
}
|
||||
|
||||
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
|
||||
return {
|
||||
kind: 'cancellation',
|
||||
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
|
||||
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at, copy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +482,8 @@ function derivePlanTiers(
|
||||
subscription: null | SubscriptionStateResponse,
|
||||
fallbackPortalUrl: null | string,
|
||||
capable: boolean,
|
||||
pending: PendingPlanTransition | undefined
|
||||
pending: PendingPlanTransition | undefined,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingPlanTierView[] {
|
||||
if (!capable || !subscription) {
|
||||
return []
|
||||
@@ -503,7 +515,7 @@ function derivePlanTiers(
|
||||
|
||||
return gridTiers.map((tier): BillingPlanTierView => {
|
||||
const base: BillingPlanTierBase = {
|
||||
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
|
||||
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits, copy),
|
||||
name: tier.name,
|
||||
priceDisplay: tier.dollars_per_month_display,
|
||||
tierId: tier.tier_id
|
||||
@@ -529,13 +541,16 @@ function derivePlanTiers(
|
||||
|
||||
return {
|
||||
...base,
|
||||
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
|
||||
action: { label: copy.choose, url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
|
||||
state: 'upgrade'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
function paymentMethodRow(
|
||||
billing: BillingStateResponse,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingAccountRowView {
|
||||
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
||||
const card = billing.card
|
||||
|
||||
@@ -544,81 +559,80 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
||||
// it. The reason (buys/auto-refill are blocked) already leads the page as a
|
||||
// notice, so the row stays a bare call-to-action with no redundant status text.
|
||||
return {
|
||||
action: { label: 'Add payment method', url: portalUrl },
|
||||
action: { label: copy.addPayment, url: portalUrl },
|
||||
description: '',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method'
|
||||
title: copy.paymentMethod
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Update', url: portalUrl },
|
||||
description: 'Manage the card used for top-ups and subscription renewals.',
|
||||
action: { label: copy.update, url: portalUrl },
|
||||
description: copy.manageCard,
|
||||
id: 'payment_method',
|
||||
title: 'Payment method',
|
||||
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
|
||||
title: copy.paymentMethod,
|
||||
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via, copy)}`
|
||||
}
|
||||
}
|
||||
|
||||
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
function buyCreditsRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingAccountRowView {
|
||||
if (!billing.card) {
|
||||
// The no-card blocker is already spelled out by the page-level warn banner
|
||||
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
|
||||
// so keep the plain "what buying does" line and let the controls sit disabled.
|
||||
return {
|
||||
action: { disabled: true, label: 'Buy' },
|
||||
action: { disabled: true, label: copy.buy },
|
||||
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
||||
description: 'A single charge on your card, added to your balance today.',
|
||||
description: copy.buyDescription,
|
||||
id: 'buy_credits',
|
||||
title: 'Buy credits now'
|
||||
title: copy.buyCredits
|
||||
}
|
||||
}
|
||||
|
||||
const disabledReason = buyCreditsDisabledReason(billing)
|
||||
const disabledReason = buyCreditsDisabledReason(billing, copy)
|
||||
|
||||
if (disabledReason) {
|
||||
return {
|
||||
description: disabledReason,
|
||||
id: 'buy_credits',
|
||||
title: 'Buy credits now'
|
||||
title: copy.buyCredits
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: { disabled: true, label: 'Buy' },
|
||||
action: { disabled: true, label: copy.buy },
|
||||
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
|
||||
description: 'A single charge on your card, added to your balance today.',
|
||||
description: copy.buyDescription,
|
||||
id: 'buy_credits',
|
||||
title: 'Buy credits now'
|
||||
title: copy.buyCredits
|
||||
}
|
||||
}
|
||||
|
||||
// The generic first sentence shared by the off / absent / divergent states,
|
||||
// where the concrete amounts aren't the headline. The configured state overrides
|
||||
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
|
||||
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
|
||||
|
||||
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
function autoReloadRow(billing: BillingStateResponse, copy: BillingCopy = DEFAULT_BILLING_COPY): BillingAccountRowView {
|
||||
const autoReload = billing.auto_reload
|
||||
|
||||
if (!autoReload) {
|
||||
return {
|
||||
action: { disabled: true, label: 'Manage' },
|
||||
caption: 'Manage auto-refill from the portal.',
|
||||
description: AUTO_REFILL_GENERIC,
|
||||
action: { disabled: true, label: copy.manage },
|
||||
caption: copy.manageRefill,
|
||||
description: copy.refillDescription,
|
||||
id: 'auto_reload',
|
||||
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
|
||||
title: 'Refill when low'
|
||||
title: copy.refillWhenLow
|
||||
}
|
||||
}
|
||||
|
||||
if (!autoReload.enabled) {
|
||||
return {
|
||||
caption: 'Turn on auto-refill from the portal',
|
||||
description: AUTO_REFILL_GENERIC,
|
||||
caption: copy.turnOnRefill,
|
||||
description: copy.refillDescription,
|
||||
id: 'auto_reload',
|
||||
pill: { label: 'Off', tone: 'muted' },
|
||||
title: 'Refill when low'
|
||||
pill: { label: copy.off, tone: 'muted' },
|
||||
title: copy.refillWhenLow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,16 +640,16 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
// the default enabled path below — the same treatment as a canonical card.
|
||||
if (autoReload.card?.kind === 'distinct') {
|
||||
const { brand, last4 } = autoReload.card
|
||||
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
|
||||
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : copy.anotherCard
|
||||
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
|
||||
|
||||
return {
|
||||
action: { label: 'Reconcile ↗', url: portalUrl },
|
||||
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
|
||||
description: AUTO_REFILL_GENERIC,
|
||||
action: { label: copy.reconcile, url: portalUrl },
|
||||
caption: copy.reconcileCard(cardLabel),
|
||||
description: copy.refillDescription,
|
||||
id: 'auto_reload',
|
||||
pill: { label: 'Enabled', tone: 'primary' },
|
||||
title: 'Refill when low'
|
||||
pill: { label: copy.enabled, tone: 'primary' },
|
||||
title: copy.refillWhenLow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,22 +657,23 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
|
||||
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
|
||||
|
||||
return {
|
||||
action: { label: 'Manage' },
|
||||
action: { label: copy.manage },
|
||||
// Numbers live in the first sentence (spec §8); the swap region below carries
|
||||
// the editable fields, so no redundant caption here.
|
||||
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
|
||||
description: copy.chargesBelow(reloadTo, threshold),
|
||||
id: 'auto_reload',
|
||||
// The only row that edits in place — AutoReloadRow keys its swap layout off this
|
||||
// flag rather than sniffing the action label.
|
||||
manageInApp: true,
|
||||
pill: { label: 'Enabled', tone: 'primary' },
|
||||
title: 'Refill when low'
|
||||
pill: { label: copy.enabled, tone: 'primary' },
|
||||
title: copy.refillWhenLow
|
||||
}
|
||||
}
|
||||
|
||||
function deriveUsageRows(
|
||||
billing: BillingStateResponse,
|
||||
subscription: null | SubscriptionStateResponse
|
||||
subscription: null | SubscriptionStateResponse,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): BillingUsageRowView[] {
|
||||
const rows: BillingUsageRowView[] = []
|
||||
const current = subscription?.current
|
||||
@@ -671,8 +686,8 @@ function deriveUsageRows(
|
||||
const subscriptionValue =
|
||||
remaining != null && monthly != null
|
||||
? remaining < 0
|
||||
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
|
||||
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
|
||||
? copy.remaining(formatMoney(0), formatMoney(monthly), formatMoney(Math.abs(remaining)))
|
||||
: copy.remaining(formatMoney(remaining), formatMoney(monthly))
|
||||
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
|
||||
|
||||
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
|
||||
@@ -681,16 +696,16 @@ function deriveUsageRows(
|
||||
bar:
|
||||
remainingFraction != null
|
||||
? {
|
||||
label: 'Subscription credits remaining',
|
||||
label: copy.subscriptionRemaining,
|
||||
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
|
||||
tone: 'subscription',
|
||||
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
|
||||
value: clamp01(remainingFraction)
|
||||
}
|
||||
: undefined,
|
||||
caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`,
|
||||
caption: copy.resetsOn(formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at, copy)),
|
||||
id: 'subscription_credits',
|
||||
title: 'Subscription credits',
|
||||
title: copy.subscriptionCredits,
|
||||
value: subscriptionValue
|
||||
})
|
||||
|
||||
@@ -699,9 +714,9 @@ function deriveUsageRows(
|
||||
// No bar: top-ups have no denominator (the wire carries only the current
|
||||
// balance, and the pool is open-ended), so a fill fraction would be fiction.
|
||||
rows.push({
|
||||
caption: 'Does not expire',
|
||||
caption: copy.noExpiry,
|
||||
id: 'topup_credits',
|
||||
title: 'Top-up credits',
|
||||
title: copy.topupCredits,
|
||||
value: topupValue
|
||||
})
|
||||
|
||||
@@ -711,22 +726,22 @@ function deriveUsageRows(
|
||||
const limit = parseAmount(cap.limit_usd)
|
||||
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
|
||||
const usedFraction = limit != null && limit > 0 ? spent / limit : null
|
||||
const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used`
|
||||
const value = copy.used(cap.spent_display || formatMoney(spent), cap.limit_display || formatMoney(limit))
|
||||
|
||||
rows.push({
|
||||
bar:
|
||||
usedFraction != null
|
||||
? {
|
||||
label: 'Monthly spend cap used',
|
||||
label: copy.capUsed,
|
||||
state: usedFraction >= 0.9 ? 'danger' : 'ok',
|
||||
tone: 'cap',
|
||||
track: usedFraction >= 1 ? 'danger' : undefined,
|
||||
value: clamp01(usedFraction)
|
||||
}
|
||||
: undefined,
|
||||
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
|
||||
caption: cap.is_default_ceiling ? copy.defaultCeiling : copy.monthlyRemoteSpending,
|
||||
id: 'monthly_cap',
|
||||
title: 'Monthly spend cap',
|
||||
title: copy.monthlyCap,
|
||||
value
|
||||
})
|
||||
}
|
||||
@@ -738,7 +753,11 @@ function displayBalance(billing: BillingStateResponse): string {
|
||||
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
|
||||
}
|
||||
|
||||
function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string {
|
||||
function displayPlan(
|
||||
subscription: null | SubscriptionStateResponse,
|
||||
usage?: UsageModelData,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): string {
|
||||
const current = subscription?.current
|
||||
const tier = current?.tier_name ?? usage?.plan_name
|
||||
|
||||
@@ -748,7 +767,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa
|
||||
|
||||
const price = findCurrentTier(subscription)?.dollars_per_month_display
|
||||
|
||||
return price ? `${tier} · ${price}/mo` : tier
|
||||
return price ? `${tier} · ${price}${copy.monthSuffix}` : tier
|
||||
}
|
||||
|
||||
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
|
||||
@@ -760,33 +779,40 @@ function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData
|
||||
)
|
||||
}
|
||||
|
||||
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
|
||||
function buyCreditsDisabledReason(
|
||||
billing: BillingStateResponse,
|
||||
copy: BillingCopy = DEFAULT_BILLING_COPY
|
||||
): null | string {
|
||||
if (!billing.is_admin) {
|
||||
return resolveRefusal({ kind: 'role_required', message: '' }).message
|
||||
return resolveRefusal({ kind: 'role_required', message: '' }, copy).message
|
||||
}
|
||||
|
||||
if (!billing.cli_billing_enabled) {
|
||||
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
|
||||
.message
|
||||
return resolveRefusal(
|
||||
{ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
|
||||
copy
|
||||
).message
|
||||
}
|
||||
|
||||
if (!billing.can_charge) {
|
||||
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
|
||||
.message
|
||||
return resolveRefusal(
|
||||
{ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined },
|
||||
copy
|
||||
).message
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function provenanceSuffix(resolvedVia?: null | string): string {
|
||||
function provenanceSuffix(resolvedVia?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||
if (!resolvedVia) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
autoRefill: 'auto-refill card',
|
||||
customerDefault: 'customer default',
|
||||
subPin: 'subscription card'
|
||||
autoRefill: copy.refillCard,
|
||||
customerDefault: copy.customerDefault,
|
||||
subPin: copy.subscriptionCard
|
||||
}
|
||||
|
||||
return ` - ${labels[resolvedVia] ?? resolvedVia}`
|
||||
|
||||
@@ -3,6 +3,8 @@ import { act, renderHook } from '@testing-library/react'
|
||||
import { createElement, type PropsWithChildren } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
import type { BillingResult } from './api'
|
||||
import type { BillingChargeStatusResponse } from './types'
|
||||
|
||||
@@ -58,7 +60,11 @@ function controlledClock() {
|
||||
function wrapper({ children }: PropsWithChildren) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
return createElement(QueryClientProvider, { client }, children)
|
||||
return createElement(I18nProvider, {
|
||||
configClient: null,
|
||||
initialLocale: 'en',
|
||||
children: createElement(QueryClientProvider, { client }, children)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import type { BillingApi, BillingRefusal } from './api'
|
||||
import { useBillingApi } from './api'
|
||||
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
|
||||
import { resolveRefusal } from './errors'
|
||||
import type { BillingChargeStatusResponse } from './types'
|
||||
|
||||
@@ -43,6 +44,7 @@ export interface ChargePollClock {
|
||||
}
|
||||
|
||||
export interface ChargePollOptions extends ChargePollClock {
|
||||
copy?: BillingCopy
|
||||
portalUrl?: null | string
|
||||
}
|
||||
|
||||
@@ -66,6 +68,7 @@ export async function pollChargeSettlement(
|
||||
chargeId: string,
|
||||
opts: ChargePollOptions = {}
|
||||
): Promise<ChargeFlowOutcome> {
|
||||
const copy = opts.copy ?? DEFAULT_BILLING_COPY
|
||||
const sleep = opts.sleep ?? defaultSleep
|
||||
const now = opts.now ?? Date.now
|
||||
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
|
||||
@@ -96,51 +99,51 @@ export async function pollChargeSettlement(
|
||||
return {
|
||||
amountUsd: settlement.status.amount_usd,
|
||||
kind: 'success',
|
||||
message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.'
|
||||
message: settlement.status.amount_usd ? copy.chargeAdded(settlement.status.amount_usd) : copy.creditsAdded
|
||||
}
|
||||
|
||||
case 'failed':
|
||||
return {
|
||||
action: { type: 'retry' },
|
||||
kind: 'failure',
|
||||
message: renderChargeFailed(settlement.status.reason),
|
||||
message: renderChargeFailed(settlement.status.reason, copy),
|
||||
retryFreshKey: true,
|
||||
title: 'Charge failed'
|
||||
title: copy.chargeFailedTitle
|
||||
}
|
||||
case 'ambiguous': {
|
||||
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
|
||||
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
|
||||
const resolved = resolveRefusal(refusal)
|
||||
const resolved = resolveRefusal(refusal, copy)
|
||||
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
|
||||
|
||||
return {
|
||||
kind: 'ambiguous',
|
||||
message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
|
||||
message: copy.unconfirmed(resolved.message),
|
||||
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
|
||||
title: 'Charge outcome unconfirmed'
|
||||
title: copy.unconfirmedTitle
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'failure',
|
||||
message: observed.refusal?.message || 'Could not check the charge.',
|
||||
message: observed.refusal?.message || copy.checkFailedMessage,
|
||||
retryFreshKey: true,
|
||||
title: 'Could not check charge'
|
||||
title: copy.checkFailedTitle
|
||||
}
|
||||
}
|
||||
|
||||
case 'refused':
|
||||
return {
|
||||
kind: 'failure',
|
||||
message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.',
|
||||
message: observed.refusal?.message || settlement.status.message || copy.checkFailedMessage,
|
||||
retryFreshKey: true,
|
||||
title: 'Could not check charge'
|
||||
title: copy.checkFailedTitle
|
||||
}
|
||||
|
||||
case 'cancelled':
|
||||
|
||||
case 'timed_out':
|
||||
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl)
|
||||
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl, copy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +176,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
export function useChargeFlow() {
|
||||
const copy = useBillingCopy()
|
||||
const api = useBillingApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
|
||||
@@ -206,7 +210,7 @@ export function useChargeFlow() {
|
||||
const chargeResult = await api.charge(amountUsd, idempotencyKey)
|
||||
|
||||
if (!chargeResult.ok) {
|
||||
const resolved = resolveRefusal(chargeResult.refusal)
|
||||
const resolved = resolveRefusal(chargeResult.refusal, copy)
|
||||
|
||||
const action =
|
||||
resolved.action.type === 'portal'
|
||||
@@ -239,9 +243,9 @@ export function useChargeFlow() {
|
||||
if (!chargeId) {
|
||||
setOutcome({
|
||||
kind: 'failure',
|
||||
message: 'The billing service accepted the request but did not return a charge id.',
|
||||
message: copy.missingChargeId,
|
||||
retryFreshKey: true,
|
||||
title: 'Charge could not be tracked'
|
||||
title: copy.untrackedTitle
|
||||
})
|
||||
setPhaseState('done')
|
||||
|
||||
@@ -251,6 +255,7 @@ export function useChargeFlow() {
|
||||
setPhaseState('polling')
|
||||
|
||||
const pollOutcome = await pollChargeSettlement(api, chargeId, {
|
||||
copy,
|
||||
portalUrl: chargeResult.data.portal_url
|
||||
})
|
||||
|
||||
@@ -261,7 +266,7 @@ export function useChargeFlow() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
}
|
||||
},
|
||||
[api, queryClient, setPhaseState]
|
||||
[api, queryClient, setPhaseState, copy]
|
||||
)
|
||||
|
||||
return { outcome, phase, reset, start }
|
||||
@@ -271,27 +276,27 @@ function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
|
||||
return retryableSendKinds.has(refusal.kind)
|
||||
}
|
||||
|
||||
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
|
||||
function timeoutOutcome(portalUrl?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): ChargeFlowOutcome {
|
||||
return {
|
||||
kind: 'ambiguous',
|
||||
message: 'Charge may still settle. Check the portal before retrying.',
|
||||
message: copy.stillProcessingMessage,
|
||||
portalUrl: portalUrl ?? undefined,
|
||||
title: 'Still processing after 5 minutes'
|
||||
title: copy.stillProcessingTitle
|
||||
}
|
||||
}
|
||||
|
||||
function renderChargeFailed(reason?: null | string): string {
|
||||
function renderChargeFailed(reason?: null | string, copy: BillingCopy = DEFAULT_BILLING_COPY): string {
|
||||
switch ((reason || '').trim()) {
|
||||
case 'authentication_required':
|
||||
return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.'
|
||||
return copy.bankVerification
|
||||
|
||||
case 'payment_method_expired':
|
||||
return 'Your card has expired. Update it on the portal.'
|
||||
return copy.cardExpired
|
||||
|
||||
case 'card_declined':
|
||||
return 'Your card was declined. Try another card on the portal.'
|
||||
return copy.cardDeclined
|
||||
|
||||
default:
|
||||
return `The charge didn't go through (${reason || 'processing_error'}).`
|
||||
return copy.chargeFailed(reason || 'processing_error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { createElement, type PropsWithChildren } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
stepUp: vi.fn()
|
||||
}))
|
||||
@@ -56,7 +58,11 @@ import { useStepUpFlow } from './use-step-up'
|
||||
|
||||
function createWrapper(client: QueryClient) {
|
||||
return function wrapper({ children }: PropsWithChildren) {
|
||||
return createElement(QueryClientProvider, { client }, children)
|
||||
return createElement(I18nProvider, {
|
||||
configClient: null,
|
||||
initialLocale: 'en',
|
||||
children: createElement(QueryClientProvider, { client }, children)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
||||
import { useBillingApi } from './api'
|
||||
import { useBillingCopy } from './copy'
|
||||
import { resolveRefusal } from './errors'
|
||||
|
||||
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
|
||||
@@ -26,6 +27,7 @@ interface StepUpVerificationPayload {
|
||||
}
|
||||
|
||||
export function useStepUpFlow() {
|
||||
const copy = useBillingCopy()
|
||||
const api = useBillingApi()
|
||||
const gateway = useStore($gateway)
|
||||
const queryClient = useQueryClient()
|
||||
@@ -107,7 +109,7 @@ export function useStepUpFlow() {
|
||||
unsubscribe()
|
||||
|
||||
if (!result.ok) {
|
||||
const resolved = resolveRefusal(result.refusal)
|
||||
const resolved = resolveRefusal(result.refusal, copy)
|
||||
|
||||
setMessage({
|
||||
kind: 'error',
|
||||
@@ -121,8 +123,8 @@ export function useStepUpFlow() {
|
||||
if (!result.data.granted) {
|
||||
setMessage({
|
||||
kind: 'error',
|
||||
text: 'Verification finished without allowing Remote Spending for this terminal.',
|
||||
title: 'Verification was not approved'
|
||||
text: copy.verificationDeniedMessage,
|
||||
title: copy.verificationDeniedTitle
|
||||
})
|
||||
|
||||
return
|
||||
@@ -134,10 +136,10 @@ export function useStepUpFlow() {
|
||||
])
|
||||
setMessage({
|
||||
kind: 'success',
|
||||
text: 'Remote Spending is allowed for this terminal.',
|
||||
title: 'Verification complete'
|
||||
text: copy.verificationDoneMessage,
|
||||
title: copy.verificationDoneTitle
|
||||
})
|
||||
}, [api, gateway, queryClient, unsubscribe])
|
||||
}, [api, gateway, queryClient, unsubscribe, copy])
|
||||
|
||||
return { dismiss, message, openVerification, phase, start, verification }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user