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
+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)