feat(store): add credits package management

This commit is contained in:
saltbo
2026-06-02 13:38:11 -04:00
parent a28040cd44
commit dd1d237945
20 changed files with 1183 additions and 478 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ describe('quota store helper schemas', () => {
quantity: 1,
unitAmount: 999,
totalAmount: 999,
fulfillmentPayload: { deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 2048 } },
fulfillmentPayload: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 2048, includedCredits: 0 },
},
},
],
payments: [
+16 -1
View File
@@ -48,7 +48,22 @@ export const adminCloudStore = new Hono<Env>()
),
)
if (isCloudError(result)) return c.json(result, 502)
return c.json(result)
const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.plan')
return c.json({ ...result, items, total: items.length })
})
.get('/credits/products', async (c) => {
const result = await cloudRequest(c, async ({ client, storeId }) =>
unwrapCloudResponse(
await client.stores[':storeId'].products.$get({
param: { storeId },
query: { type: 'store_item', limit: '100' },
}),
cloudPackageListResponseSchema,
),
)
if (isCloudError(result)) return c.json(result, 502)
const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.credits')
return c.json({ ...result, items, total: items.length })
})
.post('/packages', zValidator('json', cloudProductInputSchema), async (c) => {
const result = await cloudRequest(c, async ({ client, storeId }) =>
+18 -1
View File
@@ -50,7 +50,24 @@ export const cloudStore = new Hono<Env>()
),
)
if (isCloudError(result)) return c.json(result, 502)
return c.json(result)
const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.plan')
return c.json({ ...result, items, total: items.length })
})
.get('/credits/products', async (c) => {
const store = await getUserStoreSettings(c.get('platform').db)
if ('error' in store) return c.json({ error: store.error }, 403)
const result = await cloudRequest(c, async ({ client, storeId }) =>
unwrapCloudResponse(
await client.stores[':storeId'].products.$get({
param: { storeId },
query: { type: 'store_item', limit: '100', status: 'active' },
}),
cloudPackageListResponseSchema,
),
)
if (isCloudError(result)) return c.json(result, 502)
const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.credits')
return c.json({ ...result, items, total: items.length })
})
.get('/targets', async (c) => {
const db = c.get('platform').db
+100
View File
@@ -0,0 +1,100 @@
import { z } from 'zod'
import { zpanCloudEventSchema } from 'zpan-cloud-sdk'
export const legacyCloudProductDeliverableSchema = z.object({
type: z.enum(['zpan.plan', 'zpan.credits', 'zpan.extra']),
storageBytes: z.number().int().min(0).default(0),
trafficBytes: z.number().int().min(0).default(0),
includedCredits: z.number().int().min(0).default(0),
validityDays: z.number().int().positive().optional(),
trafficOveragePriceCents: z.number().int().min(0).optional(),
})
const legacyCloudOrderQuotaChangeSchema = z
.object({
eventId: z.string().min(1),
eventType: z.literal('order.quota_changed'),
cloudOrderId: z.string().min(1),
targetOrgId: z.string().min(1),
direction: z.enum(['increase', 'decrease']),
storageBytes: z.number().int().min(0).default(0),
trafficBytes: z.number().int().min(0).default(0),
trafficOveragePriceCents: z.number().int().min(0).optional(),
source: z.string().min(1).optional(),
packageId: z.string().min(1).optional(),
packageName: z.string().min(1).optional(),
occurredAt: z.string().min(1).optional(),
expiresAt: z.string().datetime().optional(),
customerId: z.string().optional(),
customerEmail: z.string().email().optional(),
})
.strict()
.superRefine((event, ctx) => {
if (event.storageBytes === 0 && event.trafficBytes === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['storageBytes'],
message: 'At least one of storageBytes or trafficBytes must be greater than 0',
})
}
})
const storeDeliveryEventSchema = zpanCloudEventSchema
function numberDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'number' ? value : 0
}
function optionalNumberDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'number' ? value : undefined
}
function stringDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'string' ? value : undefined
}
function targetOrgId(target: Record<string, unknown> | null) {
return typeof target?.orgId === 'string' ? target.orgId : ''
}
function sourceId(event: z.infer<typeof storeDeliveryEventSchema>) {
const orgId = targetOrgId(event.target)
if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}`
return event.orderId
}
function expiresAt(event: z.infer<typeof storeDeliveryEventSchema>) {
if (event.context.billingPeriodEnd) return event.context.billingPeriodEnd
const validityDays = numberDeliverableValue(event.deliverable, 'validityDays')
if (validityDays <= 0) return undefined
return new Date(new Date(event.occurredAt).getTime() + validityDays * 86_400_000).toISOString()
}
export const cloudOrderQuotaChangeSchema = z.union([
legacyCloudOrderQuotaChangeSchema,
storeDeliveryEventSchema.transform((event) => ({
eventId: event.eventId,
eventType: 'order.quota_changed' as const,
cloudOrderId: sourceId(event),
targetOrgId: targetOrgId(event.target),
direction:
event.eventType === 'store.subscription.canceled' || event.eventType === 'store.subscription.expired'
? ('decrease' as const)
: ('increase' as const),
storageBytes: numberDeliverableValue(event.deliverable, 'storageBytes'),
trafficBytes: numberDeliverableValue(event.deliverable, 'trafficBytes'),
trafficOveragePriceCents: optionalNumberDeliverableValue(event.deliverable, 'trafficOveragePriceCents'),
source: event.context.stripeSubscriptionId ? 'stripe_subscription' : 'stripe',
packageId: event.productId,
packageName: stringDeliverableValue(event.deliverable, 'packageName') ?? event.productName,
occurredAt: event.occurredAt,
expiresAt: expiresAt(event),
customerId: typeof event.target?.customerId === 'string' ? event.target.customerId : undefined,
customerEmail: typeof event.target?.customerLabel === 'string' ? event.target.customerLabel : undefined,
})),
])
export type CloudOrderQuotaChange = z.infer<typeof cloudOrderQuotaChangeSchema>
+54 -137
View File
@@ -6,8 +6,10 @@ import {
orderListResponseSchema,
productPriceSchema,
updateProductSchema,
zpanCloudEventSchema,
} from 'zpan-cloud-sdk'
import { type CloudOrderQuotaChange, legacyCloudProductDeliverableSchema } from './cloud-store-legacy'
export { cloudOrderQuotaChangeSchema } from './cloud-store-legacy'
export const cloudStoreSettingsSchema = z.object({
enabled: z.boolean(),
@@ -18,16 +20,24 @@ export const cloudProductPriceSchema = productPriceSchema.extend({
currency: cloudStoreCurrencySchema,
amount: z.number().int().positive(),
})
export const cloudProductDeliverableSchema = z.object({
type: z.enum(['zpan.plan', 'zpan.extra']),
storageBytes: z.number().int().min(0).default(0),
trafficBytes: z.number().int().min(0).default(0),
validityDays: z.number().int().positive().optional(),
trafficOveragePriceCents: z.number().int().min(0).optional(),
})
export const cloudProductDeliverableSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('zpan.plan'),
storageBytes: z.number().int().min(0).default(0),
includedCredits: z.number().int().min(0).default(0),
})
.strict(),
z
.object({
type: z.literal('zpan.credits'),
includedCredits: z.number().int().positive(),
})
.strict(),
])
export const cloudOrderFulfillmentPayloadSchema = z.object({
deliverable: cloudProductDeliverableSchema,
deliverable: z.union([legacyCloudProductDeliverableSchema, cloudProductDeliverableSchema]),
})
export const cloudOrderItemSchema = commerceOrderItemSchema.extend({
fulfillmentPayload: cloudOrderFulfillmentPayloadSchema,
@@ -54,11 +64,7 @@ function validateUniformPriceBilling(
}
}
function isMeteredTrafficPrice(price: CloudProductPrice) {
return price.recurring?.usageType === 'metered' && price.metadata?.usageResource === 'traffic_egress'
}
function validateSubscriptionMeteredPairs(
function validateSubscriptionPrices(
prices: CloudProductPrice[],
ctx: z.RefinementCtx,
path: Array<string | number> = ['prices'],
@@ -67,52 +73,47 @@ function validateSubscriptionMeteredPairs(
if (recurringPrices.length === 0) return
for (const [index, price] of prices.entries()) {
if (price.recurring && (price.recurring.interval !== 'month' || price.recurring.intervalCount !== 1)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...path, index, 'recurring'],
message: 'Subscription prices must bill monthly',
})
if (price.recurring) {
const isMonthly = price.recurring.interval === 'month' && price.recurring.intervalCount === 1
const isAnnual = price.recurring.interval === 'year' && price.recurring.intervalCount === 1
if (!isMonthly && !isAnnual) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...path, index, 'recurring'],
message: 'Subscription prices must bill monthly or yearly',
})
}
}
const usageType = price.recurring?.usageType
const usageResource = price.metadata?.usageResource
if (usageType === 'metered' && usageResource !== 'traffic_egress') {
if (price.recurring?.usageType === 'metered') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...path, index],
message: 'Metered traffic prices must set usageResource to traffic_egress',
message: 'Subscription prices must not use metered billing',
})
}
if (usageResource === 'traffic_egress' && usageType !== 'metered') {
if (price.metadata?.usageResource === 'traffic_egress') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...path, index],
message: 'Traffic overage prices must use metered billing',
message: 'Traffic overage prices are no longer supported',
})
}
}
const currencies = new Set(recurringPrices.map((price) => price.currency))
for (const currency of currencies) {
const monthlyCount = recurringPrices.filter(
(price) => price.currency === currency && !isMeteredTrafficPrice(price),
).length
if (monthlyCount !== 1) {
const fixedPrices = recurringPrices.filter(
(price) => price.currency === currency && price.recurring?.usageType !== 'metered',
)
const monthlyCount = fixedPrices.filter((price) => price.recurring?.interval === 'month').length
const yearlyCount = fixedPrices.filter((price) => price.recurring?.interval === 'year').length
if (fixedPrices.length < 1 || monthlyCount > 1 || yearlyCount > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path,
message: `Subscription prices for ${currency} must have exactly one monthly price`,
message: `Subscription prices for ${currency} must have at least one monthly or yearly price, and at most one of each`,
})
}
const meteredCount = recurringPrices.filter(
(price) => price.currency === currency && isMeteredTrafficPrice(price),
).length
if (meteredCount === 1) continue
ctx.addIssue({
code: z.ZodIssueCode.custom,
path,
message: `Subscription prices for ${currency} must have exactly one metered traffic price`,
})
}
}
@@ -121,12 +122,15 @@ function validateDeliverableBillingMode(
prices: CloudProductPrice[],
ctx: z.RefinementCtx,
) {
const expectedType = prices.some((price) => price.recurring) ? 'zpan.plan' : 'zpan.extra'
if (deliverable.type === expectedType) return
const recurring = prices.some((price) => price.recurring)
if (recurring && deliverable.type === 'zpan.plan') return
if (!recurring && deliverable.type === 'zpan.credits') return
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['metadata', 'deliverable', 'type'],
message: `Deliverable type must be ${expectedType}`,
message: recurring
? 'Recurring prices must use zpan.plan deliverables'
: 'One-time prices must use zpan.credits deliverables',
})
}
@@ -139,14 +143,14 @@ export const cloudProductInputSchema = createProductSchema
})
.superRefine((data, ctx) => {
validateUniformPriceBilling(data.prices, ctx)
validateSubscriptionMeteredPairs(data.prices, ctx)
validateSubscriptionPrices(data.prices, ctx)
const deliverable = data.metadata.deliverable
validateDeliverableBillingMode(deliverable, data.prices, ctx)
if (deliverable.storageBytes === 0 && deliverable.trafficBytes === 0) {
if (deliverable.type === 'zpan.plan' && deliverable.storageBytes === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['metadata', 'deliverable', 'storageBytes'],
message: 'At least one of storageBytes or trafficBytes must be greater than 0',
message: 'Plan storageBytes must be greater than 0',
})
}
})
@@ -161,16 +165,16 @@ export const cloudProductPatchSchema = updateProductSchema
.superRefine((data, ctx) => {
if (data.prices) {
validateUniformPriceBilling(data.prices, ctx)
validateSubscriptionMeteredPairs(data.prices, ctx)
validateSubscriptionPrices(data.prices, ctx)
}
if (data.metadata) {
const deliverable = data.metadata.deliverable
if (data.prices) validateDeliverableBillingMode(deliverable, data.prices, ctx)
if (deliverable.storageBytes > 0 || deliverable.trafficBytes > 0) return
if (deliverable.type === 'zpan.credits' || deliverable.storageBytes > 0) return
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['metadata', 'deliverable', 'storageBytes'],
message: 'At least one of storageBytes or trafficBytes must be greater than 0',
message: 'Plan storageBytes must be greater than 0',
})
}
})
@@ -195,93 +199,6 @@ export const disableGiftCardSchema = z.object({
disabled: z.literal(true),
})
const legacyCloudOrderQuotaChangeSchema = z
.object({
eventId: z.string().min(1),
eventType: z.literal('order.quota_changed'),
cloudOrderId: z.string().min(1),
targetOrgId: z.string().min(1),
direction: z.enum(['increase', 'decrease']),
storageBytes: z.number().int().min(0).default(0),
trafficBytes: z.number().int().min(0).default(0),
trafficOveragePriceCents: z.number().int().min(0).optional(),
source: z.string().min(1).optional(),
packageId: z.string().min(1).optional(),
packageName: z.string().min(1).optional(),
occurredAt: z.string().min(1).optional(),
expiresAt: z.string().datetime().optional(),
customerId: z.string().optional(),
customerEmail: z.string().email().optional(),
})
.strict()
.superRefine((event, ctx) => {
if (event.storageBytes === 0 && event.trafficBytes === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['storageBytes'],
message: 'At least one of storageBytes or trafficBytes must be greater than 0',
})
}
})
const storeDeliveryEventSchema = zpanCloudEventSchema
function numberDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'number' ? value : 0
}
function optionalNumberDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'number' ? value : undefined
}
function stringDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'string' ? value : undefined
}
function targetOrgId(target: Record<string, unknown> | null) {
return typeof target?.orgId === 'string' ? target.orgId : ''
}
function sourceId(event: z.infer<typeof storeDeliveryEventSchema>) {
const orgId = targetOrgId(event.target)
if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}`
return event.orderId
}
function expiresAt(event: z.infer<typeof storeDeliveryEventSchema>) {
if (event.context.billingPeriodEnd) return event.context.billingPeriodEnd
const validityDays = numberDeliverableValue(event.deliverable, 'validityDays')
if (validityDays <= 0) return undefined
return new Date(new Date(event.occurredAt).getTime() + validityDays * 86_400_000).toISOString()
}
export const cloudOrderQuotaChangeSchema = z.union([
legacyCloudOrderQuotaChangeSchema,
storeDeliveryEventSchema.transform((event) => ({
eventId: event.eventId,
eventType: 'order.quota_changed' as const,
cloudOrderId: sourceId(event),
targetOrgId: targetOrgId(event.target),
direction:
event.eventType === 'store.subscription.canceled' || event.eventType === 'store.subscription.expired'
? ('decrease' as const)
: ('increase' as const),
storageBytes: numberDeliverableValue(event.deliverable, 'storageBytes'),
trafficBytes: numberDeliverableValue(event.deliverable, 'trafficBytes'),
trafficOveragePriceCents: optionalNumberDeliverableValue(event.deliverable, 'trafficOveragePriceCents'),
source: event.context.stripeSubscriptionId ? 'stripe_subscription' : 'stripe',
packageId: event.productId,
packageName: stringDeliverableValue(event.deliverable, 'packageName') ?? event.productName,
occurredAt: event.occurredAt,
expiresAt: expiresAt(event),
customerId: typeof event.target?.customerId === 'string' ? event.target.customerId : undefined,
customerEmail: typeof event.target?.customerLabel === 'string' ? event.target.customerLabel : undefined,
})),
])
export type CloudStoreSettingsInput = z.infer<typeof cloudStoreSettingsSchema>
export type CloudStoreCurrency = z.infer<typeof cloudStoreCurrencySchema>
export type CloudProductPrice = z.infer<typeof cloudProductPriceSchema>
@@ -295,7 +212,7 @@ export type CheckoutInput = z.infer<typeof checkoutInputSchema>
export type GiftCardStatus = z.infer<typeof giftCardStatusSchema>
export type CreateGiftCardInput = z.input<typeof createGiftCardInputSchema>
export type DisableGiftCardInput = z.infer<typeof disableGiftCardSchema>
export type CloudOrderQuotaChange = z.infer<typeof cloudOrderQuotaChangeSchema>
export type { CloudOrderQuotaChange }
export const cloudCreditBalanceResponseSchema = z.object({
balance: z.number().int(),
+156 -120
View File
@@ -8,28 +8,35 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { cloudProductStorageBytes, cloudProductTrafficBytes, cloudProductValidityDays } from '@/lib/cloud-product'
import { cloudProductIncludedCredits, cloudProductStorageBytes } from '@/lib/cloud-product'
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'
export const emptyPackageForm = {
name: '',
description: '',
billingMode: 'subscription' as BillingMode,
validityDays: '',
storageSize: '',
storageUnit: 'GB' as Unit,
trafficSize: '',
trafficUnit: 'GB' as Unit,
usdAmount: '9.99',
usdTrafficOverageAmount: '',
includedCredits: '',
usdMonthlyAmount: '9.99',
usdYearlyAmount: '',
sortOrder: '0',
}
export type PackageFormState = typeof emptyPackageForm
export const emptyCreditPackageForm = {
name: '',
description: '',
credits: '1000',
usdAmount: '9.99',
sortOrder: '0',
}
export type CreditPackageFormState = typeof emptyCreditPackageForm
type CloudProductPriceInput = CloudProductInput['prices'][number]
export function packageInputFromForm(form: PackageFormState): CloudProductInput {
const prices = packagePricesFromForm(form)
return {
@@ -38,11 +45,9 @@ export function packageInputFromForm(form: PackageFormState): CloudProductInput
description: form.description,
metadata: {
deliverable: {
type: form.billingMode === 'subscription' ? 'zpan.plan' : 'zpan.extra',
type: 'zpan.plan',
storageBytes: form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0,
trafficBytes: form.trafficSize ? Math.round(Number(form.trafficSize) * units[form.trafficUnit]) : 0,
...(form.billingMode === 'one_time' ? { validityDays: Math.round(Number(form.validityDays)) } : {}),
...trafficOveragePrice(prices),
includedCredits: creditsFromForm(form),
},
},
prices,
@@ -53,25 +58,103 @@ export function packageInputFromForm(form: PackageFormState): CloudProductInput
export function packageFormFromPackage(pkg: CloudProduct): PackageFormState {
const storageBytes = cloudProductStorageBytes(pkg)
const trafficBytes = cloudProductTrafficBytes(pkg)
const validityDays = cloudProductValidityDays(pkg)
const storageDisplay = storageBytes > 0 ? bytesToDisplay(storageBytes) : null
const trafficDisplay = trafficBytes > 0 ? bytesToDisplay(trafficBytes) : null
return {
name: pkg.name,
description: pkg.description ?? '',
billingMode: pkg.prices.some((price) => price.recurring) ? 'subscription' : 'one_time',
validityDays: validityDays ? String(validityDays) : '',
storageSize: storageDisplay ? String(storageDisplay.size) : '',
storageUnit: storageDisplay?.unit ?? 'GB',
trafficSize: trafficDisplay ? String(trafficDisplay.size) : '',
trafficUnit: trafficDisplay?.unit ?? 'GB',
usdAmount: formatMinorAmount(monthlyPrice(pkg)?.amount),
usdTrafficOverageAmount: formatMinorAmount(meteredPrice(pkg)?.amount),
includedCredits: String(cloudProductIncludedCredits(pkg) || ''),
usdMonthlyAmount: formatMinorAmount(recurringUsdPrice(pkg, 'month')?.amount),
usdYearlyAmount: formatMinorAmount(recurringUsdPrice(pkg, 'year')?.amount),
sortOrder: String(pkg.sortOrder),
}
}
export function creditPackageInputFromForm(form: CreditPackageFormState): CloudProductInput {
const credits = creditsFromValue(form.credits)
const usdPrice: CloudProductPriceInput = {
currency: 'usd',
amount: convertCurrencyAmount(form.usdAmount),
metadata: { creditGrantType: 'top_up', creditAmount: String(credits) },
}
return {
type: 'store_item',
name: form.name,
description: form.description,
metadata: {
deliverable: {
type: 'zpan.credits',
includedCredits: credits,
},
},
prices: [usdPrice].filter((price) => Number.isFinite(price.amount) && price.amount > 0),
active: true,
sortOrder: Math.round(Number(form.sortOrder)),
}
}
export function creditPackageFormFromPackage(pkg: CloudProduct): CreditPackageFormState {
return {
name: pkg.name,
description: pkg.description ?? '',
credits: String(cloudProductIncludedCredits(pkg) || ''),
usdAmount: formatMinorAmount(oneTimeUsdPrice(pkg)?.amount),
sortOrder: String(pkg.sortOrder),
}
}
export function CreditPackageForm({
editing,
form,
available,
pending,
onFormChange,
onCancel,
onSubmit,
}: {
editing: CloudProduct | null
form: CreditPackageFormState
available: boolean
pending: boolean
onFormChange: (form: CreditPackageFormState) => void
onCancel: () => void
onSubmit: () => void
}) {
const { t } = useTranslation()
const creditsValid = creditsFromValue(form.credits) > 0
const priceValid = convertCurrencyAmount(form.usdAmount) > 0
return (
<div className="space-y-4">
<PackageIdentityFields form={form} onFormChange={onFormChange} />
<NumberField
label={t('admin.cloudStore.creditAmount')}
id="creditPackageAmount"
min="1"
step="1"
value={form.credits}
onChange={(credits) => onFormChange({ ...form, credits })}
/>
<NumberField
label={t('admin.cloudStore.usdAmount')}
id="creditPackageUsdAmount"
min="0.01"
step="0.01"
value={form.usdAmount}
onChange={(usdAmount) => onFormChange({ ...form, usdAmount })}
/>
<PackageFormActions
editing={editing}
available={available && creditsValid && priceValid}
pending={pending}
onCancel={onCancel}
onSubmit={onSubmit}
/>
</div>
)
}
export function StoragePlanForm({
editing,
form,
@@ -92,15 +175,12 @@ export function StoragePlanForm({
const { t } = useTranslation()
const storageBytes = form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0
const trafficBytes = form.trafficSize ? Math.round(Number(form.trafficSize) * units[form.trafficUnit]) : 0
const quotaValid = storageBytes > 0 || trafficBytes > 0
const validityValid = form.billingMode === 'subscription' || Number(form.validityDays) > 0
const quotaValid = storageBytes > 0
const pricesValid = packagePriceInputsValid(form)
return (
<div className="space-y-4">
<PackageIdentityFields form={form} onFormChange={onFormChange} />
<PackageBillingFields form={form} onFormChange={onFormChange} />
<PackageQuotaFields
label={t('admin.cloudStore.storageQuota')}
sizeId="packageStorageSize"
@@ -109,24 +189,21 @@ export function StoragePlanForm({
onSizeChange={(storageSize) => onFormChange({ ...form, storageSize })}
onUnitChange={(storageUnit) => onFormChange({ ...form, storageUnit })}
/>
<PackageQuotaFields
label={t('admin.cloudStore.trafficQuota')}
sizeId="packageTrafficSize"
sizeValue={form.trafficSize}
unit={form.trafficUnit}
onSizeChange={(trafficSize) => onFormChange({ ...form, trafficSize })}
onUnitChange={(trafficUnit) => onFormChange({ ...form, trafficUnit })}
<NumberField
label={t('admin.cloudStore.includedCredits')}
id="packageIncludedCredits"
min="0"
step="1"
value={form.includedCredits}
onChange={(includedCredits) => onFormChange({ ...form, includedCredits })}
/>
{!quotaValid && (form.storageSize !== '' || form.trafficSize !== '') && (
{!quotaValid && form.storageSize !== '' && (
<p className="text-xs text-destructive">{t('admin.cloudStore.quotaRequired')}</p>
)}
{!validityValid && form.validityDays !== '' && (
<p className="text-xs text-destructive">{t('admin.cloudStore.validityRequired')}</p>
)}
<PackageAmountFields form={form} onFormChange={onFormChange} />
<PackageFormActions
editing={editing}
available={available && quotaValid && validityValid && pricesValid}
available={available && quotaValid && pricesValid}
pending={pending}
onCancel={onCancel}
onSubmit={onSubmit}
@@ -139,37 +216,34 @@ function packagePricesFromForm(form: PackageFormState) {
return packagePricesForForm(form).filter((price) => Number.isFinite(price.amount) && price.amount > 0)
}
function trafficOveragePrice(prices: ReturnType<typeof packagePricesFromForm>) {
const price = prices.find(isFormMeteredTrafficPrice)
return price ? { trafficOveragePriceCents: price.amount } : {}
function creditsFromForm(form: PackageFormState) {
return creditsFromValue(form.includedCredits)
}
function isFormMeteredTrafficPrice(price: ReturnType<typeof packagePricesFromForm>[number]) {
return (
'metadata' in price && price.recurring.usageType === 'metered' && price.metadata.usageResource === 'traffic_egress'
)
function creditsFromValue(value: string) {
const credits = Number(value)
return Number.isSafeInteger(credits) && credits > 0 ? credits : 0
}
function packagePriceInputsValid(form: PackageFormState) {
const hasAmount = convertCurrencyAmount(form.usdAmount) > 0
if (!hasAmount) return false
return form.billingMode !== 'subscription' || convertCurrencyAmount(form.usdTrafficOverageAmount) > 0
return convertCurrencyAmount(form.usdMonthlyAmount) > 0 || convertCurrencyAmount(form.usdYearlyAmount) > 0
}
function packagePricesForForm(form: PackageFormState) {
const monthlyPrice = {
currency: 'usd' as const,
amount: convertCurrencyAmount(form.usdAmount),
...(form.billingMode === 'subscription' ? { recurring: { interval: 'month' as const, intervalCount: 1 } } : {}),
}
if (form.billingMode !== 'subscription') return [monthlyPrice]
const credits = creditsFromForm(form)
const metadata = credits > 0 ? { creditGrantType: 'subscription_grant', creditAmount: String(credits) } : undefined
return [
monthlyPrice,
{
currency: 'usd' as const,
amount: convertCurrencyAmount(form.usdTrafficOverageAmount),
recurring: { interval: 'month' as const, intervalCount: 1, usageType: 'metered' as const },
metadata: { usageResource: 'traffic_egress' },
amount: convertCurrencyAmount(form.usdMonthlyAmount),
recurring: { interval: 'month' as const, intervalCount: 1 },
...(metadata ? { metadata } : {}),
},
{
currency: 'usd' as const,
amount: convertCurrencyAmount(form.usdYearlyAmount),
recurring: { interval: 'year' as const, intervalCount: 1 },
...(metadata ? { metadata } : {}),
},
]
}
@@ -182,16 +256,18 @@ function formatMinorAmount(minorAmount: number | undefined): string {
return minorAmount === undefined ? '' : (minorAmount / 100).toString()
}
function isMeteredTrafficPrice(price: CloudProduct['prices'][number]) {
return price.recurring?.usageType === 'metered' && price.metadata?.usageResource === 'traffic_egress'
function recurringUsdPrice(pkg: CloudProduct, interval: 'month' | 'year') {
return pkg.prices.find(
(price) =>
price.currency === 'usd' &&
price.recurring?.interval === interval &&
price.recurring.intervalCount === 1 &&
price.recurring.usageType !== 'metered',
)
}
function monthlyPrice(pkg: CloudProduct) {
return pkg.prices.find((price) => price.currency === 'usd' && !isMeteredTrafficPrice(price))
}
function meteredPrice(pkg: CloudProduct) {
return pkg.prices.find((price) => price.currency === 'usd' && isMeteredTrafficPrice(price))
function oneTimeUsdPrice(pkg: CloudProduct) {
return pkg.prices.find((price) => price.currency === 'usd' && !price.recurring)
}
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) {
@@ -203,12 +279,12 @@ function Field({ label, htmlFor, children }: { label: string; htmlFor?: string;
)
}
function PackageIdentityFields({
function PackageIdentityFields<TForm extends { name: string; description: string }>({
form,
onFormChange,
}: {
form: PackageFormState
onFormChange: (form: PackageFormState) => void
form: TForm
onFormChange: (form: TForm) => void
}) {
const { t } = useTranslation()
return (
@@ -228,44 +304,6 @@ function PackageIdentityFields({
)
}
function PackageBillingFields({
form,
onFormChange,
}: {
form: PackageFormState
onFormChange: (form: PackageFormState) => void
}) {
const { t } = useTranslation()
return (
<div className="grid gap-2 sm:grid-cols-2">
<Field label={t('admin.cloudStore.billingMode')}>
<Select
value={form.billingMode}
onValueChange={(billingMode) => onFormChange({ ...form, billingMode: billingMode as BillingMode })}
>
<SelectTrigger aria-label={t('admin.cloudStore.billingMode')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="subscription">{t('admin.cloudStore.billingSubscription')}</SelectItem>
<SelectItem value="one_time">{t('admin.cloudStore.billingOneTime')}</SelectItem>
</SelectContent>
</Select>
</Field>
{form.billingMode === 'one_time' && (
<NumberField
label={t('admin.cloudStore.validityDays')}
id="packageValidityDays"
min="1"
step="1"
value={form.validityDays}
onChange={(validityDays) => onFormChange({ ...form, validityDays })}
/>
)}
</div>
)
}
function PackageQuotaFields({
label,
sizeId,
@@ -312,23 +350,21 @@ function PackageAmountFields({
return (
<div className="grid gap-2 sm:grid-cols-2">
<NumberField
label={t('admin.cloudStore.usdAmount')}
id="packageUsdAmount"
label={t('admin.cloudStore.usdMonthlyAmount')}
id="packageUsdMonthlyAmount"
min="0.01"
step="0.01"
value={form.usdAmount}
onChange={(usdAmount) => onFormChange({ ...form, usdAmount })}
value={form.usdMonthlyAmount}
onChange={(usdMonthlyAmount) => onFormChange({ ...form, usdMonthlyAmount })}
/>
<NumberField
label={t('admin.cloudStore.usdYearlyAmount')}
id="packageUsdYearlyAmount"
min="0.01"
step="0.01"
value={form.usdYearlyAmount}
onChange={(usdYearlyAmount) => onFormChange({ ...form, usdYearlyAmount })}
/>
{form.billingMode === 'subscription' && (
<NumberField
label={t('admin.cloudStore.usdTrafficOveragePrice')}
id="packageUsdTrafficOverageAmount"
min="0.01"
step="0.01"
value={form.usdTrafficOverageAmount}
onChange={(usdTrafficOverageAmount) => onFormChange({ ...form, usdTrafficOverageAmount })}
/>
)}
</div>
)
}
+123 -13
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { cloudProductStorageBytes, cloudProductTrafficBytes } from '@/lib/cloud-product'
import { cloudProductIncludedCredits, cloudProductStorageBytes } from '@/lib/cloud-product'
import { formatSize } from '@/lib/format'
export function StoragePlanList({
@@ -28,9 +28,9 @@ export function StoragePlanList({
<colgroup>
<col />
<col className="w-28" />
<col className="w-32" />
<col className="w-36" />
<col className="w-24" />
<col className="w-24" />
<col className="w-20" />
<col className="w-44" />
</colgroup>
@@ -38,8 +38,8 @@ export function StoragePlanList({
<TableRow>
<TableHead>{t('admin.cloudStore.planName')}</TableHead>
<TableHead className="w-28">{t('admin.cloudStore.storageQuota')}</TableHead>
<TableHead className="w-36">{t('admin.cloudStore.trafficQuota')}</TableHead>
<TableHead className="w-24">{t('admin.cloudStore.prices')}</TableHead>
<TableHead className="w-32">{t('admin.cloudStore.includedCredits')}</TableHead>
<TableHead className="w-36">{t('admin.cloudStore.prices')}</TableHead>
<TableHead className="w-24">{t('admin.cloudStore.active')}</TableHead>
<TableHead className="w-20">{t('admin.cloudStore.sortOrder')}</TableHead>
<TableHead className="w-44 text-right">{t('common.actions')}</TableHead>
@@ -57,10 +57,8 @@ export function StoragePlanList({
<TableCell className="tabular-nums">
{cloudProductStorageBytes(pkg) > 0 ? formatSize(cloudProductStorageBytes(pkg)) : '—'}
</TableCell>
<TableCell className="tabular-nums">
{cloudProductTrafficBytes(pkg) > 0 ? formatSize(cloudProductTrafficBytes(pkg)) : '—'}
</TableCell>
<TableCell className="tabular-nums">{formatUsdPrice(pkg.prices)}</TableCell>
<TableCell className="tabular-nums">{formatCredits(cloudProductIncludedCredits(pkg))}</TableCell>
<TableCell className="tabular-nums">{formatUsdPrices(pkg.prices)}</TableCell>
<TableCell>
<Badge variant={pkg.active ? 'default' : 'secondary'}>
{pkg.active ? t('common.active') : t('common.disabled')}
@@ -104,11 +102,123 @@ export function StoragePlanList({
)
}
function formatUsdPrice(prices: CloudProduct['prices']) {
const price = prices.find((item) => item.currency === 'usd' && !isMeteredTrafficPrice(item))
return price ? `${(price.amount / 100).toFixed(2)} USD` : '—'
export function CreditPackageList({
packages,
onEdit,
onDelete,
onPublishChange,
actionPending,
}: {
packages: CloudProduct[]
onEdit: (pkg: CloudProduct) => void
onDelete: (pkg: CloudProduct) => void
onPublishChange: (pkg: CloudProduct, active: boolean) => void
actionPending?: boolean
}) {
const { t } = useTranslation()
return (
<div className="rounded-md border">
<Table className="table-fixed">
<colgroup>
<col />
<col className="w-32" />
<col className="w-32" />
<col className="w-24" />
<col className="w-20" />
<col className="w-44" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>{t('admin.cloudStore.creditPackageName')}</TableHead>
<TableHead className="w-32">{t('admin.cloudStore.creditAmount')}</TableHead>
<TableHead className="w-32">{t('admin.cloudStore.prices')}</TableHead>
<TableHead className="w-24">{t('admin.cloudStore.active')}</TableHead>
<TableHead className="w-20">{t('admin.cloudStore.sortOrder')}</TableHead>
<TableHead className="w-44 text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{packages.map((pkg) => (
<TableRow key={pkg.id}>
<TableCell className="min-w-0">
<div className="min-w-0">
<div className="truncate font-medium">{pkg.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{pkg.description ?? ''}</div>
</div>
</TableCell>
<TableCell className="tabular-nums">{formatCredits(cloudProductIncludedCredits(pkg))}</TableCell>
<TableCell className="tabular-nums">{formatOneTimeUsdPrice(pkg.prices)}</TableCell>
<TableCell>
<Badge variant={pkg.active ? 'default' : 'secondary'}>
{pkg.active ? t('common.active') : t('common.disabled')}
</Badge>
</TableCell>
<TableCell className="tabular-nums">{pkg.sortOrder}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={actionPending}
onClick={() => onPublishChange(pkg, !pkg.active)}
>
{pkg.active ? t('admin.cloudStore.unpublish') : t('admin.cloudStore.publish')}
</Button>
<Button variant="outline" size="icon-sm" onClick={() => onEdit(pkg)} title={t('common.edit')}>
<Pencil />
<span className="sr-only">{t('common.edit')}</span>
</Button>
<Button
variant="outline"
size="icon-sm"
disabled={actionPending}
onClick={() => onDelete(pkg)}
title={t('common.delete')}
>
<Trash2 className="text-destructive" />
<span className="sr-only">{t('common.delete')}</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{packages.length === 0 && (
<div className="border-t p-8 text-center text-sm text-muted-foreground">
{t('admin.cloudStore.noCreditPackages')}
</div>
)}
</div>
)
}
function isMeteredTrafficPrice(price: CloudProduct['prices'][number]) {
return price.recurring?.usageType === 'metered' && price.metadata?.usageResource === 'traffic_egress'
function formatUsdPrices(prices: CloudProduct['prices']) {
const monthly = recurringPrice(prices, 'month')
const yearly = recurringPrice(prices, 'year')
const parts = [
monthly ? `$${(monthly.amount / 100).toFixed(2)}/mo` : null,
yearly ? `$${(yearly.amount / 100).toFixed(2)}/yr` : null,
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : '—'
}
function formatOneTimeUsdPrice(prices: CloudProduct['prices']) {
const price = prices.find((item) => item.currency === 'usd' && !item.recurring)
return price ? `$${(price.amount / 100).toFixed(2)}` : '—'
}
function recurringPrice(prices: CloudProduct['prices'], interval: 'month' | 'year') {
return prices.find(
(item) =>
item.currency === 'usd' &&
item.recurring?.interval === interval &&
item.recurring.intervalCount === 1 &&
item.recurring.usageType !== 'metered',
)
}
function formatCredits(credits: number) {
return credits > 0 ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(credits) : '—'
}
@@ -11,12 +11,16 @@ import {
StorageGiftCardPanel,
} from '@/components/admin/cloud-gift-card-panel'
import {
CreditPackageForm,
creditPackageFormFromPackage,
creditPackageInputFromForm,
emptyCreditPackageForm,
emptyPackageForm,
packageFormFromPackage,
packageInputFromForm,
StoragePlanForm,
} from '@/components/admin/cloud-product-form'
import { StoragePlanList } from '@/components/admin/cloud-product-list'
import { CreditPackageList, StoragePlanList } from '@/components/admin/cloud-product-list'
import {
createCloudGiftCards,
createCloudProduct,
@@ -72,6 +76,47 @@ export function usePackageEditor() {
}
}
export function useCreditPackageEditor() {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<CloudProduct | null>(null)
const [deleting, setDeleting] = useState<CloudProduct | null>(null)
const [form, setForm] = useState(emptyCreditPackageForm)
const mutation = useCreditPackageMutation(editing, form, () => {
setOpen(false)
setEditing(null)
setForm(emptyCreditPackageForm)
})
const publishMutation = usePackagePublishMutation()
const deleteMutation = usePackageDeleteMutation(() => setDeleting(null))
return {
open,
editing,
deleting,
form,
setForm,
newPackage: () => {
setEditing(null)
setForm(emptyCreditPackageForm)
setOpen(true)
},
mutation,
publishMutation,
deleteMutation,
edit: (pkg: CloudProduct) => editCreditPackage(pkg, setEditing, setForm, setOpen),
publish: (pkg: CloudProduct, active: boolean) => publishMutation.mutate({ id: pkg.id, active }),
delete: (pkg: CloudProduct) => setDeleting(pkg),
cancelDelete: () => setDeleting(null),
confirmDelete: () => {
if (deleting) deleteMutation.mutate(deleting.id)
},
cancel: () => {
setOpen(false)
setEditing(null)
setForm(emptyCreditPackageForm)
},
}
}
export function useGiftCardActions() {
const [form, setForm] = useState(emptyGiftCardForm)
const [disablingGiftCard, setDisablingGiftCard] = useState<string | null>(null)
@@ -85,18 +130,23 @@ export function useGiftCardActions() {
export function PackagesTab({
available,
packages,
creditPackages,
editor,
creditEditor,
}: {
available: boolean
packages: CloudProduct[]
creditPackages: CloudProduct[]
editor: ReturnType<typeof usePackageEditor>
creditEditor: ReturnType<typeof useCreditPackageEditor>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<PackageFilter>('all')
const visiblePackages = filterPackages(packages, filter)
const visibleCreditPackages = filterPackages(creditPackages, filter)
return (
<div className="space-y-3">
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<Select value={filter} onValueChange={(value) => setFilter(value as PackageFilter)}>
<SelectTrigger className="w-40">
@@ -113,15 +163,42 @@ export function PackagesTab({
{t('admin.cloudStore.newPackage')}
</Button>
</div>
<StoragePlanList
packages={visiblePackages}
actionPending={editor.publishMutation.isPending || editor.deleteMutation.isPending}
onEdit={editor.edit}
onDelete={editor.delete}
onPublishChange={editor.publish}
/>
<section className="space-y-3">
<div>
<h3 className="text-base font-semibold">{t('admin.cloudStore.planProductsTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.cloudStore.planProductsDescription')}</p>
</div>
<StoragePlanList
packages={visiblePackages}
actionPending={editor.publishMutation.isPending || editor.deleteMutation.isPending}
onEdit={editor.edit}
onDelete={editor.delete}
onPublishChange={editor.publish}
/>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold">{t('admin.cloudStore.creditProductsTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.cloudStore.creditProductsDescription')}</p>
</div>
<Button disabled={!available} onClick={creditEditor.newPackage}>
<Plus className="mr-2 h-4 w-4" />
{t('admin.cloudStore.newCreditPackage')}
</Button>
</div>
<CreditPackageList
packages={visibleCreditPackages}
actionPending={creditEditor.publishMutation.isPending || creditEditor.deleteMutation.isPending}
onEdit={creditEditor.edit}
onDelete={creditEditor.delete}
onPublishChange={creditEditor.publish}
/>
</section>
<PackageDialog available={available} editor={editor} />
<DeletePackageDialog editor={editor} />
<CreditPackageDialog available={available} editor={creditEditor} />
<DeleteCreditPackageDialog editor={creditEditor} />
</div>
)
}
@@ -150,6 +227,36 @@ function PackageDialog({ available, editor }: { available: boolean; editor: Retu
)
}
function CreditPackageDialog({
available,
editor,
}: {
available: boolean
editor: ReturnType<typeof useCreditPackageEditor>
}) {
const { t } = useTranslation()
return (
<Dialog open={editor.open} onOpenChange={(open) => (open ? editor.newPackage() : editor.cancel())}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{editor.editing ? t('admin.cloudStore.editCreditPackage') : t('admin.cloudStore.newCreditPackage')}
</DialogTitle>
</DialogHeader>
<CreditPackageForm
editing={editor.editing}
form={editor.form}
available={available}
pending={editor.mutation.isPending}
onFormChange={editor.setForm}
onCancel={editor.cancel}
onSubmit={() => editor.mutation.mutate()}
/>
</DialogContent>
</Dialog>
)
}
function DeletePackageDialog({ editor }: { editor: ReturnType<typeof usePackageEditor> }) {
const { t } = useTranslation()
const pkg = editor.deleting
@@ -180,6 +287,36 @@ function DeletePackageDialog({ editor }: { editor: ReturnType<typeof usePackageE
)
}
function DeleteCreditPackageDialog({ editor }: { editor: ReturnType<typeof useCreditPackageEditor> }) {
const { t } = useTranslation()
const pkg = editor.deleting
if (!pkg) return null
return (
<Dialog
open={Boolean(pkg)}
onOpenChange={(open) => {
if (!open && !editor.deleteMutation.isPending) editor.cancelDelete()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.cloudStore.deleteCreditTitle')}</DialogTitle>
<DialogDescription>{t('admin.cloudStore.deleteConfirm', { name: pkg.name })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={editor.cancelDelete} disabled={editor.deleteMutation.isPending}>
{t('common.cancel')}
</Button>
<Button variant="destructive" disabled={editor.deleteMutation.isPending} onClick={editor.confirmDelete}>
{editor.deleteMutation.isPending ? t('common.loading') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function GiftCardsTab({
actions,
available,
@@ -225,6 +362,24 @@ function usePackageMutation(editing: CloudProduct | null, form: typeof emptyPack
})
}
function useCreditPackageMutation(
editing: CloudProduct | null,
form: typeof emptyCreditPackageForm,
onSaved: () => void,
) {
const { t } = useTranslation()
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => saveCreditPackage(editing, form),
onSuccess: () => {
onSaved()
queryClient.invalidateQueries({ queryKey: ['admin', 'cloud-store'] })
toast.success(t('admin.cloudStore.packageSaved'))
},
onError: (err) => toast.error(err.message),
})
}
function usePackagePublishMutation() {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -307,6 +462,17 @@ function editPackage(
setOpen(true)
}
function editCreditPackage(
pkg: CloudProduct,
setEditing: (pkg: CloudProduct) => void,
setForm: (form: typeof emptyCreditPackageForm) => void,
setOpen: (open: boolean) => void,
) {
setEditing(pkg)
setForm(creditPackageFormFromPackage(pkg))
setOpen(true)
}
function savePackage(editing: CloudProduct | null, form: typeof emptyPackageForm) {
const input = packageInputFromForm(form)
if (!editing) return createCloudProduct(input)
@@ -320,6 +486,19 @@ function savePackage(editing: CloudProduct | null, form: typeof emptyPackageForm
})
}
function saveCreditPackage(editing: CloudProduct | null, form: typeof emptyCreditPackageForm) {
const input = creditPackageInputFromForm(form)
if (!editing) return createCloudProduct(input)
return updateCloudProduct(editing.id, {
type: input.type,
name: input.name,
description: input.description,
metadata: input.metadata,
prices: input.prices,
sortOrder: input.sortOrder,
})
}
function filterPackages(packages: CloudProduct[], filter: PackageFilter) {
if (filter === 'active') return packages.filter((pkg) => pkg.active)
if (filter === 'disabled') return packages.filter((pkg) => !pkg.active)
+67 -1
View File
@@ -1,7 +1,9 @@
import type { CloudCreditLedgerEntry } from '@shared/schemas'
import { BadgeCent } from 'lucide-react'
import type { CloudProduct } from '@shared/types'
import { BadgeCent, PlusCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -10,20 +12,27 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { cloudProductIncludedCredits } from '@/lib/cloud-product'
import { StorageActions } from './storage-dialogs'
export function CreditBalanceButton({
credits,
products,
entries,
loading,
onRedeem,
onCheckout,
isRedeeming,
checkoutDisabled,
}: {
credits?: { balance: number }
products: CloudProduct[]
entries: CloudCreditLedgerEntry[]
loading: boolean
onRedeem: (code: string) => void
onCheckout: (packageId: string, priceId: string) => void
isRedeeming: boolean
checkoutDisabled: boolean
}) {
const { t } = useTranslation()
return (
@@ -44,6 +53,7 @@ export function CreditBalanceButton({
<DialogDescription>{t('storage.creditActivityDescription')}</DialogDescription>
</DialogHeader>
<CreditBalanceSummary credits={credits} onRedeem={onRedeem} isRedeeming={isRedeeming} />
<CreditProducts products={products} disabled={checkoutDisabled} onCheckout={onCheckout} />
<div className="space-y-3">
<h3 className="text-sm font-medium">{t('storage.creditActivityTitle')}</h3>
<CreditActivity entries={entries} loading={loading} />
@@ -53,6 +63,53 @@ export function CreditBalanceButton({
)
}
function CreditProducts({
products,
disabled,
onCheckout,
}: {
products: CloudProduct[]
disabled: boolean
onCheckout: (packageId: string, priceId: string) => void
}) {
const { t, i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? 'en'
const purchasableProducts = products
.map((product) => ({ product, price: oneTimeUsdPrice(product) }))
.filter((item): item is { product: CloudProduct; price: CloudProduct['prices'][number] & { id: string } } =>
Boolean(item.price),
)
if (purchasableProducts.length === 0) return null
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">{t('storage.creditTopUpTitle')}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{purchasableProducts.map(({ product, price }) => (
<div key={product.id} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{product.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('storage.creditTopUpAmount', { amount: formatCredits(cloudProductIncludedCredits(product)) })}
</div>
</div>
<div className="shrink-0 text-sm font-semibold tabular-nums">
{formatMoney(price.amount, price.currency, language)}
</div>
</div>
<Button className="mt-3 h-8 w-full" disabled={disabled} onClick={() => onCheckout(product.id, price.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.buyCredits')}
</Button>
</div>
))}
</div>
</div>
)
}
function CreditBalanceSummary({
credits,
onRedeem,
@@ -127,6 +184,15 @@ function formatCredits(amount: number) {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(amount)
}
function formatMoney(amount: number, currency: string, language: string) {
return new Intl.NumberFormat(language, { style: 'currency', currency: currency.toUpperCase() }).format(amount / 100)
}
function oneTimeUsdPrice(pricesProduct: CloudProduct) {
const price = pricesProduct.prices.find((item) => item.currency === 'usd' && !item.recurring)
return price?.id ? { ...price, id: price.id } : null
}
function creditSourceLabel(
sourceType: CloudCreditLedgerEntry['sourceType'],
t: ReturnType<typeof useTranslation>['t'],
+52 -65
View File
@@ -1,10 +1,10 @@
import type { CloudProduct } from '@shared/types'
import { HardDrive, Package, PlusCircle } from 'lucide-react'
import { HardDrive, PlusCircle } from 'lucide-react'
import type * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cloudProductStorageBytes, cloudProductTrafficBytes, cloudProductValidityDays } from '@/lib/cloud-product'
import { cloudProductIncludedCredits, cloudProductStorageBytes } from '@/lib/cloud-product'
import { formatSize } from '@/lib/format'
export function StoragePackages({
@@ -101,41 +101,44 @@ function PackageCard({
onCheckout: (packageId: string, priceId: string) => void
}) {
const { t } = useTranslation()
const price = selectPrice(pkg.prices)
const priceLabel = formatPackagePrice(price, pkg, language, t)
const plan = isPlanProduct(pkg)
const prices = selectPlanPrices(pkg.prices)
const primaryPrice = prices.monthly ?? prices.yearly
if (!primaryPrice) throw new Error('cloud_product_price_missing')
const priceLabel = formatPlanPrice(primaryPrice, language, t)
const storageBytes = cloudProductStorageBytes(pkg)
const trafficBytes = cloudProductTrafficBytes(pkg)
const includedCredits = cloudProductIncludedCredits(pkg)
return (
<ProductCardShell
title={pkg.name}
description={pkg.description ?? ''}
badge={plan ? t('storage.monthlyPlanBadge') : t('storage.resourcePackageBadge')}
icon={plan ? <HardDrive className="h-4 w-4" /> : <Package className="h-4 w-4" />}
badge={t('storage.planBadge')}
icon={<HardDrive className="h-4 w-4" />}
price={priceLabel}
action={
<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>
<div className="grid gap-2">
{prices.monthly && (
<Button className="h-9 w-full" disabled={disabled} onClick={() => onCheckout(pkg.id, prices.monthly!.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutMonthly')}
</Button>
)}
{prices.yearly && (
<Button
className="h-9 w-full"
variant={prices.monthly ? 'outline' : 'default'}
disabled={disabled}
onClick={() => onCheckout(pkg.id, prices.yearly!.id)}
>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutYearly')}
</Button>
)}
</div>
}
>
{plan ? (
<>
<PlanDetailRow label={t('storage.baseStorageQuota')} value={formatSize(storageBytes)} />
<PlanDetailRow label={t('storage.includedTraffic')} value={formatSize(trafficBytes)} />
<PlanDetailRow
label={t('storage.trafficPolicy')}
value={formatTrafficPolicy(pkg, price.currency, language, t)}
/>
</>
) : (
<>
<PlanDetailRow label={t('storage.packageStorageQuota')} value={formatSize(storageBytes)} />
<PlanDetailRow label={t('storage.packageTrafficQuota')} value={formatSize(trafficBytes)} />
<PlanDetailRow label={t('storage.packageValidity')} value={formatValidity(pkg, t)} />
</>
)}
<PlanDetailRow label={t('storage.baseStorageQuota')} value={formatSize(storageBytes)} />
<PlanDetailRow label={t('storage.includedCredits')} value={formatCredits(includedCredits)} />
<PlanDetailRow label={t('storage.trafficPolicy')} value={t('storage.usageBilledWithCredits')} />
</ProductCardShell>
)
}
@@ -149,60 +152,44 @@ function PlanDetailRow({ label, value }: { label: string; value: string }) {
)
}
function selectPrice(prices: CloudProduct['prices']) {
const purchasablePrices = prices.filter((item) => item.recurring?.usageType !== 'metered')
const price = purchasablePrices.find((item) => item.currency === 'usd')
if (!price) throw new Error('cloud_product_price_missing')
const priceId = price.id
if (!priceId) throw new Error('cloud_product_price_missing')
return { ...price, id: priceId }
}
function isPlanProduct(pkg: CloudProduct) {
return pkg.prices.some((price) => price.recurring && price.recurring.usageType !== 'metered')
function selectPlanPrices(prices: CloudProduct['prices']) {
return {
monthly: recurringPrice(prices, 'month'),
yearly: recurringPrice(prices, 'year'),
}
}
function formatMoney(amount: number, currency: string, language: string) {
return new Intl.NumberFormat(language, { style: 'currency', currency: currency.toUpperCase() }).format(amount / 100)
}
function formatPackagePrice(
function formatPlanPrice(
price: CloudProduct['prices'][number],
pkg: CloudProduct,
language: string,
t: ReturnType<typeof useTranslation>['t'],
) {
const amount = formatMoney(price.amount, price.currency, language)
if (price.recurring?.interval === 'month' && price.recurring.intervalCount === 1)
return t('storage.priceMonthly', { amount })
const validityDays = cloudProductValidityDays(pkg)
if (validityDays) return t('storage.priceForDays', { amount, days: validityDays })
if (price.recurring?.interval === 'year' && price.recurring.intervalCount === 1)
return t('storage.priceYearly', { amount })
return amount
}
function formatValidity(pkg: CloudProduct, t: ReturnType<typeof useTranslation>['t']) {
const validityDays = cloudProductValidityDays(pkg)
if (validityDays) return t('storage.billingFixedDays', { days: validityDays })
return t('storage.packageNoExpiry')
}
function selectMeteredTrafficPrice(prices: CloudProduct['prices'], currency: string) {
return prices.find(
(price) =>
price.currency === currency &&
price.recurring?.usageType === 'metered' &&
price.metadata?.usageResource === 'traffic_egress',
function recurringPrice(prices: CloudProduct['prices'], interval: 'month' | 'year') {
const price = prices.find(
(item) =>
item.currency === 'usd' &&
item.recurring?.interval === interval &&
item.recurring.intervalCount === 1 &&
item.recurring.usageType !== 'metered',
)
if (!price) return null
const priceId = price.id
if (!priceId) throw new Error('cloud_product_price_missing')
return { ...price, id: priceId }
}
function formatTrafficPolicy(
pkg: CloudProduct,
currency: string,
language: string,
t: ReturnType<typeof useTranslation>['t'],
) {
if (cloudProductTrafficBytes(pkg) <= 0) return t('storage.usageNoLimit')
const overagePrice = selectMeteredTrafficPrice(pkg.prices, currency)
if (!overagePrice) return t('storage.trafficStopsAtQuota')
return t('storage.trafficOveragePerGb', { amount: formatMoney(overagePrice.amount, currency, language) })
function formatCredits(credits: number) {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(credits)
}
+27 -5
View File
@@ -1138,33 +1138,46 @@
"admin.cloudStore.cloudTip.connected": "This instance is connected to ZPan Cloud for storage plan delivery.",
"admin.cloudStore.cloudTip.notConnected": "Connect this instance to ZPan Cloud before relying on storage plan delivery.",
"admin.cloudStore.newPackage": "New plan",
"admin.cloudStore.newCreditPackage": "New Credits package",
"admin.cloudStore.editPackage": "Edit plan",
"admin.cloudStore.editCreditPackage": "Edit Credits package",
"admin.cloudStore.packageName": "Name",
"admin.cloudStore.planName": "Plan name",
"admin.cloudStore.creditPackageName": "Credits package",
"admin.cloudStore.description": "Description",
"admin.cloudStore.billingMode": "Billing",
"admin.cloudStore.billingSubscription": "Monthly subscription",
"admin.cloudStore.billingSubscription": "Subscription",
"admin.cloudStore.billingOneTime": "Fixed-duration package",
"admin.cloudStore.validityDays": "Valid days",
"admin.cloudStore.size": "Size",
"admin.cloudStore.unit": "Unit",
"admin.cloudStore.storageQuota": "Storage quota",
"admin.cloudStore.trafficQuota": "Download traffic quota",
"admin.cloudStore.includedCredits": "Included Credits",
"admin.cloudStore.creditAmount": "Credits",
"admin.cloudStore.trafficOveragePrice": "Traffic overage price",
"admin.cloudStore.quotaOptionalHint": "Leave blank to omit",
"admin.cloudStore.quotaRequired": "At least one of storage quota or download traffic quota must be set.",
"admin.cloudStore.quotaRequired": "Storage quota must be set.",
"admin.cloudStore.validityRequired": "Valid days must be greater than 0.",
"admin.cloudStore.prices": "Prices",
"admin.cloudStore.usdAmount": "Package amount (USD)",
"admin.cloudStore.usdMonthlyAmount": "Monthly price (USD)",
"admin.cloudStore.usdYearlyAmount": "Yearly price (USD)",
"admin.cloudStore.usdTrafficOveragePrice": "Traffic overage price / GB (USD)",
"admin.cloudStore.sortOrder": "Sort order",
"admin.cloudStore.active": "Active",
"admin.cloudStore.publish": "Publish",
"admin.cloudStore.unpublish": "Unpublish",
"admin.cloudStore.deleteTitle": "Delete plan",
"admin.cloudStore.deleteCreditTitle": "Delete Credits package",
"admin.cloudStore.deleteConfirm": "Delete {{name}}? This removes the plan from ZPan Cloud so users cannot buy it anymore. Existing purchases and delivered quota are not changed.",
"admin.cloudStore.noPackages": "No storage plans configured.",
"admin.cloudStore.noPlans": "No storage plans configured.",
"admin.cloudStore.noCreditPackages": "No Credits packages configured.",
"admin.cloudStore.planProductsTitle": "Subscription plans",
"admin.cloudStore.planProductsDescription": "Plans grant storage and included Credits every billing period.",
"admin.cloudStore.creditProductsTitle": "Credits packages",
"admin.cloudStore.creditProductsDescription": "One-time top-ups sold separately from subscription plans.",
"admin.cloudStore.packages.filterAll": "All plans",
"admin.cloudStore.packages.filterActive": "Active only",
"admin.cloudStore.packages.filterDisabled": "Disabled only",
@@ -1277,6 +1290,9 @@
"storage.creditActivityTitle": "Credit activity",
"storage.creditActivityDescription": "Credit changes from gift-card redemptions, grants, top-ups, and usage charges.",
"storage.creditActivityEmpty": "No credit activity yet.",
"storage.creditTopUpTitle": "Credit top-ups",
"storage.creditTopUpAmount": "{{amount}} Credits",
"storage.buyCredits": "Buy Credits",
"storage.creditTableType": "Type",
"storage.creditTableChange": "Change",
"storage.creditTableStatus": "Status",
@@ -1294,12 +1310,13 @@
"storage.packagesTitle": "Storage plans",
"storage.plansTitle": "Storage plans",
"storage.packagesDescription": "Available storage plans for the current workspace.",
"storage.plansDescription": "Available storage and traffic plans for the current workspace.",
"storage.plansDescription": "Available storage plans and Credits for the current workspace.",
"storage.availablePlansTitle": "Available plans",
"storage.availablePlansDescription": "Upgrade when you need more storage or monthly traffic.",
"storage.availablePlansDescription": "Upgrade when you need more storage and included Credits.",
"storage.availableProductsTitle": "Available products",
"storage.availableProductsDescription": "Choose a monthly plan or add a one-time storage/traffic package.",
"storage.availableProductsDescription": "Choose a monthly or yearly plan. Usage beyond the included storage is billed with Credits.",
"storage.monthlyPlanBadge": "Plan",
"storage.planBadge": "Plan",
"storage.resourcePackageBadge": "Package",
"storage.planBilling": "Billing",
"storage.billingMonthly": "Monthly subscription",
@@ -1310,11 +1327,15 @@
"storage.packageValidity": "Validity",
"storage.packageNoExpiry": "No expiry",
"storage.trafficPolicy": "Traffic policy",
"storage.includedCredits": "Included Credits",
"storage.usageBilledWithCredits": "Usage billed with Credits",
"storage.trafficStopsAtQuota": "Stops at quota",
"storage.trafficOverageEnabled": "Metered overage",
"storage.trafficOveragePerGb": "{{amount}} / GB overage",
"storage.checkout": "Checkout",
"storage.checkoutPlan": "Subscribe",
"storage.checkoutMonthly": "Subscribe monthly",
"storage.checkoutYearly": "Subscribe yearly",
"storage.checkoutPackage": "Buy package",
"storage.managePlan": "Manage plan",
"storage.planAlreadyActive": "Plan already active",
@@ -1324,6 +1345,7 @@
"storage.checkoutRedirectErrorTitle": "Checkout could not start",
"storage.checkoutRedirectBack": "Back to storage",
"storage.priceMonthly": "{{amount}} / month",
"storage.priceYearly": "{{amount}} / year",
"storage.priceForDays": "{{amount}} for {{days}} days",
"storage.redeemTitle": "Redeem gift card",
"storage.redeemDescription": "Enter a gift card to redeem it into Cloud credits.",
+27 -5
View File
@@ -1138,33 +1138,46 @@
"admin.cloudStore.cloudTip.connected": "当前实例已连接 ZPan Cloud,可用于存储套餐交付。",
"admin.cloudStore.cloudTip.notConnected": "请先连接 ZPan Cloud,再依赖存储套餐交付。",
"admin.cloudStore.newPackage": "新建存储计划",
"admin.cloudStore.newCreditPackage": "新建 Credits 包",
"admin.cloudStore.editPackage": "编辑存储计划",
"admin.cloudStore.editCreditPackage": "编辑 Credits 包",
"admin.cloudStore.packageName": "名称",
"admin.cloudStore.planName": "计划名称",
"admin.cloudStore.creditPackageName": "Credits 包",
"admin.cloudStore.description": "描述",
"admin.cloudStore.billingMode": "计费方式",
"admin.cloudStore.billingSubscription": "按月订阅",
"admin.cloudStore.billingSubscription": "订阅",
"admin.cloudStore.billingOneTime": "固定期限包",
"admin.cloudStore.validityDays": "有效天数",
"admin.cloudStore.size": "容量",
"admin.cloudStore.unit": "单位",
"admin.cloudStore.storageQuota": "存储配额",
"admin.cloudStore.trafficQuota": "下载流量配额",
"admin.cloudStore.includedCredits": "包含 Credits",
"admin.cloudStore.creditAmount": "Credits",
"admin.cloudStore.trafficOveragePrice": "超额流量单价",
"admin.cloudStore.quotaOptionalHint": "留空则不包含",
"admin.cloudStore.quotaRequired": "存储配额和下载流量配额至少填写一项。",
"admin.cloudStore.quotaRequired": "必须填写存储配额。",
"admin.cloudStore.validityRequired": "有效天数必须大于 0。",
"admin.cloudStore.prices": "价格",
"admin.cloudStore.usdAmount": "套餐金额(美元)",
"admin.cloudStore.usdMonthlyAmount": "月付价格(美元)",
"admin.cloudStore.usdYearlyAmount": "年付价格(美元)",
"admin.cloudStore.usdTrafficOveragePrice": "超额流量单价 / GB(美元)",
"admin.cloudStore.sortOrder": "排序",
"admin.cloudStore.active": "启用",
"admin.cloudStore.publish": "发布",
"admin.cloudStore.unpublish": "下架",
"admin.cloudStore.deleteTitle": "删除存储计划",
"admin.cloudStore.deleteCreditTitle": "删除 Credits 包",
"admin.cloudStore.deleteConfirm": "确定删除 {{name}} 吗?这会从 ZPan Cloud 移除此计划,用户将无法再购买。已有购买和已交付的配额不会改变。",
"admin.cloudStore.noPackages": "暂无存储计划。",
"admin.cloudStore.noPlans": "暂无存储计划。",
"admin.cloudStore.noCreditPackages": "暂无 Credits 包。",
"admin.cloudStore.planProductsTitle": "订阅套餐",
"admin.cloudStore.planProductsDescription": "套餐在每个计费周期发放存储空间和包含的 Credits。",
"admin.cloudStore.creditProductsTitle": "Credits 包",
"admin.cloudStore.creditProductsDescription": "一次性充值包,独立于订阅套餐销售。",
"admin.cloudStore.packages.filterAll": "全部计划",
"admin.cloudStore.packages.filterActive": "仅启用",
"admin.cloudStore.packages.filterDisabled": "仅停用",
@@ -1277,6 +1290,9 @@
"storage.creditActivityTitle": "积分流水",
"storage.creditActivityDescription": "展示礼品卡兑换、授予、充值和用量扣减带来的积分变动。",
"storage.creditActivityEmpty": "暂无积分流水。",
"storage.creditTopUpTitle": "积分充值",
"storage.creditTopUpAmount": "{{amount}} 积分",
"storage.buyCredits": "购买积分",
"storage.creditTableType": "类型",
"storage.creditTableChange": "变动",
"storage.creditTableStatus": "状态",
@@ -1294,12 +1310,13 @@
"storage.packagesTitle": "存储计划",
"storage.plansTitle": "存储计划",
"storage.packagesDescription": "当前工作空间可购买的存储计划。",
"storage.plansDescription": "当前工作空间可购买的存储和流量计划。",
"storage.plansDescription": "当前工作空间可购买的存储计划和 Credits。",
"storage.availablePlansTitle": "可购买套餐",
"storage.availablePlansDescription": "当你需要更多存储空间或月度流量时,可以升级套餐。",
"storage.availablePlansDescription": "当你需要更多存储空间和包含的 Credits 时,可以升级套餐。",
"storage.availableProductsTitle": "可购买商品",
"storage.availableProductsDescription": "可以选择包月套餐,也可以单独购买存储包或流量包。",
"storage.availableProductsDescription": "可以选择月付或年付套餐,超出包含额度后的用量使用 Credits 结算。",
"storage.monthlyPlanBadge": "套餐",
"storage.planBadge": "套餐",
"storage.resourcePackageBadge": "资源包",
"storage.planBilling": "计费方式",
"storage.billingMonthly": "按月订阅",
@@ -1310,11 +1327,15 @@
"storage.packageValidity": "有效期",
"storage.packageNoExpiry": "长期有效",
"storage.trafficPolicy": "流量策略",
"storage.includedCredits": "包含 Credits",
"storage.usageBilledWithCredits": "用量使用 Credits 结算",
"storage.trafficStopsAtQuota": "超出后停止访问",
"storage.trafficOverageEnabled": "超出后按量计费",
"storage.trafficOveragePerGb": "超出后 {{amount}} / GB",
"storage.checkout": "结账",
"storage.checkoutPlan": "订阅",
"storage.checkoutMonthly": "月付订阅",
"storage.checkoutYearly": "年付订阅",
"storage.checkoutPackage": "购买资源包",
"storage.managePlan": "管理套餐",
"storage.planAlreadyActive": "已有生效套餐",
@@ -1324,6 +1345,7 @@
"storage.checkoutRedirectErrorTitle": "无法发起支付",
"storage.checkoutRedirectBack": "返回存储",
"storage.priceMonthly": "{{amount}} / 月",
"storage.priceYearly": "{{amount}} / 年",
"storage.priceForDays": "{{amount}} / {{days}} 天",
"storage.redeemTitle": "礼品卡兑换",
"storage.redeemDescription": "输入礼品卡,将其兑换为 Cloud 积分。",
+35 -22
View File
@@ -64,12 +64,14 @@ import {
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
listAdminCloudCreditProducts,
listAdminCloudOrders,
listAdminCloudProducts,
listAnnouncements,
listAuthProviders,
listBackgroundJobs,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudGiftCards,
listCloudOrders,
listCloudProducts,
@@ -317,15 +319,14 @@ describe('api', () => {
name: 'Small',
description: '',
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, trafficOveragePriceCents: 2 },
deliverable: { type: 'zpan.plan', storageBytes: 1024, includedCredits: 100 },
},
prices: [
{ currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } },
{
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 500,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '100' },
},
],
active: true,
@@ -343,15 +344,14 @@ describe('api', () => {
name: 'Small',
description: '',
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, trafficOveragePriceCents: 2 },
deliverable: { type: 'zpan.plan', storageBytes: 1024, includedCredits: 100 },
},
prices: [
{ currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } },
{
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 500,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '100' },
},
],
active: true,
@@ -380,8 +380,8 @@ describe('api', () => {
type: 'store_item',
name: 'Small',
description: '',
metadata: { deliverable: { type: 'zpan.extra', storageBytes: 1024, trafficBytes: 0 } },
prices: [{ currency: 'usd', amount: 500 }],
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
active: true,
sortOrder: 0,
}),
@@ -436,8 +436,19 @@ describe('api', () => {
expect(calls[4][0]).toBe('/api/admin/store/orders?limit=100&offset=100')
})
it('calls admin credits product endpoint', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
await listAdminCloudCreditProducts()
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
expect(calls[0][0]).toBe('/api/admin/store/credits/products')
expect(calls[0][1].method).toBe('GET')
})
it('calls user store endpoints', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
.mockResolvedValueOnce(makeResponse({ orderId: 'order-1', url: 'https://cloud.example/checkout' }))
@@ -445,6 +456,7 @@ describe('api', () => {
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
await listCloudProducts()
await listCloudCreditProducts()
await listCloudStoreTargets()
await createCloudCheckout('pkg-1', 'price-usd')
await createCloudBillingPortalSession()
@@ -452,15 +464,16 @@ describe('api', () => {
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
expect(calls[0][0]).toBe('/api/store/packages')
expect(calls[1][0]).toBe('/api/store/targets')
expect(calls[2][0]).toBe('/api/store/checkouts')
expect(JSON.parse(calls[2][1].body as string)).toEqual({
expect(calls[1][0]).toBe('/api/store/credits/products')
expect(calls[2][0]).toBe('/api/store/targets')
expect(calls[3][0]).toBe('/api/store/checkouts')
expect(JSON.parse(calls[3][1].body as string)).toEqual({
packageId: 'pkg-1',
priceId: 'price-usd',
})
expect(calls[3][0]).toBe('/api/store/billing-portal-sessions')
expect(calls[3][1].method).toBe('POST')
expect(calls[4][0]).toBe('/api/store/orders?limit=100&offset=100')
expect(calls[4][0]).toBe('/api/store/billing-portal-sessions')
expect(calls[4][1].method).toBe('POST')
expect(calls[5][0]).toBe('/api/store/orders?limit=100&offset=100')
})
it('calls credit balance, credit activity, redemption, and order action endpoints', async () => {
@@ -567,8 +580,8 @@ describe('api', () => {
type: 'store_item',
name: 'Small',
description: '',
metadata: { deliverable: { type: 'zpan.extra', storageBytes: 1024, trafficBytes: 0 } },
prices: [{ currency: 'usd', amount: 500 }],
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
active: true,
sortOrder: 0,
}),
@@ -580,8 +593,8 @@ describe('api', () => {
type: 'store_item',
name: 'Small',
description: '',
metadata: { deliverable: { type: 'zpan.extra', storageBytes: 1024, trafficBytes: 0 } },
prices: [{ currency: 'usd', amount: 500 }],
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 500 } },
prices: [{ currency: 'usd', amount: 500, metadata: { creditGrantType: 'top_up', creditAmount: '500' } }],
sortOrder: 0,
}),
],
+8
View File
@@ -384,6 +384,10 @@ export function listAdminCloudProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(adminCloudStoreApi.packages.$get())
}
export function listAdminCloudCreditProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(adminCloudStoreApi.credits.products.$get())
}
export function createCloudProduct(data: CloudProductInput) {
return unwrap<CloudProduct>(adminCloudStoreApi.packages.$post({ json: data }))
}
@@ -432,6 +436,10 @@ export function listCloudProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.packages.$get())
}
export function listCloudCreditProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.credits.products.$get())
}
export function listCloudStoreTargets() {
return unwrap<{ items: CloudStoreTarget[]; total: number }>(cloudStoreApi.targets.$get())
}
+8 -2
View File
@@ -9,9 +9,15 @@ export function cloudOrderTrafficBytes(order: CloudOrder) {
}
export function cloudOrderItemStorageBytes(item: CloudOrderItem | undefined) {
return item?.fulfillmentPayload.deliverable.storageBytes ?? 0
return cloudOrderItemDeliverableNumber(item, 'storageBytes')
}
export function cloudOrderItemTrafficBytes(item: CloudOrderItem | undefined) {
return item?.fulfillmentPayload.deliverable.trafficBytes ?? 0
return cloudOrderItemDeliverableNumber(item, 'trafficBytes')
}
function cloudOrderItemDeliverableNumber(item: CloudOrderItem | undefined, key: string) {
const deliverable = item?.fulfillmentPayload.deliverable as Record<string, unknown> | undefined
const value = deliverable?.[key]
return typeof value === 'number' ? value : 0
}
+11
View File
@@ -8,6 +8,10 @@ export function cloudProductTrafficBytes(pkg: CloudProduct) {
return numberDeliverableValue(pkg, 'trafficBytes')
}
export function cloudProductIncludedCredits(pkg: CloudProduct) {
return numberDeliverableValue(pkg, 'includedCredits') || creditAmountFromPriceMetadata(pkg)
}
export function cloudProductValidityDays(pkg: CloudProduct) {
return optionalNumberDeliverableValue(pkg, 'validityDays')
}
@@ -24,3 +28,10 @@ function optionalNumberDeliverableValue(pkg: CloudProduct, key: string) {
const value = pkg.metadata.deliverable[key]
return typeof value === 'number' ? value : undefined
}
function creditAmountFromPriceMetadata(pkg: CloudProduct) {
const value = pkg.prices.find((price) => price.metadata?.creditAmount)?.metadata?.creditAmount
if (!value) return 0
const credits = Number(value)
return Number.isSafeInteger(credits) && credits > 0 ? credits : 0
}
@@ -12,6 +12,7 @@ import {
deleteCloudProduct,
disableCloudGiftCard,
getCloudStoreSettings,
listAdminCloudCreditProducts,
listAdminCloudOrders,
listAdminCloudProducts,
listCloudGiftCards,
@@ -63,6 +64,7 @@ vi.mock('@/lib/api', () => {
disableCloudGiftCard: vi.fn(),
getCloudStoreSettings: vi.fn(),
listAdminCloudOrders: vi.fn(),
listAdminCloudCreditProducts: vi.fn(),
listAdminCloudProducts: vi.fn(),
listCloudGiftCards: vi.fn(),
updateCloudProduct: vi.fn(),
@@ -88,14 +90,47 @@ function quotaPackage(overrides: Partial<CloudProduct> = {}): CloudProduct {
type: 'store_item',
name: '100 GB',
description: 'Extra storage',
metadata: { deliverable: { type: 'zpan.plan', storageBytes: 107374182400, trafficBytes: 0 } },
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 107374182400, includedCredits: 1000 },
},
prices: [
{ currency: 'usd', amount: 999, recurring: { interval: 'month', intervalCount: 1 } },
{
id: 'price-monthly',
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
{
id: 'price-yearly',
currency: 'usd',
amount: 9999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
],
active: true,
sortOrder: 1,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
...overrides,
}
}
function creditPackage(overrides: Partial<CloudProduct> = {}): CloudProduct {
return {
id: 'pkg-credits',
storeId: 'store-1',
type: 'store_item',
name: '5,000 Credits',
description: 'Credit top-up',
metadata: { deliverable: { type: 'zpan.credits', includedCredits: 5000 } },
prices: [
{
id: 'price-credits',
currency: 'usd',
amount: 2999,
metadata: { creditGrantType: 'top_up', creditAmount: '5000' },
},
],
active: true,
@@ -147,7 +182,9 @@ function storeOrder(overrides: Partial<CloudOrder> = {}): CloudOrder {
quantity: 1,
unitAmount: 999,
totalAmount: 999,
fulfillmentPayload: { deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0 } },
fulfillmentPayload: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, includedCredits: 0 },
},
},
],
payments: [],
@@ -187,6 +224,7 @@ describe('AdminCloudStorePage', () => {
beforeEach(() => {
HTMLElement.prototype.scrollIntoView = vi.fn()
vi.mocked(listAdminCloudOrders).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listAdminCloudCreditProducts).mockResolvedValue({ items: [], total: 0 })
})
it('shows the Pro gate when quota store settings are unavailable', async () => {
@@ -211,8 +249,9 @@ describe('AdminCloudStorePage', () => {
fireEvent.change(view.getByLabelText('admin.cloudStore.planName'), { target: { value: '250 GB' } })
fireEvent.change(view.getByLabelText('admin.cloudStore.description'), { target: { value: 'Team storage' } })
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.includedCredits'), { target: { value: '2500' } })
fireEvent.change(view.getByLabelText('admin.cloudStore.usdMonthlyAmount'), { target: { value: '19.99' } })
fireEvent.change(view.getByLabelText('admin.cloudStore.usdYearlyAmount'), { target: { value: '199.99' } })
fireEvent.click(view.getByRole('button', { name: 'common.save' }))
await waitFor(() =>
@@ -224,17 +263,21 @@ describe('AdminCloudStorePage', () => {
deliverable: {
type: 'zpan.plan',
storageBytes: 268435456000,
trafficBytes: 0,
trafficOveragePriceCents: 2,
includedCredits: 2500,
},
},
prices: [
{ currency: 'usd', amount: 1999, recurring: { interval: 'month', intervalCount: 1 } },
{
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 1999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '2500' },
},
{
currency: 'usd',
amount: 19999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '2500' },
},
],
active: true,
@@ -244,15 +287,55 @@ describe('AdminCloudStorePage', () => {
expect(toast.success).toHaveBeenCalledWith('admin.cloudStore.packageSaved')
})
it('creates a traffic-only package with a USD price', async () => {
it('creates a credits package with configured top-up values', async () => {
vi.mocked(getCloudStoreSettings).mockResolvedValue(settings())
vi.mocked(listAdminCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(createCloudProduct).mockResolvedValue(
quotaPackage({
id: 'pkg-traffic',
metadata: { deliverable: { type: 'zpan.extra', storageBytes: 0, trafficBytes: 1099511627776 } },
vi.mocked(listAdminCloudCreditProducts).mockResolvedValue({ items: [creditPackage()], total: 1 })
vi.mocked(createCloudProduct).mockResolvedValue(creditPackage({ id: 'pkg-credits-2' }))
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('button', { name: 'admin.cloudStore.newCreditPackage' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'admin.cloudStore.newCreditPackage' }))
const dialog = await view.findByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.planName'), {
target: { value: '10,000 Credits' },
})
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.description'), {
target: { value: 'Top-up bundle' },
})
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.creditAmount'), { target: { value: '10000' } })
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.usdAmount'), { target: { value: '49.99' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(createCloudProduct).toHaveBeenCalledWith({
type: 'store_item',
name: '10,000 Credits',
description: 'Top-up bundle',
metadata: {
deliverable: {
type: 'zpan.credits',
includedCredits: 10000,
},
},
prices: [
{
currency: 'usd',
amount: 4999,
metadata: { creditGrantType: 'top_up', creditAmount: '10000' },
},
],
active: true,
sortOrder: 0,
}),
)
})
it('creates a yearly-only plan with included credits', async () => {
vi.mocked(getCloudStoreSettings).mockResolvedValue(settings())
vi.mocked(listAdminCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(createCloudProduct).mockResolvedValue(quotaPackage({ id: 'pkg-yearly' }))
const view = renderAdminPage()
@@ -260,34 +343,39 @@ describe('AdminCloudStorePage', () => {
fireEvent.click(view.getByRole('button', { name: 'admin.cloudStore.newPackage' }))
const dialog = await view.findByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.planName'), {
target: { value: '1 TB traffic' },
target: { value: 'Annual Plan' },
})
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.description'), {
target: { value: 'Download traffic' },
target: { value: 'Annual storage' },
})
fireEvent.click(within(dialog).getByRole('combobox', { name: 'admin.cloudStore.billingMode' }))
fireEvent.click(await view.findByRole('option', { name: 'admin.cloudStore.billingOneTime' }))
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.validityDays'), { target: { value: '30' } })
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.trafficQuota'), { target: { value: '1' } })
fireEvent.click(within(dialog).getByLabelText('admin.cloudStore.trafficQuota unit'))
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.storageQuota'), { target: { value: '1' } })
fireEvent.click(within(dialog).getByLabelText('admin.cloudStore.storageQuota 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.includedCredits'), { target: { value: '12000' } })
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.usdMonthlyAmount'), { target: { value: '' } })
fireEvent.change(within(dialog).getByLabelText('admin.cloudStore.usdYearlyAmount'), { target: { value: '499.99' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'common.save' }))
await waitFor(() =>
expect(createCloudProduct).toHaveBeenCalledWith({
type: 'store_item',
name: '1 TB traffic',
description: 'Download traffic',
name: 'Annual Plan',
description: 'Annual storage',
metadata: {
deliverable: {
type: 'zpan.extra',
storageBytes: 0,
trafficBytes: 1099511627776,
validityDays: 30,
type: 'zpan.plan',
storageBytes: 1099511627776,
includedCredits: 12000,
},
},
prices: [{ currency: 'usd', amount: 4999 }],
prices: [
{
currency: 'usd',
amount: 49999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '12000' },
},
],
active: true,
sortOrder: 0,
}),
@@ -301,12 +389,7 @@ describe('AdminCloudStorePage', () => {
quotaPackage({
prices: [
{ currency: 'usd', amount: 999, recurring: { interval: 'month', intervalCount: 1 } },
{
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
},
{ currency: 'usd', amount: 9999, recurring: { interval: 'year', intervalCount: 1 } },
],
}),
],
@@ -315,11 +398,11 @@ describe('AdminCloudStorePage', () => {
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('table')).toBeTruthy())
await waitFor(() => expect(view.getByText('admin.cloudStore.planProductsTitle')).toBeTruthy())
expect(view.getAllByRole('table')).toHaveLength(2)
expect(view.getByRole('columnheader', { name: 'admin.cloudStore.planName' })).toBeTruthy()
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.getAllByRole('columnheader', { name: 'admin.cloudStore.prices' })).toHaveLength(2)
expect(view.getByText('$9.99/mo · $99.99/yr')).toBeTruthy()
expect(view.queryByRole('button', { name: 'admin.cloudStore.sync' })).toBeNull()
expect(view.queryByText('admin.cloudStore.lastSync')).toBeNull()
expect(view.queryByText('admin.cloudStore.lastOrder')).toBeNull()
@@ -372,17 +455,21 @@ describe('AdminCloudStorePage', () => {
deliverable: {
type: 'zpan.plan',
storageBytes: 107374182400,
trafficBytes: 0,
trafficOveragePriceCents: 2,
includedCredits: 1000,
},
},
prices: [
{ currency: 'usd', amount: 999, recurring: { interval: 'month', intervalCount: 1 } },
{
currency: 'usd',
amount: 2,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
{
currency: 'usd',
amount: 9999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
],
sortOrder: 1,
@@ -396,21 +483,23 @@ describe('AdminCloudStorePage', () => {
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' },
amount: 1299,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1500' },
},
{
currency: 'cny',
amount: 22,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
currency: 'usd',
amount: 12999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1500' },
},
{ currency: 'cny', amount: 9800, recurring: { interval: 'month', intervalCount: 1 } },
],
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 107374182400, includedCredits: 1500 },
},
}),
],
total: 1,
@@ -423,10 +512,10 @@ describe('AdminCloudStorePage', () => {
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).getByLabelText('admin.cloudStore.usdMonthlyAmount')).toHaveProperty('value', '12.99')
expect(within(dialog).getByLabelText('admin.cloudStore.usdYearlyAmount')).toHaveProperty('value', '129.99')
expect(within(dialog).getByLabelText('admin.cloudStore.includedCredits')).toHaveProperty('value', '1500')
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' }))
@@ -440,17 +529,21 @@ describe('AdminCloudStorePage', () => {
deliverable: {
type: 'zpan.plan',
storageBytes: 107374182400,
trafficBytes: 0,
trafficOveragePriceCents: 3,
includedCredits: 1500,
},
},
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' },
amount: 1299,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1500' },
},
{
currency: 'usd',
amount: 12999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1500' },
},
],
sortOrder: 1,
@@ -660,7 +753,9 @@ describe('AdminCloudStorePage', () => {
quantity: 1,
unitAmount: 999,
totalAmount: 999,
fulfillmentPayload: { deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 2048 } },
fulfillmentPayload: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 2048, includedCredits: 0 },
},
},
],
}),
@@ -10,6 +10,7 @@ import { StorageOrdersTable } from '@/components/admin/cloud-orders-table'
import {
GiftCardsTab,
PackagesTab,
useCreditPackageEditor,
useGiftCardActions,
usePackageEditor,
} from '@/components/admin/cloud-store-admin-actions'
@@ -24,6 +25,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import {
ApiError,
getCloudStoreSettings,
listAdminCloudCreditProducts,
listAdminCloudOrders,
listAdminCloudProducts,
listCloudGiftCards,
@@ -49,6 +51,7 @@ function useAdminCloudStoreState() {
const [giftCardStatus, setGiftCardStatus] = useState<GiftCardStatus | 'all'>('all')
const query = useQuery({ queryKey: ['admin', 'cloud-store'], queryFn: loadAdminCloudStore })
const packageEditor = usePackageEditor()
const creditPackageEditor = useCreditPackageEditor()
const giftCardActions = useGiftCardActions()
const giftCardsQuery = useQuery({
queryKey: ['admin', 'cloud-store', 'gift-cards', giftCardStatus],
@@ -70,6 +73,7 @@ function useAdminCloudStoreState() {
giftCardsQuery,
giftCardStatus,
data,
creditPackageEditor,
ordersQuery,
packageEditor,
query,
@@ -182,7 +186,13 @@ function AdminTabs({ state }: { state: AdminCloudStoreReadyState }) {
<div className="space-y-4">
<CloudStoreTabBar activeTab={state.activeTab} onChange={state.setActiveTab} />
{state.activeTab === 'packages' && (
<PackagesTab available={state.data.available} packages={state.data.packages} editor={state.packageEditor} />
<PackagesTab
available={state.data.available}
packages={state.data.packages}
creditPackages={state.data.creditPackages}
editor={state.packageEditor}
creditEditor={state.creditPackageEditor}
/>
)}
{state.activeTab === 'codes' && <GiftCardsPanel state={state} />}
{state.activeTab === 'orders' && <OrdersPanel state={state} />}
@@ -217,13 +227,24 @@ async function loadAdminCloudStore(): Promise<{
enabled: boolean
settings: CloudStoreSettings | null
packages: CloudProduct[]
creditPackages: CloudProduct[]
}> {
try {
const [settings, packages] = await Promise.all([getCloudStoreSettings(), listAdminCloudProducts()])
return { available: true, enabled: settings?.enabled ?? false, settings, packages: packages.items }
const [settings, packages, creditPackages] = await Promise.all([
getCloudStoreSettings(),
listAdminCloudProducts(),
listAdminCloudCreditProducts(),
])
return {
available: true,
enabled: settings?.enabled ?? false,
settings,
packages: packages.items,
creditPackages: creditPackages.items,
}
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
return { available: false, enabled: false, settings: null, packages: [] }
return { available: false, enabled: false, settings: null, packages: [], creditPackages: [] }
}
throw err
}
+91 -23
View File
@@ -9,6 +9,7 @@ import {
getCloudCredits,
getUserQuota,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
redeemCloudGiftCard,
@@ -91,6 +92,7 @@ vi.mock('@/lib/api', () => {
getUserQuota: vi.fn(),
getCloudCredits: vi.fn(),
redeemCloudGiftCard: vi.fn(),
listCloudCreditProducts: vi.fn(),
listCloudProducts: vi.fn(),
listCloudOrders: vi.fn(),
listCloudCreditLedgerEntries: vi.fn(),
@@ -119,7 +121,9 @@ function order(overrides: Partial<CloudOrder> = {}): CloudOrder {
quantity: 1,
unitAmount: 999,
totalAmount: 999,
fulfillmentPayload: { deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0 } },
fulfillmentPayload: {
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, includedCredits: 0 },
},
},
],
payments: [],
@@ -140,8 +144,18 @@ function quotaPackage(): CloudProduct {
type: 'store_item',
name: '100 GB',
description: 'Extra storage',
metadata: { deliverable: { type: 'zpan.extra', storageBytes: 107374182400, trafficBytes: 0 } },
prices: [{ id: 'price-usd', currency: 'usd', amount: 999 }],
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 107374182400, includedCredits: 1000 },
},
prices: [
{
id: 'price-usd',
currency: 'usd',
amount: 999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
],
active: true,
sortOrder: 1,
createdAt: '2026-05-05T00:00:00.000Z',
@@ -154,24 +168,53 @@ function subscriptionPackage(): CloudProduct {
...quotaPackage(),
id: 'pkg-subscription',
name: 'Team Plan',
metadata: { deliverable: { type: 'zpan.plan', storageBytes: 107374182400, trafficBytes: 21474836480 } },
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 107374182400, includedCredits: 1000 },
},
prices: [
{
id: 'price-subscription-usd',
currency: 'usd',
amount: 999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
{
id: 'price-subscription-yearly-usd',
currency: 'usd',
amount: 25,
recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' },
metadata: { usageResource: 'traffic_egress' },
amount: 9999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '1000' },
},
],
}
}
function creditPackage(): CloudProduct {
return {
id: 'pkg-credits',
storeId: 'store-1',
type: 'store_item',
name: '5,000 Credits',
description: 'Credit top-up',
metadata: {
deliverable: { type: 'zpan.credits', includedCredits: 5000 },
},
prices: [
{
id: 'price-credits-usd',
currency: 'usd',
amount: 2999,
metadata: { creditGrantType: 'top_up', creditAmount: '5000' },
},
],
active: true,
sortOrder: 1,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
}
}
function renderStoragePage(queryClient: QueryClient) {
return render(
<QueryClientProvider client={queryClient}>
@@ -208,6 +251,7 @@ describe('StoragePage', () => {
trafficExtraNames: [],
})
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 0 })
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
})
@@ -351,8 +395,8 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ }))
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutMonthly/ }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
expect(toast.info).not.toHaveBeenCalled()
@@ -370,9 +414,10 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByText('storage.monthlyPlanBadge')).toBeTruthy())
await waitFor(() => expect(view.getByText('storage.planBadge')).toBeTruthy())
expect(view.getByText('storage.trafficPolicy')).toBeTruthy()
expect(view.getByText(/storage\.trafficOveragePerGb/)).toBeTruthy()
expect(view.getByText('storage.includedCredits')).toBeTruthy()
expect(view.getByText('storage.usageBilledWithCredits')).toBeTruthy()
})
it('uses the USD product price for checkout regardless of locale', async () => {
@@ -388,8 +433,8 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ }))
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutMonthly/ }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
})
@@ -406,8 +451,8 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ }))
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutMonthly/ }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
})
@@ -424,8 +469,8 @@ describe('StoragePage', () => {
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutPackage/ }))
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: /storage.checkoutMonthly/ }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
expect(view.getByText('storage.checkoutPending')).toBeTruthy()
@@ -507,7 +552,7 @@ describe('StoragePage', () => {
await waitFor(() => expect(view.getByRole('button', { name: 'storage.managePlan' })).toBeTruthy())
expect(view.getByText('Team Plan')).toBeTruthy()
expect(view.getByRole('button', { name: 'storage.managePlan' })).toBeTruthy()
expect(view.queryByRole('button', { name: /storage.checkoutPlan|storage.checkoutPackage/ })).toBeNull()
expect(view.queryByRole('button', { name: /storage.checkoutMonthly|storage.checkoutYearly/ })).toBeNull()
expect(openNewTab).not.toHaveBeenCalled()
})
@@ -533,7 +578,7 @@ describe('StoragePage', () => {
expect(vi.mocked(listCloudOrders)).toHaveBeenCalledWith()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => expect(view.queryByText('org-2')).toBeNull())
fireEvent.click(await view.findByRole('button', { name: /storage.checkoutPackage/ }))
fireEvent.click(await view.findByRole('button', { name: /storage.checkoutMonthly/ }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=pkg-1&priceId=price-usd')
})
@@ -552,8 +597,8 @@ describe('StoragePage', () => {
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.queryByLabelText('storage.giftCardCode')).toBeNull())
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy())
const checkoutButton = view.getByRole('button', { name: /storage.checkoutPackage/ }) as HTMLButtonElement
await waitFor(() => expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy())
const checkoutButton = view.getByRole('button', { name: /storage.checkoutMonthly/ }) as HTMLButtonElement
expect(checkoutButton.disabled).toBe(true)
fireEvent.click(checkoutButton)
@@ -576,8 +621,8 @@ describe('StoragePage', () => {
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
await waitFor(() => expect(view.getByText('100 GB')).toBeTruthy())
expect(view.getByText('storage.availableProductsTitle')).toBeTruthy()
expect(view.getByText('storage.packageStorageQuota')).toBeTruthy()
expect(view.getByRole('button', { name: /storage.checkoutPackage/ })).toBeTruthy()
expect(view.getByText('storage.baseStorageQuota')).toBeTruthy()
expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy()
expect(view.queryByRole('button', { name: 'storage.redeemTitle' })).toBeNull()
})
@@ -606,6 +651,29 @@ describe('StoragePage', () => {
await waitFor(() => expect(view.getByText('1,250')).toBeTruthy())
})
it('starts checkout from a credits top-up product', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [creditPackage()], total: 1 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
expect(await view.findByText('storage.creditTopUpTitle')).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: 'storage.buyCredits' }))
expect(openNewTab).toHaveBeenCalledWith(
'/store/checkout?action=checkout&packageId=pkg-credits&priceId=price-credits-usd',
)
})
it('opens credit activity dialog', async () => {
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
+10
View File
@@ -26,6 +26,7 @@ import {
getCloudCredits,
getUserQuota,
listCloudCreditLedgerEntries,
listCloudCreditProducts,
listCloudOrders,
listCloudProducts,
redeemCloudGiftCard,
@@ -67,6 +68,12 @@ export function StoragePage() {
enabled: cloudStoreQuery.isSuccess && !!targetOrgId,
retry: false,
})
const creditProductsQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'products'],
queryFn: listCloudCreditProducts,
enabled: cloudStoreQuery.isSuccess,
retry: false,
})
const creditLedgerQuery = useQuery({
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
queryFn: listCloudCreditLedgerEntries,
@@ -174,10 +181,13 @@ export function StoragePage() {
<div className="flex flex-wrap justify-end gap-2">
<CreditBalanceButton
credits={credits}
products={creditProductsQuery.data?.items ?? []}
entries={creditLedgerQuery.data?.items ?? []}
loading={creditLedgerQuery.isLoading}
onRedeem={(code) => redeemMutation.mutate(code)}
onCheckout={startCheckout}
isRedeeming={redeemMutation.isPending}
checkoutDisabled={!targetOrgId}
/>
<StorageOrderHistoryDialog
orders={currentOrders}