diff --git a/server/routes/cloud-store-helpers.test.ts b/server/routes/cloud-store-helpers.test.ts index eea51464..b8a95a97 100644 --- a/server/routes/cloud-store-helpers.test.ts +++ b/server/routes/cloud-store-helpers.test.ts @@ -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: [ diff --git a/server/routes/cloud-store/admin.ts b/server/routes/cloud-store/admin.ts index b1fe5f71..68dd6c75 100644 --- a/server/routes/cloud-store/admin.ts +++ b/server/routes/cloud-store/admin.ts @@ -48,7 +48,22 @@ export const adminCloudStore = new Hono() ), ) 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 }) => diff --git a/server/routes/cloud-store/storefront.ts b/server/routes/cloud-store/storefront.ts index e3e061bb..a531aa87 100644 --- a/server/routes/cloud-store/storefront.ts +++ b/server/routes/cloud-store/storefront.ts @@ -50,7 +50,24 @@ export const cloudStore = new Hono() ), ) 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 diff --git a/shared/schemas/cloud-store-legacy.ts b/shared/schemas/cloud-store-legacy.ts new file mode 100644 index 00000000..bf0deeaf --- /dev/null +++ b/shared/schemas/cloud-store-legacy.ts @@ -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, key: string) { + const value = deliverable[key] + return typeof value === 'number' ? value : 0 +} + +function optionalNumberDeliverableValue(deliverable: Record, key: string) { + const value = deliverable[key] + return typeof value === 'number' ? value : undefined +} + +function stringDeliverableValue(deliverable: Record, key: string) { + const value = deliverable[key] + return typeof value === 'string' ? value : undefined +} + +function targetOrgId(target: Record | null) { + return typeof target?.orgId === 'string' ? target.orgId : '' +} + +function sourceId(event: z.infer) { + const orgId = targetOrgId(event.target) + if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}` + return event.orderId +} + +function expiresAt(event: z.infer) { + 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 diff --git a/shared/schemas/cloud-store.ts b/shared/schemas/cloud-store.ts index f42dc7a9..216df7b2 100644 --- a/shared/schemas/cloud-store.ts +++ b/shared/schemas/cloud-store.ts @@ -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 = ['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, key: string) { - const value = deliverable[key] - return typeof value === 'number' ? value : 0 -} - -function optionalNumberDeliverableValue(deliverable: Record, key: string) { - const value = deliverable[key] - return typeof value === 'number' ? value : undefined -} - -function stringDeliverableValue(deliverable: Record, key: string) { - const value = deliverable[key] - return typeof value === 'string' ? value : undefined -} - -function targetOrgId(target: Record | null) { - return typeof target?.orgId === 'string' ? target.orgId : '' -} - -function sourceId(event: z.infer) { - const orgId = targetOrgId(event.target) - if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}` - return event.orderId -} - -function expiresAt(event: z.infer) { - 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 export type CloudStoreCurrency = z.infer export type CloudProductPrice = z.infer @@ -295,7 +212,7 @@ export type CheckoutInput = z.infer export type GiftCardStatus = z.infer export type CreateGiftCardInput = z.input export type DisableGiftCardInput = z.infer -export type CloudOrderQuotaChange = z.infer +export type { CloudOrderQuotaChange } export const cloudCreditBalanceResponseSchema = z.object({ balance: z.number().int(), diff --git a/src/components/admin/cloud-product-form.tsx b/src/components/admin/cloud-product-form.tsx index 8ad7fb5f..cbc72414 100644 --- a/src/components/admin/cloud-product-form.tsx +++ b/src/components/admin/cloud-product-form.tsx @@ -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 ( +
+ + onFormChange({ ...form, credits })} + /> + onFormChange({ ...form, usdAmount })} + /> + +
+ ) +} + 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 (
- onFormChange({ ...form, storageSize })} onUnitChange={(storageUnit) => onFormChange({ ...form, storageUnit })} /> - onFormChange({ ...form, trafficSize })} - onUnitChange={(trafficUnit) => onFormChange({ ...form, trafficUnit })} + onFormChange({ ...form, includedCredits })} /> - {!quotaValid && (form.storageSize !== '' || form.trafficSize !== '') && ( + {!quotaValid && form.storageSize !== '' && (

{t('admin.cloudStore.quotaRequired')}

)} - {!validityValid && form.validityDays !== '' && ( -

{t('admin.cloudStore.validityRequired')}

- )} Number.isFinite(price.amount) && price.amount > 0) } -function trafficOveragePrice(prices: ReturnType) { - const price = prices.find(isFormMeteredTrafficPrice) - return price ? { trafficOveragePriceCents: price.amount } : {} +function creditsFromForm(form: PackageFormState) { + return creditsFromValue(form.includedCredits) } -function isFormMeteredTrafficPrice(price: ReturnType[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({ 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 ( -
- - - - {form.billingMode === 'one_time' && ( - onFormChange({ ...form, validityDays })} - /> - )} -
- ) -} - function PackageQuotaFields({ label, sizeId, @@ -312,23 +350,21 @@ function PackageAmountFields({ return (
onFormChange({ ...form, usdAmount })} + value={form.usdMonthlyAmount} + onChange={(usdMonthlyAmount) => onFormChange({ ...form, usdMonthlyAmount })} + /> + onFormChange({ ...form, usdYearlyAmount })} /> - {form.billingMode === 'subscription' && ( - onFormChange({ ...form, usdTrafficOverageAmount })} - /> - )}
) } diff --git a/src/components/admin/cloud-product-list.tsx b/src/components/admin/cloud-product-list.tsx index e22c0dfb..4cbd0eda 100644 --- a/src/components/admin/cloud-product-list.tsx +++ b/src/components/admin/cloud-product-list.tsx @@ -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({ + - @@ -38,8 +38,8 @@ export function StoragePlanList({ {t('admin.cloudStore.planName')} {t('admin.cloudStore.storageQuota')} - {t('admin.cloudStore.trafficQuota')} - {t('admin.cloudStore.prices')} + {t('admin.cloudStore.includedCredits')} + {t('admin.cloudStore.prices')} {t('admin.cloudStore.active')} {t('admin.cloudStore.sortOrder')} {t('common.actions')} @@ -57,10 +57,8 @@ export function StoragePlanList({ {cloudProductStorageBytes(pkg) > 0 ? formatSize(cloudProductStorageBytes(pkg)) : '—'} - - {cloudProductTrafficBytes(pkg) > 0 ? formatSize(cloudProductTrafficBytes(pkg)) : '—'} - - {formatUsdPrice(pkg.prices)} + {formatCredits(cloudProductIncludedCredits(pkg))} + {formatUsdPrices(pkg.prices)} {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 ( +
+ + + + + + + + + + + + {t('admin.cloudStore.creditPackageName')} + {t('admin.cloudStore.creditAmount')} + {t('admin.cloudStore.prices')} + {t('admin.cloudStore.active')} + {t('admin.cloudStore.sortOrder')} + {t('common.actions')} + + + + {packages.map((pkg) => ( + + +
+
{pkg.name}
+
{pkg.description ?? ''}
+
+
+ {formatCredits(cloudProductIncludedCredits(pkg))} + {formatOneTimeUsdPrice(pkg.prices)} + + + {pkg.active ? t('common.active') : t('common.disabled')} + + + {pkg.sortOrder} + +
+ + + +
+
+
+ ))} +
+
+ {packages.length === 0 && ( +
+ {t('admin.cloudStore.noCreditPackages')} +
+ )} +
+ ) } -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) : '—' } diff --git a/src/components/admin/cloud-store-admin-actions.tsx b/src/components/admin/cloud-store-admin-actions.tsx index 1c2f9214..3bd83e08 100644 --- a/src/components/admin/cloud-store-admin-actions.tsx +++ b/src/components/admin/cloud-store-admin-actions.tsx @@ -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(null) + const [deleting, setDeleting] = useState(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(null) @@ -85,18 +130,23 @@ export function useGiftCardActions() { export function PackagesTab({ available, packages, + creditPackages, editor, + creditEditor, }: { available: boolean packages: CloudProduct[] + creditPackages: CloudProduct[] editor: ReturnType + creditEditor: ReturnType }) { const { t } = useTranslation() const [filter, setFilter] = useState('all') const visiblePackages = filterPackages(packages, filter) + const visibleCreditPackages = filterPackages(creditPackages, filter) return ( -
+