Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/controllers/SubscriptionTierConfigController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
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);
}
};
}
66 changes: 66 additions & 0 deletions src/entities/SubscriptionTierConfig.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions src/routes/subscriptionTierConfigRoutes.ts
Original file line number Diff line number Diff line change
@@ -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;