mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
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:
@@ -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' })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()}`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user