mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat(store): add credits package management
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user