diff --git a/e2e/cloud-store.spec.ts b/e2e/cloud-store.spec.ts index df66560c..03176b2d 100644 --- a/e2e/cloud-store.spec.ts +++ b/e2e/cloud-store.spec.ts @@ -84,7 +84,7 @@ test.describe await postJson<{ orderId: string; url: string }>(page, '/api/store/checkouts', { packageId: product.id, - currency: 'usd', + priceId: product.prices[0].id, }) const orders = await expectOrderCreated(page, product.id) @@ -284,7 +284,7 @@ async function createOneTimePackageThroughUi(page: Page, packageName: string) { await dialog.getByLabel('Valid days').fill('7') await dialog.getByRole('spinbutton', { name: 'Storage quota' }).fill('1') await dialog.getByRole('spinbutton', { name: 'Download traffic quota' }).fill('1') - await dialog.getByLabel('USD amount').fill('1') + await dialog.getByLabel('Package amount (USD)').fill('1') const response = page.waitForResponse( (item) => item.url().includes('/api/admin/store/packages') && item.request().method() === 'POST', @@ -340,7 +340,7 @@ async function expectAdminGiftCardVisibleInApi(page: Page, code: string) { } async function redeemGiftCard(page: Page, code: string) { - await page.getByRole('button', { name: 'Credits' }).click() + await page.getByRole('button', { name: 'View credit activity' }).click() const creditsDialog = page.getByRole('dialog', { name: 'Credits' }) await creditsDialog.getByRole('button', { name: 'Redeem gift card' }).click() const redeemDialog = page.getByRole('dialog', { name: 'Redeem gift card' }) diff --git a/server/routes/cloud-store-helpers.test.ts b/server/routes/cloud-store-helpers.test.ts index e2ea8f0d..eea51464 100644 --- a/server/routes/cloud-store-helpers.test.ts +++ b/server/routes/cloud-store-helpers.test.ts @@ -163,7 +163,7 @@ describe('quota store helper schemas', () => { }) }) - it('normalizes Cloud gift card create responses to generated cards', () => { + it('parses Cloud gift card create responses to generated cards', () => { const card = { id: 'gift-created', storeId: 'store-1', @@ -180,8 +180,26 @@ describe('quota store helper schemas', () => { createdByAdmin: 'admin', } - expect(cloudGiftCardCreateResponseSchema.parse([card])).toEqual([card]) - expect(cloudGiftCardCreateResponseSchema.parse({ items: [card], total: 1, limit: 50, offset: 0 })).toEqual([card]) + const normalized = { + id: 'gift-created', + storeId: 'store-1', + campaignId: null, + code: 'ZS-CREATED-1', + codeLast4: 'TED1', + credits: 500, + status: 'active', + expiresAt: null, + createdAt: '2026-05-07T00:00:00.000Z', + updatedAt: '2026-05-07T00:00:00.000Z', + disabledAt: null, + revokedAt: null, + createdByAdmin: 'admin', + } + + expect(cloudGiftCardCreateResponseSchema.parse([card])).toEqual([normalized]) + expect(cloudGiftCardCreateResponseSchema.parse({ items: [card], total: 1, limit: 50, offset: 0 })).toEqual([ + normalized, + ]) }) it('parses Cloud package responses', () => { diff --git a/server/routes/cloud-store-helpers.ts b/server/routes/cloud-store-helpers.ts index da0c471b..d7146ade 100644 --- a/server/routes/cloud-store-helpers.ts +++ b/server/routes/cloud-store-helpers.ts @@ -32,7 +32,7 @@ export const cloudOrdersQuerySchema = z.object({ }) export const cloudStoreOrdersQuerySchema = cloudOrdersQuerySchema -export const cloudGiftCardSchema = z.object({ +const rawCloudGiftCardSchema = z.object({ id: z.string().min(1), storeId: z.string().min(1), campaignId: z.string().nullable(), @@ -47,6 +47,7 @@ export const cloudGiftCardSchema = z.object({ revokedAt: z.string().nullable(), createdByAdmin: z.string().min(1), }) +export const cloudGiftCardSchema = rawCloudGiftCardSchema export const cloudGiftCardsResponseSchema = z.object({ items: z.array(cloudGiftCardSchema), total: z.number().int(), diff --git a/server/routes/cloud-store.integration.test.ts b/server/routes/cloud-store.integration.test.ts index ac7fdd82..46f919cc 100644 --- a/server/routes/cloud-store.integration.test.ts +++ b/server/routes/cloud-store.integration.test.ts @@ -2,7 +2,6 @@ import { sql } from 'drizzle-orm' import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import type { CloudGiftCard } from '../../shared/types' import { PUBLIC_KEYS } from '../licensing/public-keys.js' import { getCloudStoreSettings } from '../services/cloud-store.js' import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' @@ -16,7 +15,7 @@ const REFRESH_TOKEN = 'test-refresh-token' const INSTANCE_STORE_PATH = '/api/stores/store-test-binding' const { secretKey: EVENT_SECRET, publicKey: EVENT_PUBLIC } = generateKeys('public') -const zpanCloudGiftCardResponseFixture: CloudGiftCard = { +const cloudGiftCardResponseFixture = { id: 'gift-card-1', storeId: 'store-test-binding', campaignId: null, @@ -32,8 +31,8 @@ const zpanCloudGiftCardResponseFixture: CloudGiftCard = { createdByAdmin: 'admin', } -function cloudGiftCard(overrides: Partial = {}) { - return { ...zpanCloudGiftCardResponseFixture, ...overrides } +function cloudGiftCard(overrides: Record = {}) { + return { ...cloudGiftCardResponseFixture, ...overrides } } function cloudProduct(overrides: Record = {}) { @@ -44,10 +43,7 @@ function cloudProduct(overrides: Record = {}) { name: 'Small', description: 'starter', metadata: { deliverable: { type: 'zpan.extra', storageBytes: 4096, trafficBytes: 0 } }, - prices: [ - { id: 'price-usd', currency: 'usd', amount: 500 }, - { id: 'price-cny', currency: 'cny', amount: 3600 }, - ], + prices: [{ id: 'price-usd', currency: 'usd', amount: 500 }], active: true, sortOrder: 1, createdAt: '2026-05-06T00:00:00.000Z', @@ -623,10 +619,7 @@ describe('Quota Store API', () => { name: 'Object Shape', description: null, metadata: { deliverable: { type: 'zpan.extra', storageBytes: 0, trafficBytes: 4096 } }, - prices: [ - { currency: 'usd', amount: 500 }, - { currency: 'cny', amount: 3600 }, - ], + prices: [{ currency: 'usd', amount: 500 }], sortOrder: 3, }), ], @@ -644,7 +637,7 @@ describe('Quota Store API', () => { cloudProductRequest({ name: 'Updated', metadata: { deliverable: { type: 'zpan.extra', storageBytes: 0, trafficBytes: 8192 } }, - prices: [{ currency: 'cny', amount: 900 }], + prices: [{ currency: 'usd', amount: 900 }], }), ), }) @@ -673,7 +666,7 @@ describe('Quota Store API', () => { metadata: { deliverable: { type: 'zpan.extra', storageBytes: 0, trafficBytes: 8192 }, }, - prices: [{ currency: 'cny', amount: 900 }], + prices: [{ currency: 'usd', amount: 900 }], active: true, sortOrder: 0, }) @@ -795,7 +788,7 @@ describe('Quota Store API', () => { metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, trafficBytes: 0 } }, prices: [ { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { currency: 'cny', amount: 9000 }, + { currency: 'usd', amount: 9000 }, ], }), ), @@ -805,6 +798,33 @@ describe('Quota Store API', () => { expect(vi.mocked(fetch)).not.toHaveBeenCalled() }) + it('rejects non-USD package prices before proxying to Cloud', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + await seedSettings(app, headers) + + const created = await app.request('/api/admin/store/packages', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify( + cloudProductRequest({ + name: 'CNY Package', + prices: [{ currency: 'cny', amount: 9000 }], + }), + ), + }) + const updated = await app.request('/api/admin/store/packages/cloud-pkg-1', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ prices: [{ currency: 'cny', amount: 9000 }] }), + }) + + expect(created.status).toBe(400) + expect(updated.status).toBe(400) + expect(vi.mocked(fetch)).not.toHaveBeenCalled() + }) + it('rejects malformed metered traffic prices before proxying to Cloud', async () => { const { app, db } = await createTestApp() await seedProLicense(db) @@ -1368,6 +1388,12 @@ describe('Quota Store API', () => { expect(res.status).toBe(201) await expect(res.json()).resolves.toMatchObject([{ code: 'ZS-PAGED-CREATE-1' }]) + const [[url, init]] = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> + expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards`) + expect(JSON.parse(init.body as string)).toEqual({ + credits: 4096, + count: 1, + }) }) it('disables admin gift cards through Cloud', async () => { @@ -1581,7 +1607,7 @@ describe('Quota Store API', () => { const checkout = await app.request('/api/store/checkouts', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId, currency: 'usd' }), + body: JSON.stringify({ packageId }), }) expect(checkout.status).toBe(200) @@ -1621,7 +1647,7 @@ describe('Quota Store API', () => { const checkout = await app.request('/api/store/checkouts', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId, currency: 'usd' }), + body: JSON.stringify({ packageId }), }) expect(checkout.status).toBe(409) @@ -1639,7 +1665,7 @@ describe('Quota Store API', () => { const checkout = await app.request('/api/store/checkouts', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId, currency: 'usd' }), + body: JSON.stringify({ packageId }), }) expect(checkout.status).toBe(200) @@ -1686,7 +1712,7 @@ describe('Quota Store API', () => { const checkout = await app.request('/api/store/checkouts', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId, currency: 'cny' }), + body: JSON.stringify({ packageId }), }) const orders = await app.request('/api/store/orders', { headers }) @@ -1713,7 +1739,7 @@ describe('Quota Store API', () => { expect(requestHeader(orderInit, 'Authorization')).toBe(`Bearer ${REFRESH_TOKEN}`) expect(orderBody).toMatchObject({ items: [{ productId: packageId }], - currency: 'cny', + currency: 'usd', target: { orgId, customerId: orgId, @@ -1750,6 +1776,61 @@ describe('Quota Store API', () => { expect(parsedOrdersUrl.searchParams.get('customerId')).toBe(orgId) }) + it('rejects checkout currency fields before proxying to Cloud', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await authedHeaders(app, 'buyer@example.com') + await seedSettings(app, headers) + const packageId = await seedPackage(db) + + const cnyCheckout = await app.request('/api/store/checkouts', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId, currency: 'cny' }), + }) + const usdCheckout = await app.request('/api/store/checkouts', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId, currency: 'usd' }), + }) + + expect(cnyCheckout.status).toBe(400) + expect(usdCheckout.status).toBe(400) + const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> + expect(calls.some(([url, init]) => init.method === 'POST' && String(url).endsWith('/orders'))).toBe(false) + }) + + it('rejects non-USD checkout price ids instead of falling back to USD', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await authedHeaders(app, 'buyer@example.com') + await seedSettings(app, headers) + const packageId = await seedPackage(db) + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => + cloudProduct({ + id: packageId, + prices: [ + { id: 'price-usd', currency: 'usd', amount: 500 }, + { id: 'price-cny', currency: 'cny', amount: 3600 }, + ], + }), + } as Response) + + const checkout = await app.request('/api/store/checkouts', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId, priceId: 'price-cny' }), + }) + + expect(checkout.status).toBe(400) + await expect(checkout.json()).resolves.toEqual({ error: 'package_price_missing' }) + const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> + expect(calls.some(([url, init]) => init.method === 'POST' && String(url).endsWith('/orders'))).toBe(false) + }) + it('proxies credit balance and gift card redemption through credit endpoints', async () => { const { app, db } = await createTestApp() await seedProLicense(db) diff --git a/server/routes/cloud-store/storefront.ts b/server/routes/cloud-store/storefront.ts index 023e5a03..e3e061bb 100644 --- a/server/routes/cloud-store/storefront.ts +++ b/server/routes/cloud-store/storefront.ts @@ -111,7 +111,7 @@ export const cloudStore = new Hono() const store = await getUserStoreSettings(db) if ('error' in store) return c.json({ error: store.error }, 403) - const currency = body.currency ?? 'usd' + const currency = 'usd' const product = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( await client.stores[':storeId'].products[':productId'].$get({ @@ -121,9 +121,11 @@ export const cloudStore = new Hono() ), ) if (isCloudError(product)) return c.json(product, 502) - const price = - product.prices.find((item) => item.id === body.priceId && item.currency === currency) ?? - product.prices.find((item) => item.currency === currency && item.recurring?.usageType !== 'metered') + const price = body.priceId + ? product.prices.find( + (item) => item.id === body.priceId && item.currency === currency && item.recurring?.usageType !== 'metered', + ) + : product.prices.find((item) => item.currency === currency && item.recurring?.usageType !== 'metered') if (!price) return c.json({ error: 'package_price_missing' }, 400) if (price.recurring) { const quota = await getEffectiveQuota(db, targetOrgId) diff --git a/shared/schemas/cloud-store.ts b/shared/schemas/cloud-store.ts index bc5b6e1b..79745065 100644 --- a/shared/schemas/cloud-store.ts +++ b/shared/schemas/cloud-store.ts @@ -13,7 +13,7 @@ export const cloudStoreSettingsSchema = z.object({ enabled: z.boolean(), }) -export const cloudStoreCurrencySchema = z.string().min(1) +export const cloudStoreCurrencySchema = z.literal('usd') export const cloudProductPriceSchema = productPriceSchema.extend({ currency: cloudStoreCurrencySchema, amount: z.number().int().positive(), @@ -175,11 +175,12 @@ export const cloudProductPatchSchema = updateProductSchema } }) -export const checkoutInputSchema = z.object({ - packageId: z.string().min(1), - currency: cloudStoreCurrencySchema.optional(), - priceId: z.string().min(1).optional(), -}) +export const checkoutInputSchema = z + .object({ + packageId: z.string().min(1), + priceId: z.string().min(1).optional(), + }) + .strict() export const giftCardStatusSchema = z.enum(['active', 'redeemed', 'disabled', 'expired', 'revoked']) diff --git a/src/components/admin/cloud-product-form.tsx b/src/components/admin/cloud-product-form.tsx index 3f2aa048..8ad7fb5f 100644 --- a/src/components/admin/cloud-product-form.tsx +++ b/src/components/admin/cloud-product-form.tsx @@ -13,9 +13,6 @@ import { cloudProductStorageBytes, cloudProductTrafficBytes, cloudProductValidit const units = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const type Unit = keyof typeof units type BillingMode = 'subscription' | 'one_time' -type PriceCurrency = 'usd' | 'cny' - -const packageCurrencies: PriceCurrency[] = ['usd', 'cny'] export const emptyPackageForm = { name: '', @@ -28,8 +25,6 @@ export const emptyPackageForm = { trafficUnit: 'GB' as Unit, usdAmount: '9.99', usdTrafficOverageAmount: '', - cnyAmount: '', - cnyTrafficOverageAmount: '', sortOrder: '0', } @@ -71,10 +66,8 @@ export function packageFormFromPackage(pkg: CloudProduct): PackageFormState { storageUnit: storageDisplay?.unit ?? 'GB', trafficSize: trafficDisplay ? String(trafficDisplay.size) : '', trafficUnit: trafficDisplay?.unit ?? 'GB', - usdAmount: formatMinorAmount(monthlyPrice(pkg, 'usd')?.amount), - usdTrafficOverageAmount: formatMinorAmount(meteredPrice(pkg, 'usd')?.amount), - cnyAmount: formatMinorAmount(monthlyPrice(pkg, 'cny')?.amount), - cnyTrafficOverageAmount: formatMinorAmount(meteredPrice(pkg, 'cny')?.amount), + usdAmount: formatMinorAmount(monthlyPrice(pkg)?.amount), + usdTrafficOverageAmount: formatMinorAmount(meteredPrice(pkg)?.amount), sortOrder: String(pkg.sortOrder), } } @@ -143,9 +136,7 @@ export function StoragePlanForm({ } function packagePricesFromForm(form: PackageFormState) { - return packageCurrencies - .flatMap((currency) => packagePricesForCurrency(currency, form)) - .filter((price) => Number.isFinite(price.amount) && price.amount > 0) + return packagePricesForForm(form).filter((price) => Number.isFinite(price.amount) && price.amount > 0) } function trafficOveragePrice(prices: ReturnType) { @@ -160,34 +151,23 @@ function isFormMeteredTrafficPrice(price: ReturnType 0 - const hasTrafficOverageAmount = convertCurrencyAmount(trafficOverageAmount) > 0 - if (!hasAmount && !hasTrafficOverageAmount) continue - if (!hasAmount) return false - if (form.billingMode === 'subscription' && !hasTrafficOverageAmount) return false - hasPrice = true - } - return hasPrice + const hasAmount = convertCurrencyAmount(form.usdAmount) > 0 + if (!hasAmount) return false + return form.billingMode !== 'subscription' || convertCurrencyAmount(form.usdTrafficOverageAmount) > 0 } -function packagePricesForCurrency(currency: PriceCurrency, form: PackageFormState) { - const monthlyAmount = currency === 'usd' ? form.usdAmount : form.cnyAmount - const trafficOverageAmount = currency === 'usd' ? form.usdTrafficOverageAmount : form.cnyTrafficOverageAmount +function packagePricesForForm(form: PackageFormState) { const monthlyPrice = { - currency, - amount: convertCurrencyAmount(monthlyAmount), + currency: 'usd' as const, + amount: convertCurrencyAmount(form.usdAmount), ...(form.billingMode === 'subscription' ? { recurring: { interval: 'month' as const, intervalCount: 1 } } : {}), } if (form.billingMode !== 'subscription') return [monthlyPrice] return [ monthlyPrice, { - currency, - amount: convertCurrencyAmount(trafficOverageAmount), + currency: 'usd' as const, + amount: convertCurrencyAmount(form.usdTrafficOverageAmount), recurring: { interval: 'month' as const, intervalCount: 1, usageType: 'metered' as const }, metadata: { usageResource: 'traffic_egress' }, }, @@ -206,12 +186,12 @@ function isMeteredTrafficPrice(price: CloudProduct['prices'][number]) { return price.recurring?.usageType === 'metered' && price.metadata?.usageResource === 'traffic_egress' } -function monthlyPrice(pkg: CloudProduct, currency: PriceCurrency) { - return pkg.prices.find((price) => price.currency === currency && !isMeteredTrafficPrice(price)) +function monthlyPrice(pkg: CloudProduct) { + return pkg.prices.find((price) => price.currency === 'usd' && !isMeteredTrafficPrice(price)) } -function meteredPrice(pkg: CloudProduct, currency: PriceCurrency) { - return pkg.prices.find((price) => price.currency === currency && isMeteredTrafficPrice(price)) +function meteredPrice(pkg: CloudProduct) { + return pkg.prices.find((price) => price.currency === 'usd' && isMeteredTrafficPrice(price)) } function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) { @@ -349,24 +329,6 @@ function PackageAmountFields({ onChange={(usdTrafficOverageAmount) => onFormChange({ ...form, usdTrafficOverageAmount })} /> )} - onFormChange({ ...form, cnyAmount })} - /> - {form.billingMode === 'subscription' && ( - onFormChange({ ...form, cnyTrafficOverageAmount })} - /> - )} ) } diff --git a/src/components/store/storage-packages.tsx b/src/components/store/storage-packages.tsx index 46aac57c..9ce89130 100644 --- a/src/components/store/storage-packages.tsx +++ b/src/components/store/storage-packages.tsx @@ -14,7 +14,7 @@ export function StoragePackages({ }: { packages: CloudProduct[] disabled: boolean - onCheckout: (packageId: string, currency: string) => void + onCheckout: (packageId: string, priceId: string) => void }) { const { t, i18n } = useTranslation() const language = i18n.resolvedLanguage ?? 'en' @@ -98,10 +98,10 @@ function PackageCard({ pkg: CloudProduct disabled: boolean language: string - onCheckout: (packageId: string, currency: string) => void + onCheckout: (packageId: string, priceId: string) => void }) { const { t } = useTranslation() - const price = selectPrice(pkg.prices, language) + const price = selectPrice(pkg.prices) const priceLabel = formatPackagePrice(price, pkg, language, t) const plan = isPlanProduct(pkg) const storageBytes = cloudProductStorageBytes(pkg) @@ -114,7 +114,7 @@ function PackageCard({ icon={plan ? : } price={priceLabel} action={ - @@ -149,12 +149,13 @@ function PlanDetailRow({ label, value }: { label: string; value: string }) { ) } -function selectPrice(prices: CloudProduct['prices'], language: string) { - const currency = language.startsWith('zh') ? 'cny' : 'usd' +function selectPrice(prices: CloudProduct['prices']) { const purchasablePrices = prices.filter((item) => item.recurring?.usageType !== 'metered') - const price = purchasablePrices.find((item) => item.currency === currency) ?? purchasablePrices[0] + const price = purchasablePrices.find((item) => item.currency === 'usd') if (!price) throw new Error('cloud_product_price_missing') - return price + const priceId = price.id + if (!priceId) throw new Error('cloud_product_price_missing') + return { ...price, id: priceId } } function isPlanProduct(pkg: CloudProduct) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 529588da..c2126426 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1155,10 +1155,8 @@ "admin.cloudStore.quotaRequired": "At least one of storage quota or download traffic quota must be set.", "admin.cloudStore.validityRequired": "Valid days must be greater than 0.", "admin.cloudStore.prices": "Prices", - "admin.cloudStore.usdAmount": "USD amount", - "admin.cloudStore.usdTrafficOveragePrice": "USD traffic overage price / GB", - "admin.cloudStore.cnyAmount": "CNY amount", - "admin.cloudStore.cnyTrafficOveragePrice": "CNY traffic overage price / GB", + "admin.cloudStore.usdAmount": "Package amount (USD)", + "admin.cloudStore.usdTrafficOveragePrice": "Traffic overage price / GB (USD)", "admin.cloudStore.sortOrder": "Sort order", "admin.cloudStore.active": "Active", "admin.cloudStore.publish": "Publish", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2a5869c2..f3169105 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1155,10 +1155,8 @@ "admin.cloudStore.quotaRequired": "存储配额和下载流量配额至少填写一项。", "admin.cloudStore.validityRequired": "有效天数必须大于 0。", "admin.cloudStore.prices": "价格", - "admin.cloudStore.usdAmount": "美元金额", - "admin.cloudStore.usdTrafficOveragePrice": "美元超额流量单价 / GB", - "admin.cloudStore.cnyAmount": "人民币金额", - "admin.cloudStore.cnyTrafficOveragePrice": "人民币超额流量单价 / GB", + "admin.cloudStore.usdAmount": "套餐金额(美元)", + "admin.cloudStore.usdTrafficOveragePrice": "超额流量单价 / GB(美元)", "admin.cloudStore.sortOrder": "排序", "admin.cloudStore.active": "启用", "admin.cloudStore.publish": "发布", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index baac7ce8..858a87f6 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -327,13 +327,6 @@ describe('api', () => { recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, metadata: { usageResource: 'traffic_egress' }, }, - { currency: 'cny', amount: 3600, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'cny', - amount: 14, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, ], active: true, sortOrder: 0, @@ -360,13 +353,6 @@ describe('api', () => { recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, metadata: { usageResource: 'traffic_egress' }, }, - { currency: 'cny', amount: 3600, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'cny', - amount: 14, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, ], active: true, sortOrder: 0, @@ -459,7 +445,7 @@ describe('api', () => { await listCloudProducts() await listCloudStoreTargets() - await createCloudCheckout('pkg-1', 'cny') + await createCloudCheckout('pkg-1', 'price-usd') await createCloudBillingPortalSession() await listCloudOrders({ limit: 100, offset: 100 }) @@ -469,7 +455,7 @@ describe('api', () => { expect(calls[2][0]).toBe('/api/store/checkouts') expect(JSON.parse(calls[2][1].body as string)).toEqual({ packageId: 'pkg-1', - currency: 'cny', + priceId: 'price-usd', }) expect(calls[3][0]).toBe('/api/store/billing-portal-sessions') expect(calls[3][1].method).toBe('POST') diff --git a/src/lib/api.ts b/src/lib/api.ts index 4c9c7b09..02db7de3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -448,9 +448,9 @@ export function redeemCloudGiftCard(code: string) { return unwrap(cloudStoreApi.credits.redemptions.$post({ json: { code } })) } -export function createCloudCheckout(packageId: string, currency?: string, priceId?: string) { +export function createCloudCheckout(packageId: string, priceId?: string) { return unwrap<{ orderId: string; url: string; paymentId?: string }>( - cloudStoreApi.checkouts.$post({ json: { packageId, currency, priceId } }), + cloudStoreApi.checkouts.$post({ json: { packageId, priceId } }), ) } diff --git a/src/routes/_authenticated/admin/cloud-store.test.tsx b/src/routes/_authenticated/admin/cloud-store.test.tsx index b725b093..c982ebdf 100644 --- a/src/routes/_authenticated/admin/cloud-store.test.tsx +++ b/src/routes/_authenticated/admin/cloud-store.test.tsx @@ -213,8 +213,6 @@ describe('AdminCloudStorePage', () => { fireEvent.change(view.getByLabelText('admin.cloudStore.storageQuota'), { target: { value: '250' } }) fireEvent.change(view.getByLabelText('admin.cloudStore.usdAmount'), { target: { value: '19.99' } }) fireEvent.change(view.getByLabelText('admin.cloudStore.usdTrafficOveragePrice'), { target: { value: '0.02' } }) - fireEvent.change(view.getByLabelText('admin.cloudStore.cnyAmount'), { target: { value: '129.00' } }) - fireEvent.change(view.getByLabelText('admin.cloudStore.cnyTrafficOveragePrice'), { target: { value: '0.14' } }) fireEvent.click(view.getByRole('button', { name: 'common.save' })) await waitFor(() => @@ -238,13 +236,6 @@ describe('AdminCloudStorePage', () => { recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, metadata: { usageResource: 'traffic_egress' }, }, - { currency: 'cny', amount: 12900, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'cny', - amount: 14, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, ], active: true, sortOrder: 0, @@ -253,7 +244,7 @@ describe('AdminCloudStorePage', () => { expect(toast.success).toHaveBeenCalledWith('admin.cloudStore.packageSaved') }) - it('creates a traffic-only package with USD and CNY prices', async () => { + it('creates a traffic-only package with a USD price', async () => { vi.mocked(getCloudStoreSettings).mockResolvedValue(settings()) vi.mocked(listAdminCloudProducts).mockResolvedValue({ items: [], total: 0 }) vi.mocked(createCloudProduct).mockResolvedValue( @@ -281,7 +272,6 @@ describe('AdminCloudStorePage', () => { fireEvent.click(within(dialog).getByLabelText('admin.cloudStore.trafficQuota unit')) fireEvent.click(await view.findByRole('option', { name: 'TB' })) fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.usdAmount'), { target: { value: '49.99' } }) - fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.cnyAmount'), { target: { value: '329.00' } }) fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) await waitFor(() => @@ -297,10 +287,7 @@ describe('AdminCloudStorePage', () => { validityDays: 30, }, }, - prices: [ - { currency: 'usd', amount: 4999 }, - { currency: 'cny', amount: 32900 }, - ], + prices: [{ currency: 'usd', amount: 4999 }], active: true, sortOrder: 0, }), @@ -320,7 +307,6 @@ describe('AdminCloudStorePage', () => { recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, metadata: { usageResource: 'traffic_egress' }, }, - { currency: 'cny', amount: 6900, recurring: { interval: 'month', intervalCount: 1 } }, ], }), ], @@ -334,7 +320,6 @@ describe('AdminCloudStorePage', () => { expect(view.getByRole('columnheader', { name: 'admin.cloudStore.prices' })).toBeTruthy() expect(view.getByText('9.99 USD')).toBeTruthy() expect(view.queryByText('0.02 USD')).toBeNull() - expect(view.queryByText('69.00 CNY')).toBeNull() expect(view.queryByRole('button', { name: 'admin.cloudStore.sync' })).toBeNull() expect(view.queryByText('admin.cloudStore.lastSync')).toBeNull() expect(view.queryByText('admin.cloudStore.lastOrder')).toBeNull() @@ -345,6 +330,7 @@ describe('AdminCloudStorePage', () => { const dialog = await view.findByRole('dialog') expect(within(dialog).getByText('admin.cloudStore.newPackage')).toBeTruthy() expect(within(dialog).getByLabelText('admin.cloudStore.planName')).toBeTruthy() + expect(within(dialog).queryByLabelText('admin.cloudStore.cnyAmount')).toBeNull() expect(within(dialog).queryByLabelText('admin.cloudStore.sortOrder')).toBeNull() expect(within(dialog).queryByLabelText('admin.cloudStore.active')).toBeNull() }) @@ -404,6 +390,74 @@ describe('AdminCloudStorePage', () => { ) }) + it('edits legacy multi-currency packages with USD-only form controls and payloads', async () => { + vi.mocked(getCloudStoreSettings).mockResolvedValue(settings()) + vi.mocked(listAdminCloudProducts).mockResolvedValue({ + items: [ + quotaPackage({ + prices: [ + { currency: 'usd', amount: 1299, recurring: { interval: 'month', intervalCount: 1 } }, + { currency: 'cny', amount: 9800, recurring: { interval: 'month', intervalCount: 1 } }, + { + currency: 'usd', + amount: 3, + recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, + metadata: { usageResource: 'traffic_egress' }, + }, + { + currency: 'cny', + amount: 22, + recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, + metadata: { usageResource: 'traffic_egress' }, + }, + ], + }), + ], + total: 1, + }) + vi.mocked(updateCloudProduct).mockResolvedValue(quotaPackage({ name: 'USD only plan' })) + + const view = renderAdminPage() + + await waitFor(() => expect(view.getByRole('button', { name: 'common.edit' })).toBeTruthy()) + fireEvent.click(view.getByRole('button', { name: 'common.edit' })) + + const dialog = await view.findByRole('dialog') + expect(within(dialog).getByLabelText('admin.cloudStore.usdAmount')).toHaveProperty('value', '12.99') + expect(within(dialog).getByLabelText('admin.cloudStore.usdTrafficOveragePrice')).toHaveProperty('value', '0.03') + expect(within(dialog).queryByLabelText('admin.cloudStore.cnyAmount')).toBeNull() + expect(within(dialog).queryByLabelText('admin.cloudStore.cnyTrafficOveragePrice')).toBeNull() + + fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.planName'), { target: { value: 'USD only plan' } }) + fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' })) + + await waitFor(() => + expect(updateCloudProduct).toHaveBeenCalledWith('pkg-1', { + type: 'store_item', + name: 'USD only plan', + description: 'Extra storage', + metadata: { + deliverable: { + type: 'zpan.plan', + storageBytes: 107374182400, + trafficBytes: 0, + trafficOveragePriceCents: 3, + }, + }, + prices: [ + { currency: 'usd', amount: 1299, recurring: { interval: 'month', intervalCount: 1 } }, + { + currency: 'usd', + amount: 3, + recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, + metadata: { usageResource: 'traffic_egress' }, + }, + ], + sortOrder: 1, + }), + ) + }) + it('publishes and unpublishes packages from table actions', async () => { vi.mocked(getCloudStoreSettings).mockResolvedValue(settings()) vi.mocked(listAdminCloudProducts).mockResolvedValue({ diff --git a/src/routes/_authenticated/storage.test.tsx b/src/routes/_authenticated/storage.test.tsx index d7d88bdd..61db222a 100644 --- a/src/routes/_authenticated/storage.test.tsx +++ b/src/routes/_authenticated/storage.test.tsx @@ -141,7 +141,7 @@ function quotaPackage(): CloudProduct { name: '100 GB', description: 'Extra storage', metadata: { deliverable: { type: 'zpan.extra', storageBytes: 107374182400, trafficBytes: 0 } }, - prices: [{ currency: 'usd', amount: 999 }], + prices: [{ id: 'price-usd', currency: 'usd', amount: 999 }], active: true, sortOrder: 1, createdAt: '2026-05-05T00:00:00.000Z', @@ -156,7 +156,12 @@ function subscriptionPackage(): CloudProduct { name: 'Team Plan', metadata: { deliverable: { type: 'zpan.plan', storageBytes: 107374182400, trafficBytes: 21474836480 } }, prices: [ - { currency: 'usd', amount: 999, recurring: { interval: 'month', intervalCount: 1 } }, + { + id: 'price-subscription-usd', + currency: 'usd', + amount: 999, + recurring: { interval: 'month', intervalCount: 1 }, + }, { currency: 'usd', amount: 25, @@ -349,7 +354,7 @@ describe('StoragePage', () => { await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy()) fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ })) - expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1¤cy=usd') + expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd') expect(toast.info).not.toHaveBeenCalled() }) @@ -370,7 +375,7 @@ describe('StoragePage', () => { expect(view.getByText(/storage\.trafficOveragePerGb/)).toBeTruthy() }) - it('uses an available product price when the locale currency is missing', async () => { + it('uses the USD product price for checkout regardless of locale', async () => { i18nState.language = 'zh-CN' vi.mocked(listCloudProducts).mockResolvedValue({ items: [quotaPackage()], total: 1 }) vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 }) @@ -386,7 +391,7 @@ describe('StoragePage', () => { await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy()) fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ })) - expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1¤cy=usd') + expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd') }) it('opens the checkout redirect page instead of creating a blank tab', async () => { @@ -404,7 +409,7 @@ describe('StoragePage', () => { await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy()) fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ })) - expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1¤cy=usd') + expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd') }) it('refreshes quota and orders after checkout starts', async () => { @@ -422,7 +427,7 @@ describe('StoragePage', () => { await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy()) fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ })) - expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1¤cy=usd') + expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd') expect(view.getByText('storage.checkoutPending')).toBeTruthy() expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] }) @@ -530,7 +535,7 @@ describe('StoragePage', () => { await waitFor(() => expect(view.queryByText('org-2')).toBeNull()) fireEvent.click(await view.findByRole('button', { name: /storage.checkoutPackage/ })) - expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1¤cy=usd') + expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd') }) it('requires an active organization to checkout', async () => { diff --git a/src/routes/_authenticated/storage.tsx b/src/routes/_authenticated/storage.tsx index 66fcb56e..b92ca576 100644 --- a/src/routes/_authenticated/storage.tsx +++ b/src/routes/_authenticated/storage.tsx @@ -125,8 +125,8 @@ export function StoragePage() { }, }) - function startCheckout(packageId: string, currency: string) { - openCheckoutTab({ action: 'checkout', packageId, currency }) + function startCheckout(packageId: string, priceId: string) { + openCheckoutTab({ action: 'checkout', packageId, priceId }) setCheckoutRefreshActive(true) queryClient.invalidateQueries({ queryKey: ['user', 'quota'] }) queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] }) @@ -228,7 +228,7 @@ export function StoragePage() { } type CheckoutTabInput = - | { action: 'checkout'; packageId: string; currency: string } + | { action: 'checkout'; packageId: string; priceId: string } | { action: 'payment'; orderId: string } | { action: 'portal' } @@ -236,7 +236,7 @@ function openCheckoutTab(input: CheckoutTabInput) { const search = new URLSearchParams({ action: input.action }) if (input.action === 'checkout') { search.set('packageId', input.packageId) - search.set('currency', input.currency) + search.set('priceId', input.priceId) } if (input.action === 'payment') search.set('orderId', input.orderId) openNewTab(`/store/checkout?${search.toString()}`) diff --git a/src/routes/store/checkout.test.tsx b/src/routes/store/checkout.test.tsx index 16aaca3b..56d473bd 100644 --- a/src/routes/store/checkout.test.tsx +++ b/src/routes/store/checkout.test.tsx @@ -38,9 +38,9 @@ describe('StorageCheckoutRedirect', () => { url: 'https://cloud.example.test/checkout', }) - render() + render() - await waitFor(() => expect(createCloudCheckout).toHaveBeenCalledWith('pkg-1', 'usd')) + await waitFor(() => expect(createCloudCheckout).toHaveBeenCalledWith('pkg-1', 'price-usd')) expect(redirectExternal).toHaveBeenCalledWith('https://cloud.example.test/checkout') }) diff --git a/src/routes/store/checkout.tsx b/src/routes/store/checkout.tsx index 4f80212a..194ea77f 100644 --- a/src/routes/store/checkout.tsx +++ b/src/routes/store/checkout.tsx @@ -9,7 +9,7 @@ import { redirectExternal } from '@/lib/browser-navigation' type CheckoutSearch = { action: 'checkout' | 'payment' | 'portal' | 'invalid' packageId?: string - currency?: string + priceId?: string orderId?: string } @@ -71,7 +71,7 @@ function normalizeCheckoutSearch(search: Record): CheckoutSearc return { action, packageId: stringValue(search.packageId), - currency: stringValue(search.currency), + priceId: stringValue(search.priceId), } } if (action === 'payment') return { action, orderId: stringValue(search.orderId) } @@ -81,8 +81,8 @@ function normalizeCheckoutSearch(search: Record): CheckoutSearc async function createCheckoutSession(search: CheckoutSearch) { if (search.action === 'checkout') { - if (!search.packageId || !search.currency) throw new Error('invalid_checkout_request') - const result = await createCloudCheckout(search.packageId, search.currency) + if (!search.packageId || !search.priceId) throw new Error('invalid_checkout_request') + const result = await createCloudCheckout(search.packageId, search.priceId) return result.url } if (search.action === 'payment') {