fix: align store pricing with USD contract

* fix: align store pricing with usd contract

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: align gift card cloud payload

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: normalize cloud gift card credits

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test: cover gift card payload without expiry

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: keep gift card payload mapping executable

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: map credits to cloud wallets

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test: align credits e2e selector

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: keep store credits on credits contract

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
Jasper Van
2026-06-02 01:44:00 -04:00
committed by GitHub
parent f45e8732fc
commit 908786837d
17 changed files with 266 additions and 159 deletions
+3 -3
View File
@@ -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' })
+21 -3
View File
@@ -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', () => {
+2 -1
View File
@@ -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(),
+101 -20
View File
@@ -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<typeof zpanCloudGiftCardResponseFixture> = {}) {
return { ...zpanCloudGiftCardResponseFixture, ...overrides }
function cloudGiftCard(overrides: Record<string, unknown> = {}) {
return { ...cloudGiftCardResponseFixture, ...overrides }
}
function cloudProduct(overrides: Record<string, unknown> = {}) {
@@ -44,10 +43,7 @@ function cloudProduct(overrides: Record<string, unknown> = {}) {
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)
+6 -4
View File
@@ -111,7 +111,7 @@ export const cloudStore = new Hono<Env>()
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<Env>()
),
)
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)
+7 -6
View File
@@ -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'])
+15 -53
View File
@@ -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<typeof packagePricesFromForm>) {
@@ -160,34 +151,23 @@ function isFormMeteredTrafficPrice(price: ReturnType<typeof packagePricesFromFor
}
function packagePriceInputsValid(form: PackageFormState) {
let hasPrice = false
for (const currency of packageCurrencies) {
const amount = currency === 'usd' ? form.usdAmount : form.cnyAmount
const trafficOverageAmount = currency === 'usd' ? form.usdTrafficOverageAmount : form.cnyTrafficOverageAmount
const hasAmount = convertCurrencyAmount(amount) > 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 })}
/>
)}
<NumberField
label={t('admin.cloudStore.cnyAmount')}
id="packageCnyAmount"
min="0.01"
step="0.01"
value={form.cnyAmount}
onChange={(cnyAmount) => onFormChange({ ...form, cnyAmount })}
/>
{form.billingMode === 'subscription' && (
<NumberField
label={t('admin.cloudStore.cnyTrafficOveragePrice')}
id="packageCnyTrafficOverageAmount"
min="0.01"
step="0.01"
value={form.cnyTrafficOverageAmount}
onChange={(cnyTrafficOverageAmount) => onFormChange({ ...form, cnyTrafficOverageAmount })}
/>
)}
</div>
)
}
+9 -8
View File
@@ -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 ? <HardDrive className="h-4 w-4" /> : <Package className="h-4 w-4" />}
price={priceLabel}
action={
<Button className="h-9 w-full" disabled={disabled} onClick={() => onCheckout(pkg.id, price.currency)}>
<Button className="h-9 w-full" disabled={disabled} onClick={() => onCheckout(pkg.id, price.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{plan ? t('storage.checkoutPlan') : t('storage.checkoutPackage')}
</Button>
@@ -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) {
+2 -4
View File
@@ -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",
+2 -4
View File
@@ -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": "发布",
+2 -16
View File
@@ -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')
+2 -2
View File
@@ -448,9 +448,9 @@ export function redeemCloudGiftCard(code: string) {
return unwrap<RedeemGiftCardResponse>(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 } }),
)
}
@@ -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({
+13 -8
View File
@@ -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&currency=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&currency=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&currency=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&currency=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&currency=usd')
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
})
it('requires an active organization to checkout', async () => {
+4 -4
View File
@@ -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()}`)
+2 -2
View File
@@ -38,9 +38,9 @@ describe('StorageCheckoutRedirect', () => {
url: 'https://cloud.example.test/checkout',
})
render(<StorageCheckoutRedirect search={{ action: 'checkout', packageId: 'pkg-1', currency: 'usd' }} />)
render(<StorageCheckoutRedirect search={{ action: 'checkout', packageId: 'pkg-1', priceId: 'price-usd' }} />)
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')
})
+4 -4
View File
@@ -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<string, unknown>): 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<string, unknown>): 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') {