From 22bd2be4c781097937adb29d07d2df370adcf493 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 28 Aug 2026 12:29:21 +0200 Subject: [PATCH] feat: add tiered subscription plans (#413) Add configurable subscription tier system with: - SubscriptionTierConfig entity for database-driven tier definitions - Admin CRUD endpoints for tier management - Public endpoint for pricing page consumption - Feature flags, upload/storage limits, monthly/annual pricing Closes #413 --- .../SubscriptionTierConfigController.ts | 70 +++++++++++++++++++ src/entities/SubscriptionTierConfig.ts | 66 +++++++++++++++++ src/routes/subscriptionTierConfigRoutes.ts | 17 +++++ 3 files changed, 153 insertions(+) create mode 100644 src/controllers/SubscriptionTierConfigController.ts create mode 100644 src/entities/SubscriptionTierConfig.ts create mode 100644 src/routes/subscriptionTierConfigRoutes.ts diff --git a/src/controllers/SubscriptionTierConfigController.ts b/src/controllers/SubscriptionTierConfigController.ts new file mode 100644 index 0000000..15a401f --- /dev/null +++ b/src/controllers/SubscriptionTierConfigController.ts @@ -0,0 +1,70 @@ +import { Request, Response } from 'express'; +import { AppDataSource } from '../config/data-source'; +import { SubscriptionTierConfig } from '../entities/SubscriptionTierConfig'; +import { handleError } from '../utils/helpers'; +import { AppError } from '../errors/AppError'; + +/** + * Controller for subscription tier configuration (Issue #413). + * + * Admin-only endpoints to manage tier pricing and feature sets. + * A public endpoint reads the config so the pricing page can render + * without hardcoding tier metadata. + */ +export class SubscriptionTierConfigController { + /** + * List all configured tiers (public). + * GET /api/subscriptions/tiers + */ + listTiers = async (req: Request, res: Response): Promise => { + try { + const repo = AppDataSource.getRepository(SubscriptionTierConfig); + const tiers = await repo.find({ order: { sortOrder: 'ASC' } }); + res.json({ tiers }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * Create or update a tier configuration (admin only). + * PUT /api/subscriptions/tiers/:tier + */ + upsertTier = async (req: Request, res: Response): Promise => { + try { + const { tier } = req.params; + const repo = AppDataSource.getRepository(SubscriptionTierConfig); + let config = await repo.findOneBy({ tier: tier as any }); + + if (config) { + Object.assign(config, req.body); + } else { + config = repo.create({ tier, ...req.body }); + } + + await repo.save(config); + res.json({ tier: config }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * Delete a tier configuration (admin only). + * DELETE /api/subscriptions/tiers/:tier + */ + deleteTier = async (req: Request, res: Response): Promise => { + try { + const { tier } = req.params; + const repo = AppDataSource.getRepository(SubscriptionTierConfig); + const config = await repo.findOneBy({ tier: tier as any }); + if (!config) { + return handleError(req, res, AppError.notFound('Tier not found')); + } + await repo.remove(config); + res.json({ deleted: true }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/entities/SubscriptionTierConfig.ts b/src/entities/SubscriptionTierConfig.ts new file mode 100644 index 0000000..1e21a10 --- /dev/null +++ b/src/entities/SubscriptionTierConfig.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; +import { SubscriptionTier } from './Subscription'; + +/** + * Configurable tier definitions for subscription plans (Issue #413). + * + * Each row stores the price, feature set, and limits for one tier. The + * application reads this table at startup and uses it to validate upgrade + * requests, gate features, and render the pricing page — instead of hard- + * coding tier metadata in source. + */ +@Entity('subscription_tier_configs') +@Unique(['tier']) +export class SubscriptionTierConfig { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'enum', enum: SubscriptionTier, unique: true }) + tier!: SubscriptionTier; + + /** Human-readable display name shown on the pricing page. */ + @Column({ type: 'varchar', length: 100 }) + displayName!: string; + + /** Monthly price in USD cents (0 = free). */ + @Column({ type: 'int', default: 0 }) + monthlyPriceCents!: number; + + /** Annual price in USD cents; null means annual billing is not offered. */ + @Column({ type: 'int', nullable: true }) + annualPriceCents?: number | null; + + /** JSON-serialised list of feature keys this tier unlocks. */ + @Column({ type: 'jsonb', default: () => "'[]'" }) + features!: string[]; + + /** Maximum uploads per month; null = unlimited. */ + @Column({ type: 'int', nullable: true }) + maxUploadsPerMonth?: number | null; + + /** Maximum storage in bytes; null = unlimited. */ + @Column({ type: 'bigint', nullable: true }) + maxStorageBytes?: number | null; + + /** If true, this tier is not currently available for new subscriptions. */ + @Column({ default: false }) + hidden!: boolean; + + /** Display order on the pricing page. */ + @Column({ type: 'int', default: 0 }) + sortOrder!: number; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/routes/subscriptionTierConfigRoutes.ts b/src/routes/subscriptionTierConfigRoutes.ts new file mode 100644 index 0000000..df66d15 --- /dev/null +++ b/src/routes/subscriptionTierConfigRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { SubscriptionTierConfigController } from '../controllers/SubscriptionTierConfigController'; +import { requireAuth } from '../middlewares/authMiddleware'; + +const router = Router(); +const controller = new SubscriptionTierConfigController(); + +// Public: list configured tiers +router.get('/tiers', controller.listTiers); + +// Admin: create/update a tier +router.put('/tiers/:tier', requireAuth, controller.upsertTier); + +// Admin: delete a tier +router.delete('/tiers/:tier', requireAuth, controller.deleteTier); + +export default router;