update subscription module

This commit is contained in:
root
2026-05-25 04:58:56 -04:00
parent 1606ea0053
commit 8d0b7a5ceb
17 changed files with 4394 additions and 157 deletions
File diff suppressed because it is too large Load Diff
+71
View File
@@ -417,3 +417,74 @@ export function upsertPricingConfig(plan: string, billingPeriod: string, amount:
create: { id: `prc_${plan.toLowerCase()}_${billingPeriod.toLowerCase()}`, plan, billingPeriod, amount, updatedBy },
})
}
export function listPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({
data: {
plan: data.plan,
label: data.label,
sortOrder: data.sortOrder ?? 0,
},
})
}
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({
where: { id },
data,
})
}
export function deletePlanFeature(id: string) {
return prisma.planFeature.delete({ where: { id } })
}
// ─── Pricing promotions ────────────────────────────────────────────────────
export function listPromotions() {
return (prisma as any).pricingPromotion.findMany({ orderBy: { createdAt: 'desc' } })
}
export function createPromotion(data: {
code: string; name: string; description?: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses?: number | null; validFrom: string; validUntil?: string | null
isActive: boolean; createdBy?: string | null
}) {
return (prisma as any).pricingPromotion.create({
data: {
...data,
validFrom: new Date(data.validFrom),
validUntil: data.validUntil ? new Date(data.validUntil) : null,
},
})
}
export function updatePromotion(id: string, data: Partial<{
code: string; name: string; description: string | null
discountType: string; discountValue: number
plans: string[]; periods: string[]
maxUses: number | null; validFrom: string; validUntil: string | null
isActive: boolean
}>) {
const { validFrom, validUntil, ...rest } = data
return (prisma as any).pricingPromotion.update({
where: { id },
data: {
...rest,
...(validFrom ? { validFrom: new Date(validFrom) } : {}),
...(validUntil !== undefined ? { validUntil: validUntil ? new Date(validUntil) : null } : {}),
},
})
}
export function deletePromotion(id: string) {
return (prisma as any).pricingPromotion.delete({ where: { id } })
}
+62 -1
View File
@@ -11,7 +11,8 @@ import {
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
homepageUpdateSchema, idParamSchema, companyIdParamSchema, invoiceIdParamSchema,
pricingUpdateSchema,
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
} from './admin.schemas'
import { z } from 'zod'
@@ -235,6 +236,66 @@ router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (r
} catch (err) { next(err) }
})
router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (_req, res, next) => {
try {
ok(res, await service.listPlanFeatures())
} catch (err) { next(err) }
})
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const data = parseBody(planFeatureCreateSchema, req)
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
const data = parseBody(planFeatureUpdateSchema, req)
ok(res, await service.updatePlanFeature(featureId, data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Promotions ────────────────────────────────────────────────────────────
router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
ok(res, await service.listPromotions())
} catch (err) { next(err) }
})
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const data = parseBody(promotionCreateSchema, req)
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
const data = parseBody(promotionUpdateSchema, req)
ok(res, await service.updatePromotion(promotionId, data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
await service.deletePromotion(promotionId, req.admin.id, req.ip)
ok(res, { success: true })
} catch (err) { next(err) }
})
// ─── Subscription admin overrides ─────────────────────────────
router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
@@ -182,3 +182,30 @@ export const pricingUpdateSchema = z.object({
amount: z.number().int().positive(),
})).min(1),
})
const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0),
})
export const planFeatureCreateSchema = planFeatureSchema
export const planFeatureUpdateSchema = planFeatureSchema.partial()
export const promotionCreateSchema = z.object({
code: z.string().min(2).max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(),
validUntil: z.string().datetime().nullable().optional(),
isActive: z.boolean().default(true),
})
export const promotionUpdateSchema = promotionCreateSchema.partial()
export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) })
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
@@ -315,3 +315,78 @@ export async function updatePricingConfigs(entries: { plan: string; billingPerio
})
return results
}
export function listPlanFeatures() {
return repo.listPlanFeatures()
}
export async function createPlanFeature(data: Parameters<typeof repo.createPlanFeature>[0], adminId: string, ip?: string) {
const feature = await repo.createPlanFeature(data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'CREATE',
resource: 'PlanFeature',
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function updatePlanFeature(id: string, data: Parameters<typeof repo.updatePlanFeature>[1], adminId: string, ip?: string) {
const feature = await repo.updatePlanFeature(id, data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PlanFeature',
resourceId: id,
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function deletePlanFeature(id: string, adminId: string, ip?: string) {
await repo.deletePlanFeature(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE',
resource: 'PlanFeature',
resourceId: id,
ipAddress: ip,
userAgent: undefined,
})
}
// ─── Promotions ────────────────────────────────────────────────────────────
export function listPromotions() {
return repo.listPromotions()
}
export async function createPromotion(data: Parameters<typeof repo.createPromotion>[0], adminId: string, ip?: string) {
const promo = await repo.createPromotion({ ...data, createdBy: adminId })
await repo.createAuditLog({
adminUserId: adminId, action: 'CREATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function updatePromotion(id: string, data: Parameters<typeof repo.updatePromotion>[1], adminId: string, ip?: string) {
const promo = await repo.updatePromotion(id, data)
await repo.createAuditLog({
adminUserId: adminId, action: 'UPDATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function deletePromotion(id: string, adminId: string, ip?: string) {
await repo.deletePromotion(id)
await repo.createAuditLog({
adminUserId: adminId, action: 'DELETE', resource: 'PricingPromotion',
entityId: id, ipAddress: ip, userAgent: undefined,
})
}
+12
View File
@@ -1,3 +1,4 @@
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { Router } from 'express'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
@@ -16,6 +17,17 @@ router.get('/platform/homepage', async (_req, res, next) => {
} catch (err) { next(err) }
})
router.get('/platform/pricing', async (_req, res, next) => {
try {
ok(res, await service.getPlatformPricing())
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.json({ data: { prices: PLAN_PRICES, planFeatures: PLAN_FEATURES } })
}
next(err)
}
})
router.get('/:slug/brand', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
+28
View File
@@ -1,3 +1,4 @@
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
@@ -5,6 +6,7 @@ import { applyAdditionalDriversToReservation } from '../../services/additionalDr
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './site.repo'
@@ -14,6 +16,32 @@ export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
}
export async function getPlatformPricing() {
const [configs, features] = await Promise.all([
prisma.pricingConfig.findMany(),
prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
])
const prices = configs.length === 0
? PLAN_PRICES
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc, config) => {
if (!acc[config.plan]) acc[config.plan] = {}
acc[config.plan]![config.billingPeriod] = { MAD: config.amount }
return acc
}, {})
const planFeatures = Object.fromEntries(
Object.entries(PLAN_FEATURES).map(([plan, fallback]) => {
const labels = features.filter((feature) => feature.plan === plan).map((feature) => feature.label)
return [plan, labels.length > 0 ? labels : fallback]
}),
)
return { prices, planFeatures }
}
export async function getBrand(slug: string) {
const company = await repo.findCompanyBySlug(slug)
return presentBrand(company)
@@ -2,15 +2,24 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
type Billing = 'monthly' | 'annual'
const PRICES: Record<string, { monthly: number; annual: number }> = {
STARTER: { monthly: 299, annual: 2990 },
GROWTH: { monthly: 399, annual: 3990 },
PRO: { monthly: 499, annual: 4990 },
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
type PlanFeatureMap = Record<string, string[]>
type PlatformPricing = {
prices: PricingMatrix
planFeatures: PlanFeatureMap
}
export const DEFAULT_PRICING: PlatformPricing = {
prices: PLAN_PRICES,
planFeatures: PLAN_FEATURES,
}
const PLANS = [
@@ -18,38 +27,18 @@ const PLANS = [
key: 'STARTER',
name: 'Starter',
tagline: 'Launch your fleet online',
features: [
'Up to 10 vehicles',
'1 user account',
'Basic analytics',
'Marketplace listing',
],
highlight: false,
},
{
key: 'GROWTH',
name: 'Growth',
tagline: 'Scale with confidence',
features: [
'Up to 50 vehicles',
'5 user accounts',
'Full analytics',
'Priority marketplace placement',
'Custom branding',
],
highlight: true,
},
{
key: 'PRO',
name: 'Pro',
tagline: 'Enterprise-grade power',
features: [
'Unlimited vehicles',
'Unlimited user accounts',
'Advanced reports',
'API access',
'Dedicated support',
],
highlight: false,
},
]
@@ -93,20 +82,32 @@ const copy = {
},
} as const
function annualSavingsPct(plan: string): number {
const { monthly, annual } = PRICES[plan]
const wouldPay = monthly * 12
return Math.round(((wouldPay - annual) / wouldPay) * 100)
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
}
export default function PricingClient() {
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
const annual = getPlanPrice(prices, plan, 'ANNUAL')
const wouldPay = monthly * 12
if (wouldPay <= 0) return 0
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
}
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
const [billing, setBilling] = useState<Billing>('monthly')
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const savings = annualSavingsPct('STARTER')
useEffect(() => {
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
.then(setPricing)
}, [])
const savings = annualSavingsPct(pricing.prices, 'STARTER')
return (
<div className="space-y-10">
@@ -141,10 +142,12 @@ export default function PricingClient() {
{/* Plan cards */}
<div className="grid gap-6 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PRICES[plan.key]
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
const displayPrice = billing === 'annual'
? Math.round(price.annual / 12)
: price.monthly
? Math.round(annualPrice / 12)
: monthlyPrice
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
return (
<div
@@ -177,13 +180,13 @@ export default function PricingClient() {
</div>
{billing === 'annual' && (
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.billedAs} {price.annual} MAD {dict.yearly}
{dict.billedAs} {annualPrice} MAD {dict.yearly}
</p>
)}
</div>
<ul className="mt-8 flex-1 space-y-3">
{plan.features.map((f) => (
{features.map((f) => (
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
<svg
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
@@ -42,7 +42,7 @@ const copy = {
},
} as const
export default function PricingPageContent() {
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
const { language } = useMarketplacePreferences()
const dict = copy[language]
@@ -56,7 +56,7 @@ export default function PricingPageContent() {
</div>
<div className="mt-16">
<PricingClient />
<PricingClient initialPricing={initialPricing} />
</div>
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
@@ -1,4 +1,6 @@
import type { Metadata } from 'next'
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { marketplaceFetchOrDefault } from '@/lib/api'
import PricingPageContent from './PricingPageContent'
export const metadata: Metadata = {
@@ -6,6 +8,12 @@ export const metadata: Metadata = {
description: 'Simple, transparent pricing for every fleet size.',
}
export default function PricingPage() {
return <PricingPageContent />
const DEFAULT_PRICING = {
prices: PLAN_PRICES,
planFeatures: PLAN_FEATURES,
}
export default async function PricingPage() {
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
return <PricingPageContent initialPricing={initialPricing} />
}