From 0cfc0fafd43a24e5e98ca976ce97b9f5de390606 Mon Sep 17 00:00:00 2001 From: saltbo Date: Thu, 23 Jul 2026 11:58:02 -0400 Subject: [PATCH] feat(workspaces): add billing settings tab --- src/components/store/checkout-navigation.ts | 38 +++ src/components/store/credits-panel.tsx | 173 ++++++----- src/components/store/order-history.tsx | 11 +- src/components/store/storage-panels.tsx | 4 +- src/components/team/org-switcher.test.tsx | 2 +- src/components/team/org-switcher.tsx | 2 +- src/i18n/locales/en.json | 4 + src/i18n/locales/zh.json | 4 + src/routeTree.gen.ts | 22 ++ src/routes/_authenticated/storage.test.tsx | 282 +----------------- src/routes/_authenticated/storage.tsx | 185 +----------- .../teams/$teamId/billing.test.tsx | 242 +++++++++++++++ .../_authenticated/teams/$teamId/billing.tsx | 217 ++++++++++++++ .../_authenticated/teams/$teamId/route.tsx | 1 + 14 files changed, 641 insertions(+), 546 deletions(-) create mode 100644 src/components/store/checkout-navigation.ts create mode 100644 src/routes/_authenticated/teams/$teamId/billing.test.tsx create mode 100644 src/routes/_authenticated/teams/$teamId/billing.tsx diff --git a/src/components/store/checkout-navigation.ts b/src/components/store/checkout-navigation.ts new file mode 100644 index 00000000..ad05c86f --- /dev/null +++ b/src/components/store/checkout-navigation.ts @@ -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, + } +} diff --git a/src/components/store/credits-panel.tsx b/src/components/store/credits-panel.tsx index 3acbd90f..8de69f62 100644 --- a/src/components/store/credits-panel.tsx +++ b/src/components/store/credits-panel.tsx @@ -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 ( - - - - - - - {t('storage.creditsButton')} - {t('storage.creditActivityDescription')} - - - -
-

{t('storage.creditActivityTitle')}

- +
+
+
+
+ + {t('storage.creditBalance')} +
+
+ {credits ? formatCredits(credits.balance) : t('common.loading')} +
- -
+
+ + + {accountAction} +
+ + +
+

{t('storage.creditActivityTitle')}

+ +
+ ) } -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(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 ( -
-

{t('storage.creditTopUpTitle')}

-
- {purchasableProducts.map(({ product, price }) => ( -
-
-
-
{product.name}
-
- {t('storage.creditTopUpAmount', { amount: formatCredits(cloudProductIncludedCredits(product)) })} -
-
-
- {formatCurrency(price.amount, price.currency, language)} -
-
- + + + + + + + {t('storage.creditTopUpTitle')} + {t('storage.creditTopUpDialogDescription')} + + {purchasableProducts.length > 0 ? ( +
+ {purchasableProducts.map(({ product, price }) => { + const selectedProduct = selected?.product.id === product.id + return ( + + ) + })}
- ))} -
-
- ) -} - -function CreditBalanceSummary({ - credits, - onRedeem, - isRedeeming, -}: { - credits?: { balance: number } - onRedeem: (code: string) => void - isRedeeming: boolean -}) { - const { t } = useTranslation() - return ( -
-
-
{t('storage.creditBalance')}
-
- {credits ? formatCredits(credits.balance) : t('common.loading')} -
-
- -
+ ) : ( +
+ {t('storage.noCreditTopUps')} +
+ )} + + + + + + ) } @@ -141,7 +164,7 @@ function CreditActivity({ entries, loading }: { entries: CloudCreditLedgerEntry[ if (entries.length === 0) return return ( -
+
diff --git a/src/components/store/order-history.tsx b/src/components/store/order-history.tsx index 685603d1..8b1f49f4 100644 --- a/src/components/store/order-history.tsx +++ b/src/components/store/order-history.tsx @@ -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 ( - @@ -48,7 +46,6 @@ export function StorageOrderHistoryDialog({ orders={orders} onContinuePayment={onContinuePayment} onCancelOrder={onCancelOrder} - continuingOrderId={continuingOrderId} cancelingOrderId={cancelingOrderId} /> @@ -124,7 +121,6 @@ function OrderRow({ )}
- {formatTargetValue(order, 'orgId')} {new Date(order.createdAt).toLocaleString()}
@@ -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 : '-' -} diff --git a/src/components/store/storage-panels.tsx b/src/components/store/storage-panels.tsx index 7527634d..32328493 100644 --- a/src/components/store/storage-panels.tsx +++ b/src/components/store/storage-panels.tsx @@ -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' diff --git a/src/components/team/org-switcher.test.tsx b/src/components/team/org-switcher.test.tsx index b90f41ef..5fda4cf9 100644 --- a/src/components/team/org-switcher.test.tsx +++ b/src/components/team/org-switcher.test.tsx @@ -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/ })) diff --git a/src/components/team/org-switcher.tsx b/src/components/team/org-switcher.tsx index 9d5e0973..2f978c5f 100644 --- a/src/components/team/org-switcher.tsx +++ b/src/components/team/org-switcher.tsx @@ -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 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c3f5165a..2fe97fc0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8eedfea4..b0af0bba 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "无法发起支付", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8f54f5fd..4c1d3f10 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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: diff --git a/src/routes/_authenticated/storage.test.tsx b/src/routes/_authenticated/storage.test.tsx index 3204513c..0f1b53a2 100644 --- a/src/routes/_authenticated/storage.test.tsx +++ b/src/routes/_authenticated/storage.test.tsx @@ -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 { - 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( @@ -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'] }) - }) }) diff --git a/src/routes/_authenticated/storage.tsx b/src/routes/_authenticated/storage.tsx index 89142374..fa99ad87 100644 --- a/src/routes/_authenticated/storage.tsx +++ b/src/routes/_authenticated/storage.tsx @@ -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(null) const [checkoutSelection, setCheckoutSelection] = useState(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

{t('common.loading')}

} @@ -196,32 +99,9 @@ export function StoragePage() { return (
-
-
-

{t('storage.title')}

-

{t('storage.subtitle')}

-
- {canManageBilling && ( -
- redeemMutation.mutate(code)} - onCheckout={requestCheckout} - isRedeeming={redeemMutation.isPending} - checkoutDisabled={!targetOrgId} - /> - -
- )} +
+

{t('storage.title')}

+

{t('storage.subtitle')}

{checkoutRefreshActive && ( @@ -257,22 +137,6 @@ export function StoragePage() { ) )}
- !open && setCancelOrderId(null)}> - - - {t('storage.cancelOrder')} - {t('storage.cancelConfirm')} - - - - - - - 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' diff --git a/src/routes/_authenticated/teams/$teamId/billing.test.tsx b/src/routes/_authenticated/teams/$teamId/billing.test.tsx new file mode 100644 index 00000000..b7768efb --- /dev/null +++ b/src/routes/_authenticated/teams/$teamId/billing.test.tsx @@ -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( + + + , + ) +} + +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() + }) +}) diff --git a/src/routes/_authenticated/teams/$teamId/billing.tsx b/src/routes/_authenticated/teams/$teamId/billing.tsx new file mode 100644 index 00000000..9231e7d2 --- /dev/null +++ b/src/routes/_authenticated/teams/$teamId/billing.tsx @@ -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(null) + const [cancelOrderId, setCancelOrderId] = useState(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

{t('common.loading')}

+ } + + if (cloudStoreQuery.isError) { + const disabled = + cloudStoreQuery.error instanceof ApiError && + cloudStoreQuery.error.reason === 'FEATURE_NOT_AVAILABLE' && + cloudStoreQuery.error.metadata?.feature === 'quota_store' + return + } + + return ( +
+ {checkoutRefreshActive && ( +
+ {t('storage.checkoutPending')} +
+ )} + + {canManageBilling ? ( + redeemMutation.mutate(code)} + onCheckout={requestCheckout} + isRedeeming={redeemMutation.isPending} + checkoutDisabled={!targetOrgId} + accountAction={ + + } + /> + ) : ( + targetsQuery.isSuccess && ( +
+

{t('storage.teamMemberBillingNotice')}

+
+ ) + )} + + !open && setCancelOrderId(null)}> + + + {t('storage.cancelOrder')} + {t('storage.cancelConfirm')} + + + + + + + + !open && setCheckoutSelection(null)} + onConfirm={startCheckout} + /> +
+ ) +} diff --git a/src/routes/_authenticated/teams/$teamId/route.tsx b/src/routes/_authenticated/teams/$teamId/route.tsx index 0c1c1aa8..23d2f0ba 100644 --- a/src/routes/_authenticated/teams/$teamId/route.tsx +++ b/src/routes/_authenticated/teams/$teamId/route.tsx @@ -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)