feat(workspaces): add billing settings tab

This commit is contained in:
saltbo
2026-07-23 11:58:02 -04:00
parent ea8fdf236e
commit 0cfc0fafd4
14 changed files with 641 additions and 546 deletions
@@ -0,0 +1,38 @@
import type { CloudProduct } from '@shared/types'
import { openNewTab } from '@/lib/browser-navigation'
import type { CheckoutSelection } from './checkout-confirm-dialog'
type CheckoutTabInput =
| { action: 'checkout'; packageId: string; priceId: string; promotionCode?: string }
| { action: 'payment'; orderId: string }
| { action: 'portal' }
export function openCheckoutTab(input: CheckoutTabInput) {
const search = new URLSearchParams({ action: input.action })
if (input.action === 'checkout') {
search.set('packageId', input.packageId)
search.set('priceId', input.priceId)
if (input.promotionCode) search.set('promotionCode', input.promotionCode)
}
if (input.action === 'payment') search.set('orderId', input.orderId)
openNewTab(`/store/checkout?${search.toString()}`)
}
export function resolveCheckoutSelection(
products: CloudProduct[],
packageId: string,
priceId: string,
): CheckoutSelection | null {
const product = products.find((item) => item.id === packageId)
const price = product?.prices.find((item) => item.id === priceId)
if (!product || !price) return null
const interval = price.recurring?.interval
return {
packageId,
priceId,
productName: product.name,
amount: price.amount,
currency: price.currency,
interval: interval === 'month' || interval === 'year' ? interval : null,
}
}
+98 -75
View File
@@ -1,6 +1,8 @@
import type { CloudCreditLedgerEntry } from '@shared/schemas'
import type { CloudProduct } from '@shared/types'
import { BadgeCent, PlusCircle } from 'lucide-react'
import { BadgeCent, CircleDollarSign } from 'lucide-react'
import type { ReactNode } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -8,6 +10,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -16,7 +19,7 @@ import { cloudProductIncludedCredits } from '@/lib/cloud-product'
import { formatCurrency } from '@/lib/format'
import { StorageActions } from './storage-dialogs'
export function CreditBalanceButton({
export function CreditBillingPanel({
credits,
products,
entries,
@@ -25,6 +28,7 @@ export function CreditBalanceButton({
onCheckout,
isRedeeming,
checkoutDisabled,
accountAction,
}: {
credits?: { balance: number }
products: CloudProduct[]
@@ -34,37 +38,37 @@ export function CreditBalanceButton({
onCheckout: (packageId: string, priceId: string) => void
isRedeeming: boolean
checkoutDisabled: boolean
accountAction?: ReactNode
}) {
const { t } = useTranslation()
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground"
aria-label={t('storage.viewCreditActivity')}
>
<BadgeCent className="h-4 w-4" />
{t('storage.creditsButton')}
</button>
</DialogTrigger>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t('storage.creditsButton')}</DialogTitle>
<DialogDescription>{t('storage.creditActivityDescription')}</DialogDescription>
</DialogHeader>
<CreditBalanceSummary credits={credits} onRedeem={onRedeem} isRedeeming={isRedeeming} />
<CreditProducts products={products} disabled={checkoutDisabled} onCheckout={onCheckout} />
<div className="space-y-3">
<h3 className="text-sm font-medium">{t('storage.creditActivityTitle')}</h3>
<CreditActivity entries={entries} loading={loading} />
<section className="space-y-6">
<div className="flex min-h-32 flex-wrap items-center justify-between gap-6 rounded-lg border bg-card px-6 py-5">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<BadgeCent className="size-4" />
<span>{t('storage.creditBalance')}</span>
</div>
<div className="text-4xl font-semibold tracking-tight tabular-nums">
{credits ? formatCredits(credits.balance) : t('common.loading')}
</div>
</div>
</DialogContent>
</Dialog>
<div className="flex flex-wrap items-center gap-2">
<CreditTopUpDialog products={products} disabled={checkoutDisabled} onCheckout={onCheckout} />
<StorageActions onRedeem={onRedeem} isRedeeming={isRedeeming} />
{accountAction}
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-semibold">{t('storage.creditActivityTitle')}</h3>
<CreditActivity entries={entries} loading={loading} />
</div>
</section>
)
}
function CreditProducts({
function CreditTopUpDialog({
products,
disabled,
onCheckout,
@@ -74,63 +78,82 @@ function CreditProducts({
onCheckout: (packageId: string, priceId: string) => void
}) {
const { t, i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? 'en'
const [open, setOpen] = useState(false)
const purchasableProducts = products
.map((product) => ({ product, price: oneTimeUsdPrice(product) }))
.filter((item): item is { product: CloudProduct; price: CloudProduct['prices'][number] & { id: string } } =>
Boolean(item.price),
)
const [selectedId, setSelectedId] = useState<string | null>(null)
const selected = purchasableProducts.find(({ product }) => product.id === selectedId) ?? purchasableProducts[0]
if (purchasableProducts.length === 0) return null
function continueCheckout() {
if (!selected) return
setOpen(false)
onCheckout(selected.product.id, selected.price.id)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">{t('storage.creditTopUpTitle')}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{purchasableProducts.map(({ product, price }) => (
<div key={product.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{product.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('storage.creditTopUpAmount', { amount: formatCredits(cloudProductIncludedCredits(product)) })}
</div>
</div>
<div className="shrink-0 text-sm font-semibold tabular-nums">
{formatCurrency(price.amount, price.currency, language)}
</div>
</div>
<Button className="mt-3 h-8 w-full" disabled={disabled} onClick={() => onCheckout(product.id, price.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.buyCredits')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button disabled={disabled}>
<CircleDollarSign className="size-4" />
{t('storage.creditTopUpTitle')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('storage.creditTopUpTitle')}</DialogTitle>
<DialogDescription>{t('storage.creditTopUpDialogDescription')}</DialogDescription>
</DialogHeader>
{purchasableProducts.length > 0 ? (
<div className="space-y-2 py-2">
{purchasableProducts.map(({ product, price }) => {
const selectedProduct = selected?.product.id === product.id
return (
<label
key={product.id}
className={`flex cursor-pointer items-center justify-between gap-4 rounded-md border px-4 py-3 transition-colors ${
selectedProduct ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted/50'
}`}
>
<span className="flex min-w-0 items-center gap-3">
<input
type="radio"
name="credit-top-up"
value={product.id}
checked={selectedProduct}
className="size-4 shrink-0 accent-primary"
onChange={() => setSelectedId(product.id)}
/>
<span className="font-medium">
{t('storage.creditTopUpAmount', {
amount: formatCredits(cloudProductIncludedCredits(product)),
})}
</span>
</span>
<span className="shrink-0 font-semibold tabular-nums">
{formatCurrency(price.amount, price.currency, i18n.resolvedLanguage ?? 'en')}
</span>
</label>
)
})}
</div>
))}
</div>
</div>
)
}
function CreditBalanceSummary({
credits,
onRedeem,
isRedeeming,
}: {
credits?: { balance: number }
onRedeem: (code: string) => void
isRedeeming: boolean
}) {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-muted/20 p-4">
<div className="min-w-0">
<div className="text-sm text-muted-foreground">{t('storage.creditBalance')}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums">
{credits ? formatCredits(credits.balance) : t('common.loading')}
</div>
</div>
<StorageActions onRedeem={onRedeem} isRedeeming={isRedeeming} />
</div>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t('storage.noCreditTopUps')}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t('common.cancel')}
</Button>
<Button disabled={!selected} onClick={continueCheckout}>
{t('storage.proceedToCheckout')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -141,7 +164,7 @@ function CreditActivity({ entries, loading }: { entries: CloudCreditLedgerEntry[
if (entries.length === 0) return <CreditEmptyState label={t('storage.creditActivityEmpty')} />
return (
<div className="max-h-[60vh] overflow-auto rounded-lg border">
<div className="overflow-x-auto rounded-lg border">
<table className="w-full caption-bottom text-left text-sm">
<thead className="sticky top-0 border-b bg-background">
<tr>
+1 -10
View File
@@ -20,20 +20,18 @@ export function StorageOrderHistoryDialog({
orders,
onContinuePayment,
onCancelOrder,
continuingOrderId,
cancelingOrderId,
}: {
orders: CloudOrder[]
onContinuePayment: (orderId: string) => void
onCancelOrder: (orderId: string) => void
continuingOrderId: string | null
cancelingOrderId: string | null
}) {
const { t } = useTranslation()
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline">
<ShoppingCart />
{t('storage.historyTitle')}
</Button>
@@ -48,7 +46,6 @@ export function StorageOrderHistoryDialog({
orders={orders}
onContinuePayment={onContinuePayment}
onCancelOrder={onCancelOrder}
continuingOrderId={continuingOrderId}
cancelingOrderId={cancelingOrderId}
/>
</div>
@@ -124,7 +121,6 @@ function OrderRow({
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{formatTargetValue(order, 'orgId')}</span>
<span>{new Date(order.createdAt).toLocaleString()}</span>
</div>
</div>
@@ -208,8 +204,3 @@ function isActionableOrder(order: CloudOrder) {
if (order.status !== 'pending') return false
return order.paymentStatus !== 'paid' && order.paymentStatus !== 'canceled'
}
function formatTargetValue(order: CloudOrder, key: string) {
const value = order.target?.[key]
return typeof value === 'string' ? value : '-'
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { CreditBalanceButton } from './credits-panel'
export { StorageOrderHistoryContent, StorageOrderHistoryDialog } from './order-history'
export { CreditBillingPanel } from './credits-panel'
export { StorageOrderHistoryDialog } from './order-history'
export { StorageActions } from './storage-dialogs'
export { StoragePackages } from './storage-packages'
export { StorageUnavailableState } from './storage-unavailable'
+1 -1
View File
@@ -131,7 +131,7 @@ describe('OrgSwitcher', () => {
})
it('opens the first workspace settings tab when switching workspaces from settings', async () => {
mocks.pathname = '/teams/personal-org/ihost'
mocks.pathname = '/teams/personal-org/billing'
const view = await openSwitcher()
fireEvent.click(view.getByRole('menuitem', { name: /Design Team/ }))
+1 -1
View File
@@ -57,7 +57,7 @@ export function OrgSwitcher() {
return
}
await queryClient.invalidateQueries({ queryKey: ['objects'] })
const isWorkspaceSettings = /^\/teams\/[^/]+\/(activity|ihost|members|settings)$/.test(pathname)
const isWorkspaceSettings = /^\/teams\/[^/]+\/(activity|billing|ihost|members|settings)$/.test(pathname)
if (isWorkspaceSettings) {
navigate({ to: '/teams/$teamId/settings', params: { teamId: org.id } })
return
+4
View File
@@ -1188,6 +1188,7 @@
"teams.tabMembers": "Members",
"teams.tabActivity": "Activity",
"teams.tabSettings": "Settings",
"teams.tabBilling": "Billing",
"teams.empty": "No teams yet. Create one to get started.",
"teams.createNew": "Create Team",
"teams.createTitle": "Create New Team",
@@ -1917,6 +1918,8 @@
"storage.creditActivityDescription": "Credit changes from gift-card redemptions, grants, top-ups, and usage charges.",
"storage.creditActivityEmpty": "No credit activity yet.",
"storage.creditTopUpTitle": "Credit top-ups",
"storage.creditTopUpDialogDescription": "Choose a top-up package to add Credits to this workspace.",
"storage.noCreditTopUps": "No Credit top-ups are available right now.",
"storage.creditTopUpAmount": "{{amount}} Credits",
"storage.buyCredits": "Buy Credits",
"storage.creditTableType": "Type",
@@ -1970,6 +1973,7 @@
"storage.planAlreadyActive": "Plan already active",
"storage.checkoutPending": "Waiting for Stripe payment confirmation. Quota and order status will refresh automatically.",
"storage.teamMemberNotice": "Storage for this team space is managed by the team owner. Contact the owner if more capacity is needed.",
"storage.teamMemberBillingNotice": "Billing for this team space is managed by the team owner.",
"storage.checkoutRedirectTitle": "Preparing checkout",
"storage.checkoutRedirectDescription": "Creating a secure payment session. You will be redirected automatically.",
"storage.checkoutRedirectErrorTitle": "Checkout could not start",
+4
View File
@@ -1188,6 +1188,7 @@
"teams.tabMembers": "成员",
"teams.tabActivity": "动态",
"teams.tabSettings": "设置",
"teams.tabBilling": "账单",
"teams.empty": "暂无团队,创建一个开始使用。",
"teams.createNew": "创建团队",
"teams.createTitle": "创建新团队",
@@ -1917,6 +1918,8 @@
"storage.creditActivityDescription": "展示礼品卡兑换、授予、充值和用量扣减带来的积分变动。",
"storage.creditActivityEmpty": "暂无积分流水。",
"storage.creditTopUpTitle": "积分充值",
"storage.creditTopUpDialogDescription": "选择充值包,为当前空间增加积分。",
"storage.noCreditTopUps": "当前暂无可购买的积分充值包。",
"storage.creditTopUpAmount": "{{amount}} 积分",
"storage.buyCredits": "购买积分",
"storage.creditTableType": "类型",
@@ -1970,6 +1973,7 @@
"storage.planAlreadyActive": "已有生效套餐",
"storage.checkoutPending": "正在等待 Stripe 支付确认,配额和订单状态会自动刷新。",
"storage.teamMemberNotice": "该团队空间的存储由团队所有者管理,如需更多容量请联系所有者。",
"storage.teamMemberBillingNotice": "该团队空间的账单由团队所有者管理。",
"storage.checkoutRedirectTitle": "正在准备支付",
"storage.checkoutRedirectDescription": "正在创建安全支付会话,稍后会自动跳转。",
"storage.checkoutRedirectErrorTitle": "无法发起支付",
+22
View File
@@ -54,6 +54,7 @@ import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_a
import { Route as AuthenticatedTeamsTeamIdSettingsRouteImport } from './routes/_authenticated/teams/$teamId/settings'
import { Route as AuthenticatedTeamsTeamIdMembersRouteImport } from './routes/_authenticated/teams/$teamId/members'
import { Route as AuthenticatedTeamsTeamIdIhostRouteImport } from './routes/_authenticated/teams/$teamId/ihost'
import { Route as AuthenticatedTeamsTeamIdBillingRouteImport } from './routes/_authenticated/teams/$teamId/billing'
import { Route as AuthenticatedTeamsTeamIdActivityRouteImport } from './routes/_authenticated/teams/$teamId/activity'
import { Route as AuthenticatedAdminUsersUserIdRouteImport } from './routes/_authenticated/admin/users/$userId'
import { Route as AuthenticatedAdminTeamsOrgIdRouteImport } from './routes/_authenticated/admin/teams/$orgId'
@@ -309,6 +310,12 @@ const AuthenticatedTeamsTeamIdIhostRoute =
path: '/ihost',
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
} as any)
const AuthenticatedTeamsTeamIdBillingRoute =
AuthenticatedTeamsTeamIdBillingRouteImport.update({
id: '/billing',
path: '/billing',
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
} as any)
const AuthenticatedTeamsTeamIdActivityRoute =
AuthenticatedTeamsTeamIdActivityRouteImport.update({
id: '/activity',
@@ -382,6 +389,7 @@ export interface FileRoutesByFullPath {
'/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
'/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -430,6 +438,7 @@ export interface FileRoutesByTo {
'/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
'/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -483,6 +492,7 @@ export interface FileRoutesById {
'/_authenticated/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
'/_authenticated/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/_authenticated/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
'/_authenticated/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
'/_authenticated/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/_authenticated/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -536,6 +546,7 @@ export interface FileRouteTypes {
| '/admin/teams/$orgId'
| '/admin/users/$userId'
| '/teams/$teamId/activity'
| '/teams/$teamId/billing'
| '/teams/$teamId/ihost'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
@@ -584,6 +595,7 @@ export interface FileRouteTypes {
| '/admin/teams/$orgId'
| '/admin/users/$userId'
| '/teams/$teamId/activity'
| '/teams/$teamId/billing'
| '/teams/$teamId/ihost'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
@@ -636,6 +648,7 @@ export interface FileRouteTypes {
| '/_authenticated/admin/teams/$orgId'
| '/_authenticated/admin/users/$userId'
| '/_authenticated/teams/$teamId/activity'
| '/_authenticated/teams/$teamId/billing'
| '/_authenticated/teams/$teamId/ihost'
| '/_authenticated/teams/$teamId/members'
| '/_authenticated/teams/$teamId/settings'
@@ -975,6 +988,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedTeamsTeamIdIhostRouteImport
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
}
'/_authenticated/teams/$teamId/billing': {
id: '/_authenticated/teams/$teamId/billing'
path: '/billing'
fullPath: '/teams/$teamId/billing'
preLoaderRoute: typeof AuthenticatedTeamsTeamIdBillingRouteImport
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
}
'/_authenticated/teams/$teamId/activity': {
id: '/_authenticated/teams/$teamId/activity'
path: '/activity'
@@ -1081,6 +1101,7 @@ const AuthenticatedSettingsRouteRouteWithChildren =
interface AuthenticatedTeamsTeamIdRouteRouteChildren {
AuthenticatedTeamsTeamIdActivityRoute: typeof AuthenticatedTeamsTeamIdActivityRoute
AuthenticatedTeamsTeamIdBillingRoute: typeof AuthenticatedTeamsTeamIdBillingRoute
AuthenticatedTeamsTeamIdIhostRoute: typeof AuthenticatedTeamsTeamIdIhostRoute
AuthenticatedTeamsTeamIdMembersRoute: typeof AuthenticatedTeamsTeamIdMembersRoute
AuthenticatedTeamsTeamIdSettingsRoute: typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -1091,6 +1112,7 @@ const AuthenticatedTeamsTeamIdRouteRouteChildren: AuthenticatedTeamsTeamIdRouteR
{
AuthenticatedTeamsTeamIdActivityRoute:
AuthenticatedTeamsTeamIdActivityRoute,
AuthenticatedTeamsTeamIdBillingRoute: AuthenticatedTeamsTeamIdBillingRoute,
AuthenticatedTeamsTeamIdIhostRoute: AuthenticatedTeamsTeamIdIhostRoute,
AuthenticatedTeamsTeamIdMembersRoute: AuthenticatedTeamsTeamIdMembersRoute,
AuthenticatedTeamsTeamIdSettingsRoute:
+3 -279
View File
@@ -1,11 +1,10 @@
import type { CloudOrder, CloudProduct } from '@shared/types'
import type { CloudProduct } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ApiError,
cancelCloudOrder,
getCloudCredits,
getUserQuota,
listCloudCreditLedgerEntries,
@@ -13,7 +12,6 @@ import {
listCloudOrders,
listCloudProducts,
listCloudStoreTargets,
redeemCloudGiftCard,
} from '@/lib/api'
import { openNewTab } from '@/lib/browser-navigation'
import { StoragePage } from './storage'
@@ -115,44 +113,6 @@ vi.mock('@/lib/api', () => {
}
})
function order(overrides: Partial<CloudOrder> = {}): CloudOrder {
return {
id: 'order-1',
storeId: 'store-1',
buyerAccountId: 'buyer-1',
target: { orgId: 'org-1' },
status: 'paid',
subtotalAmount: 999,
discountAmount: 0,
totalAmount: 999,
currency: 'usd',
items: [
{
id: 'item-1',
orderId: 'order-1',
productId: 'pkg-1',
productType: 'store_item',
name: '100 GB',
description: null,
quantity: 1,
unitAmount: 999,
totalAmount: 999,
fulfillmentPayload: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, includedCredits: 0 },
},
},
],
payments: [],
paymentStatus: 'paid',
fulfillmentStatus: 'fulfilled',
createdAt: '2026-05-05T00:00:00.000Z',
paidAt: '2026-05-05T00:00:00.000Z',
fulfilledAt: '2026-05-05T00:00:00.000Z',
canceledAt: null,
...overrides,
}
}
function quotaPackage(): CloudProduct {
return {
id: 'pkg-1',
@@ -233,31 +193,6 @@ function higherSubscriptionPackage(): CloudProduct {
}
}
function creditPackage(): CloudProduct {
return {
id: 'pkg-credits',
storeId: 'store-1',
type: 'store_item',
name: '5,000 Credits',
description: 'Credit top-up',
metadata: {
deliverable: { type: 'zpan.credits', includedCredits: 5000 },
},
prices: [
{
id: 'price-credits-usd',
currency: 'usd',
amount: 2999,
metadata: { creditGrantType: 'top_up', creditAmount: '5000' },
},
],
active: true,
sortOrder: 1,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
}
}
function renderStoragePage(queryClient: QueryClient) {
return render(
<QueryClientProvider client={queryClient}>
@@ -305,25 +240,6 @@ describe('StoragePage', () => {
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
})
it('refreshes quota when a checkout order is delivered', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({
items: [order()],
total: 1,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
renderStoragePage(queryClient)
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] }))
})
it('shows effective quota and plan credits status', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
@@ -369,7 +285,7 @@ describe('StoragePage', () => {
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
expect(view.getAllByRole('progressbar')).toHaveLength(2)
expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy()
expect(view.queryByLabelText('storage.viewCreditActivity')).toBeNull()
await waitFor(() => expect(view.getAllByText('1.5 KB').length).toBeGreaterThan(0))
expect(view.getByText('storage.trafficPeriodDetail:2026-05')).toBeTruthy()
})
@@ -720,13 +636,9 @@ describe('StoragePage', () => {
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=portal')
})
it('uses the active workspace for orders and checkout', async () => {
it('uses the active workspace for checkout', async () => {
activeOrganization.value = { id: 'org-2' }
vi.mocked(listCloudProducts).mockResolvedValue({ items: [quotaPackage()], total: 1 })
vi.mocked(listCloudOrders).mockResolvedValue({
items: [order({ id: 'order-2', target: { orgId: 'org-2' } })],
total: 1,
})
const queryClient = new QueryClient({
defaultOptions: {
@@ -736,12 +648,6 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
await waitFor(() => expect(view.getByText('org-2')).toBeTruthy())
expect(vi.mocked(listCloudOrders)).toHaveBeenCalledWith()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => expect(view.queryByText('org-2')).toBeNull())
fireEvent.click(await view.findByRole('button', { name: /storage.checkoutMonthly/ }))
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
@@ -837,186 +743,4 @@ describe('StoragePage', () => {
expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy()
expect(view.queryByRole('button', { name: 'storage.redeemTitle' })).toBeNull()
})
it('shows credit balance inside the credits dialog', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
const creditsButton = view.getByLabelText('storage.viewCreditActivity')
expect(creditsButton).toBeTruthy()
expect(creditsButton.textContent).toContain('storage.creditsButton')
expect(creditsButton.textContent).not.toContain('1,250')
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
expect(view.queryByText('storage.creditBalance')).toBeNull()
expect(view.queryByText('1,250')).toBeNull()
fireEvent.click(creditsButton)
await waitFor(() => expect(view.getAllByText('storage.creditBalance').length).toBeGreaterThan(0))
expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy()
await waitFor(() => expect(view.getAllByText('1,250').length).toBeGreaterThan(0))
})
it('starts checkout from a credits top-up product', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [creditPackage()], total: 1 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
expect(await view.findByText('storage.creditTopUpTitle')).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: 'storage.buyCredits' }))
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
expect(openNewTab).toHaveBeenCalledWith(
'/store/checkout?action=checkout&packageId=pkg-credits&priceId=price-credits-usd',
)
})
it('opens credit activity dialog', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({
items: [
{
id: 'ledger-1',
creditAccountId: 'credit-account-1',
creditBucketId: 'credit-bucket-1',
storeId: 'store-1',
customerId: 'org-1',
amount: 500,
direction: 'credit',
status: 'posted',
sourceType: 'gift_card_redemption',
sourceId: 'gift-1',
orderId: null,
paymentId: null,
createdAt: '2026-05-08T00:00:00.000Z',
},
],
total: 1,
limit: 50,
offset: 0,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
expect(await view.findByText('storage.creditActivityTitle')).toBeTruthy()
expect(view.getAllByText('1,250').length).toBeGreaterThan(0)
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
})
it('redeems a gift card successfully', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
vi.mocked(redeemCloudGiftCard).mockResolvedValue({
redeemedCredits: 5000,
entries: [],
failures: [],
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
await waitFor(() => expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.redeemTitle' }))
fireEvent.change(view.getByLabelText('storage.giftCardCode'), { target: { value: 'ZS-1234-5678' } })
fireEvent.click(view.getByRole('button', { name: 'storage.redeemAction' }))
await waitFor(() => expect(redeemCloudGiftCard).toHaveBeenCalledWith('ZS-1234-5678'))
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:5000')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
})
it('continues payment for an unpaid order', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({
items: [order({ id: 'order-unpaid', paymentStatus: 'unpaid', status: 'pending' })],
total: 1,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
await waitFor(() => expect(view.getByLabelText('storage.continuePayment')).toBeTruthy())
fireEvent.click(view.getByLabelText('storage.continuePayment'))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=payment&orderId=order-unpaid')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] })
})
it('cancels an unpaid order', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({
items: [order({ id: 'order-unpaid', paymentStatus: 'unpaid', status: 'pending' })],
total: 1,
})
vi.mocked(cancelCloudOrder).mockResolvedValue(
order({ id: 'order-unpaid', status: 'canceled', paymentStatus: 'canceled' }),
)
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
await waitFor(() => expect(view.getByLabelText('storage.cancelOrder')).toBeTruthy())
fireEvent.click(view.getByLabelText('storage.cancelOrder'))
expect(await view.findByText('storage.cancelConfirm')).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: 'common.confirm' }))
await waitFor(() => expect(cancelCloudOrder).toHaveBeenCalledWith('order-unpaid'))
expect(toast.success).toHaveBeenCalledWith('storage.cancelSuccess')
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
})
})
+7 -178
View File
@@ -1,41 +1,17 @@
import type { CloudProduct } from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CheckoutConfirmDialog, type CheckoutSelection } from '@/components/store/checkout-confirm-dialog'
import { openCheckoutTab, resolveCheckoutSelection } from '@/components/store/checkout-navigation'
import {
CreditBalanceButton,
CurrentPlanCard,
FreeQuotaCard,
StorageOrderHistoryDialog,
StoragePackages,
StorageUnavailableState,
} from '@/components/store/storage-panels'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ApiError,
cancelCloudOrder,
getCloudCredits,
getUserQuota,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
listCloudStoreTargets,
redeemCloudGiftCard,
} from '@/lib/api'
import { ApiError, getCloudCredits, getUserQuota, listCloudProducts, listCloudStoreTargets } from '@/lib/api'
import { useActiveOrganization } from '@/lib/auth-client'
import { openNewTab } from '@/lib/browser-navigation'
export const Route = createFileRoute('/_authenticated/storage')({
component: StoragePage,
@@ -45,7 +21,6 @@ export function StoragePage() {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const [checkoutRefreshActive, setCheckoutRefreshActive] = useState(false)
const [cancelOrderId, setCancelOrderId] = useState<string | null>(null)
const [checkoutSelection, setCheckoutSelection] = useState<CheckoutSelection | null>(null)
const { data: activeOrg } = useActiveOrganization()
const cloudStoreQuery = useQuery({
@@ -65,12 +40,6 @@ export function StoragePage() {
const currentTarget = targetsQuery.data?.items.find((item) => item.orgId === targetOrgId)
const isTeamSpace = currentTarget?.type === 'team'
const canManageBilling = targetsQuery.isSuccess && (!isTeamSpace || currentTarget?.role === 'owner')
const ordersQuery = useQuery({
queryKey: ['cloud-store', 'orders', targetOrgId],
queryFn: () => listCloudOrders(),
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
const quotaQuery = useQuery({
queryKey: ['user', 'quota', targetOrgId],
queryFn: getUserQuota,
@@ -83,26 +52,7 @@ export function StoragePage() {
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
const creditProductsQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'products'],
queryFn: listCloudCreditProducts,
enabled: cloudStoreQuery.isSuccess,
retry: false,
})
const creditLedgerQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
queryFn: listCloudCreditLedgerEntries,
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
const currentOrders = ordersQuery.data?.items ?? []
const deliveredCheckoutCount = currentOrders.filter((order) => order.fulfillmentStatus === 'fulfilled').length
const hasActiveSubscription = quotaQuery.data?.currentPlan?.subscription === true
const credits = creditsQuery.data ? { balance: creditsQuery.data.balance } : undefined
useEffect(() => {
if (deliveredCheckoutCount > 0) queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
}, [deliveredCheckoutCount, queryClient])
useEffect(() => {
if (!checkoutRefreshActive) return
@@ -118,36 +68,8 @@ export function StoragePage() {
}
}, [checkoutRefreshActive, queryClient])
const cancelOrderMutation = useMutation({
mutationFn: (orderId: string) => cancelCloudOrder(orderId),
onSuccess: () => {
toast.success(t('storage.cancelSuccess'))
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
},
onError: (err) => {
toast.error(err.message)
},
})
const redeemMutation = useMutation({
mutationFn: (code: string) => redeemCloudGiftCard(code),
onSuccess: (result) => {
toast.success(
t('storage.redeemSuccess', {
amount: result.redeemedCredits,
}),
)
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
},
onError: (err) => {
toast.error(err.message)
},
})
function requestCheckout(packageId: string, priceId: string) {
const products = [...(cloudStoreQuery.data?.items ?? []), ...(creditProductsQuery.data?.items ?? [])]
const selection = resolveCheckoutSelection(products, packageId, priceId)
const selection = resolveCheckoutSelection(cloudStoreQuery.data?.items ?? [], packageId, priceId)
if (!selection) {
startCheckout(packageId, priceId)
return
@@ -162,29 +84,10 @@ export function StoragePage() {
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
}
function continuePayment(orderId: string) {
openCheckoutTab({ action: 'payment', orderId })
setCheckoutRefreshActive(true)
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
}
function managePlan() {
openCheckoutTab({ action: 'portal' })
}
function cancelOrder(orderId: string) {
setCancelOrderId(orderId)
}
function confirmCancelOrder() {
if (!cancelOrderId) return
cancelOrderMutation.mutate(cancelOrderId, {
onSuccess: () => {
setCancelOrderId(null)
},
})
}
if (cloudStoreQuery.isLoading) {
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
}
@@ -196,32 +99,9 @@ export function StoragePage() {
return (
<div className="max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">{t('storage.title')}</h2>
<p className="text-sm text-muted-foreground">{t('storage.subtitle')}</p>
</div>
{canManageBilling && (
<div className="flex flex-wrap justify-end gap-2">
<CreditBalanceButton
credits={credits}
products={creditProductsQuery.data?.items ?? []}
entries={creditLedgerQuery.data?.items ?? []}
loading={creditLedgerQuery.isLoading}
onRedeem={(code) => redeemMutation.mutate(code)}
onCheckout={requestCheckout}
isRedeeming={redeemMutation.isPending}
checkoutDisabled={!targetOrgId}
/>
<StorageOrderHistoryDialog
orders={currentOrders}
onContinuePayment={continuePayment}
onCancelOrder={cancelOrder}
continuingOrderId={null}
cancelingOrderId={cancelOrderMutation.isPending ? cancelOrderMutation.variables : null}
/>
</div>
)}
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">{t('storage.title')}</h2>
<p className="text-sm text-muted-foreground">{t('storage.subtitle')}</p>
</div>
{checkoutRefreshActive && (
@@ -257,22 +137,6 @@ export function StoragePage() {
)
)}
</div>
<Dialog open={!!cancelOrderId} onOpenChange={(open) => !open && setCancelOrderId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('storage.cancelOrder')}</DialogTitle>
<DialogDescription>{t('storage.cancelConfirm')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelOrderId(null)} disabled={cancelOrderMutation.isPending}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmCancelOrder} disabled={cancelOrderMutation.isPending}>
{cancelOrderMutation.isPending ? t('common.loading') : t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CheckoutConfirmDialog
key={checkoutSelection?.priceId ?? 'none'}
selection={checkoutSelection}
@@ -284,41 +148,6 @@ export function StoragePage() {
)
}
type CheckoutTabInput =
| { action: 'checkout'; packageId: string; priceId: string; promotionCode?: string }
| { action: 'payment'; orderId: string }
| { action: 'portal' }
function openCheckoutTab(input: CheckoutTabInput) {
const search = new URLSearchParams({ action: input.action })
if (input.action === 'checkout') {
search.set('packageId', input.packageId)
search.set('priceId', input.priceId)
if (input.promotionCode) search.set('promotionCode', input.promotionCode)
}
if (input.action === 'payment') search.set('orderId', input.orderId)
openNewTab(`/store/checkout?${search.toString()}`)
}
function resolveCheckoutSelection(
products: CloudProduct[],
packageId: string,
priceId: string,
): CheckoutSelection | null {
const product = products.find((item) => item.id === packageId)
const price = product?.prices.find((item) => item.id === priceId)
if (!product || !price) return null
const interval = price.recurring?.interval
return {
packageId,
priceId,
productName: product.name,
amount: price.amount,
currency: price.currency,
interval: interval === 'month' || interval === 'year' ? interval : null,
}
}
function isCloudStoreDisabledError(error: unknown) {
return (
error instanceof ApiError && error.reason === 'FEATURE_NOT_AVAILABLE' && error.metadata?.feature === 'quota_store'
@@ -0,0 +1,242 @@
import type { CloudOrder, CloudProduct } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cancelCloudOrder,
getCloudCredits,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
listCloudStoreTargets,
redeemCloudGiftCard,
} from '@/lib/api'
import { openNewTab } from '@/lib/browser-navigation'
import { WorkspaceBillingPage } from './billing'
const activeOrganization = vi.hoisted(() => ({
value: { id: 'org-1' },
}))
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
vi.unstubAllGlobals()
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: { amount?: string | number }) =>
values?.amount === undefined ? key : `${key}:${values.amount}`,
i18n: { resolvedLanguage: 'en' },
}),
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: unknown) => options,
}))
vi.mock('@/lib/auth-client', () => ({
useActiveOrganization: () => ({ data: activeOrganization.value }),
}))
vi.mock('@/lib/browser-navigation', () => ({
openNewTab: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
ApiError: class MockApiError extends Error {},
cancelCloudOrder: vi.fn(),
getCloudCredits: vi.fn(),
listCloudCreditLedgerEntries: vi.fn(),
listCloudCreditProducts: vi.fn(),
listCloudOrders: vi.fn(),
listCloudProducts: vi.fn(),
listCloudStoreTargets: vi.fn(),
redeemCloudGiftCard: vi.fn(),
createDiscountQuote: vi.fn(),
}))
function creditProduct(): CloudProduct {
return {
id: 'credits-1',
storeId: 'store-1',
type: 'store_item',
name: '5,000 Credits',
description: null,
metadata: { deliverable: { type: 'zpan.credits', credits: 5000 } },
prices: [{ id: 'price-1', currency: 'usd', amount: 500, recurring: null }],
active: true,
sortOrder: 1,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
}
}
function unpaidOrder(): CloudOrder {
return {
id: 'order-unpaid',
storeId: 'store-1',
buyerAccountId: 'buyer-1',
target: { orgId: 'org-1' },
status: 'pending',
subtotalAmount: 500,
discountAmount: 0,
totalAmount: 500,
currency: 'usd',
items: [
{
id: 'item-1',
orderId: 'order-unpaid',
productId: 'credits-1',
productType: 'store_item',
name: '5,000 Credits',
description: null,
quantity: 1,
unitAmount: 500,
totalAmount: 500,
fulfillmentPayload: { deliverable: { type: 'zpan.credits', includedCredits: 5000 } },
},
],
payments: [],
paymentStatus: 'unpaid',
fulfillmentStatus: 'pending',
createdAt: '2026-05-05T00:00:00.000Z',
paidAt: null,
fulfilledAt: null,
canceledAt: null,
}
}
function renderPage(queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceBillingPage />
</QueryClientProvider>,
)
}
beforeEach(() => {
activeOrganization.value = { id: 'org-1' }
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal Space', type: 'personal', role: 'owner' }],
total: 1,
})
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [creditProduct()], total: 1 })
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({
items: [
{
id: 'ledger-1',
creditAccountId: 'account-1',
creditBucketId: 'bucket-1',
storeId: 'store-1',
customerId: 'org-1',
amount: 500,
direction: 'credit',
status: 'posted',
sourceType: 'gift_card_redemption',
sourceId: 'gift-1',
orderId: null,
paymentId: null,
createdAt: '2026-05-08T00:00:00.000Z',
},
],
total: 1,
limit: 50,
offset: 0,
})
vi.mocked(listCloudOrders).mockResolvedValue({ items: [unpaidOrder()], total: 1 })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('WorkspaceBillingPage', () => {
it('shows the Credits account and activity inline while keeping orders in a dialog', async () => {
const view = renderPage()
expect(await view.findByText('1,250')).toBeTruthy()
expect(view.queryByText('storage.billingTitle')).toBeNull()
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy()
expect(view.queryByText('5,000 Credits')).toBeNull()
expect(view.queryByRole('dialog')).toBeNull()
expect(view.queryByText('org-1')).toBeNull()
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
expect(await view.findByRole('dialog')).toBeTruthy()
expect(view.getByText('5,000 Credits')).toBeTruthy()
})
it('starts a Credits checkout and keeps gift-card redemption in a dialog', async () => {
vi.mocked(redeemCloudGiftCard).mockResolvedValue({ redeemedCredits: 5000, entries: [], failures: [] })
const view = renderPage()
fireEvent.click(await view.findByRole('button', { name: 'storage.creditTopUpTitle' }))
expect(await view.findByRole('radio')).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: 'storage.proceedToCheckout' }))
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=credits-1&priceId=price-1')
fireEvent.click(view.getByRole('button', { name: 'storage.redeemTitle' }))
fireEvent.change(view.getByLabelText('storage.giftCardCode'), { target: { value: 'ZS-1234-5678' } })
fireEvent.click(view.getByRole('button', { name: 'storage.redeemAction' }))
await waitFor(() => expect(redeemCloudGiftCard).toHaveBeenCalledWith('ZS-1234-5678'))
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:5000')
})
it('continues and cancels an unpaid order', async () => {
vi.mocked(cancelCloudOrder).mockResolvedValue({
...unpaidOrder(),
status: 'canceled',
paymentStatus: 'canceled',
})
const view = renderPage()
fireEvent.click(await view.findByRole('button', { name: 'storage.historyTitle' }))
fireEvent.click(await view.findByLabelText('storage.continuePayment'))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=payment&orderId=order-unpaid')
fireEvent.click(view.getByLabelText('storage.cancelOrder'))
fireEvent.click(await view.findByRole('button', { name: 'common.confirm' }))
await waitFor(() => expect(cancelCloudOrder).toHaveBeenCalledWith('order-unpaid'))
})
it('shows a read-only notice to non-owner team members', async () => {
activeOrganization.value = { id: 'team-1' }
vi.mocked(listCloudStoreTargets).mockResolvedValue({
items: [{ orgId: 'team-1', name: 'Design Team', type: 'team', role: 'editor' }],
total: 1,
})
const view = renderPage()
expect(await view.findByText('storage.teamMemberBillingNotice')).toBeTruthy()
expect(view.queryByText('storage.creditBalance')).toBeNull()
expect(getCloudCredits).not.toHaveBeenCalled()
expect(listCloudOrders).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,217 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CheckoutConfirmDialog, type CheckoutSelection } from '@/components/store/checkout-confirm-dialog'
import { openCheckoutTab, resolveCheckoutSelection } from '@/components/store/checkout-navigation'
import {
CreditBillingPanel,
StorageOrderHistoryDialog,
StorageUnavailableState,
} from '@/components/store/storage-panels'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ApiError,
cancelCloudOrder,
getCloudCredits,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
listCloudStoreTargets,
redeemCloudGiftCard,
} from '@/lib/api'
import { useActiveOrganization } from '@/lib/auth-client'
export const Route = createFileRoute('/_authenticated/teams/$teamId/billing')({
component: WorkspaceBillingPage,
})
export function WorkspaceBillingPage() {
const { t, i18n } = useTranslation()
const queryClient = useQueryClient()
const { data: activeOrg } = useActiveOrganization()
const targetOrgId = activeOrg?.id ?? ''
const [checkoutRefreshActive, setCheckoutRefreshActive] = useState(false)
const [checkoutSelection, setCheckoutSelection] = useState<CheckoutSelection | null>(null)
const [cancelOrderId, setCancelOrderId] = useState<string | null>(null)
const cloudStoreQuery = useQuery({
queryKey: ['cloud-store', 'packages'],
queryFn: listCloudProducts,
retry: false,
})
const targetsQuery = useQuery({
queryKey: ['cloud-store', 'targets'],
queryFn: listCloudStoreTargets,
enabled: cloudStoreQuery.isSuccess,
retry: false,
})
const currentTarget = targetsQuery.data?.items.find((item) => item.orgId === targetOrgId)
const isTeamSpace = currentTarget?.type === 'team'
const canManageBilling = targetsQuery.isSuccess && (!isTeamSpace || currentTarget?.role === 'owner')
const creditsQuery = useQuery({
queryKey: ['cloud-store', 'credits', targetOrgId],
queryFn: getCloudCredits,
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
const creditProductsQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'products'],
queryFn: listCloudCreditProducts,
enabled: cloudStoreQuery.isSuccess && canManageBilling,
retry: false,
})
const creditLedgerQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
queryFn: listCloudCreditLedgerEntries,
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
const ordersQuery = useQuery({
queryKey: ['cloud-store', 'orders', targetOrgId],
queryFn: () => listCloudOrders(),
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
retry: false,
})
useEffect(() => {
if (!checkoutRefreshActive) return
const interval = window.setInterval(() => {
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
}, 5000)
const timeout = window.setTimeout(() => setCheckoutRefreshActive(false), 120000)
return () => {
window.clearInterval(interval)
window.clearTimeout(timeout)
}
}, [checkoutRefreshActive, queryClient])
const redeemMutation = useMutation({
mutationFn: (code: string) => redeemCloudGiftCard(code),
onSuccess: (result) => {
toast.success(t('storage.redeemSuccess', { amount: result.redeemedCredits }))
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
},
onError: (error) => toast.error(error.message),
})
const cancelOrderMutation = useMutation({
mutationFn: (orderId: string) => cancelCloudOrder(orderId),
onSuccess: () => {
toast.success(t('storage.cancelSuccess'))
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
setCancelOrderId(null)
},
onError: (error) => toast.error(error.message),
})
function requestCheckout(packageId: string, priceId: string) {
const selection = resolveCheckoutSelection(creditProductsQuery.data?.items ?? [], packageId, priceId)
if (!selection) {
startCheckout(packageId, priceId)
return
}
setCheckoutSelection(selection)
}
function startCheckout(packageId: string, priceId: string, promotionCode?: string) {
openCheckoutTab({ action: 'checkout', packageId, priceId, promotionCode })
setCheckoutRefreshActive(true)
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
}
function continuePayment(orderId: string) {
openCheckoutTab({ action: 'payment', orderId })
setCheckoutRefreshActive(true)
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
}
if (cloudStoreQuery.isLoading) {
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
}
if (cloudStoreQuery.isError) {
const disabled =
cloudStoreQuery.error instanceof ApiError &&
cloudStoreQuery.error.reason === 'FEATURE_NOT_AVAILABLE' &&
cloudStoreQuery.error.metadata?.feature === 'quota_store'
return <StorageUnavailableState disabled={disabled} />
}
return (
<div className="max-w-6xl space-y-6 pb-8">
{checkoutRefreshActive && (
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{t('storage.checkoutPending')}
</div>
)}
{canManageBilling ? (
<CreditBillingPanel
credits={creditsQuery.data}
products={creditProductsQuery.data?.items ?? []}
entries={creditLedgerQuery.data?.items ?? []}
loading={creditLedgerQuery.isLoading}
onRedeem={(code) => redeemMutation.mutate(code)}
onCheckout={requestCheckout}
isRedeeming={redeemMutation.isPending}
checkoutDisabled={!targetOrgId}
accountAction={
<StorageOrderHistoryDialog
orders={ordersQuery.data?.items ?? []}
onContinuePayment={continuePayment}
onCancelOrder={setCancelOrderId}
cancelingOrderId={cancelOrderMutation.isPending ? cancelOrderMutation.variables : null}
/>
}
/>
) : (
targetsQuery.isSuccess && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-8 text-center">
<p className="text-sm text-muted-foreground">{t('storage.teamMemberBillingNotice')}</p>
</div>
)
)}
<Dialog open={!!cancelOrderId} onOpenChange={(open) => !open && setCancelOrderId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('storage.cancelOrder')}</DialogTitle>
<DialogDescription>{t('storage.cancelConfirm')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelOrderId(null)} disabled={cancelOrderMutation.isPending}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => cancelOrderId && cancelOrderMutation.mutate(cancelOrderId)}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? t('common.loading') : t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CheckoutConfirmDialog
key={checkoutSelection?.priceId ?? 'none'}
selection={checkoutSelection}
language={i18n.resolvedLanguage ?? 'en'}
onOpenChange={(open) => !open && setCheckoutSelection(null)}
onConfirm={startCheckout}
/>
</div>
)
}
@@ -88,6 +88,7 @@ function TeamLayout() {
},
{ to: '/teams/$teamId/members', params: { teamId }, label: t('teams.tabMembers'), hidden: isPersonal },
{ to: '/teams/$teamId/ihost', params: { teamId }, label: t('settings.tabImageHosting') },
{ to: '/teams/$teamId/billing', params: { teamId }, label: t('teams.tabBilling') },
{ to: '/teams/$teamId/activity', params: { teamId }, label: t('teams.tabActivity') },
]
const tabs = allTabs.filter((item) => !item.hidden)