From 401c853cbc92c1a25820eb66b6603ba9475b0c59 Mon Sep 17 00:00:00 2001 From: aurorabini Date: Fri, 28 Aug 2026 12:11:11 +0200 Subject: [PATCH] feat: add subscription trial period support - Add trial status to SubscriptionStatus enum - Add isTrial, trialDaysUsed, trialDurationDays columns to Subscription entity - Add createTrialSubscription and convertTrialToPaid methods to SubscriptionService - Add trial subscription routes and controller methods - Create migration for trial period columns Closes #416 --- src/controllers/SubscriptionController.ts | 82 +++++++++++++++++++ src/dtos/CreateTrialSubscriptionDTO.ts | 13 +++ src/entities/Subscription.ts | 10 +++ .../1753200000000-AddTrialPeriodSupport.ts | 70 ++++++++++++++++ src/routes/subscriptionRoutes.ts | 14 ++++ src/services/SubscriptionService.ts | 80 ++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 src/dtos/CreateTrialSubscriptionDTO.ts create mode 100644 src/migrations/1753200000000-AddTrialPeriodSupport.ts diff --git a/src/controllers/SubscriptionController.ts b/src/controllers/SubscriptionController.ts index e8da06e..5396bfc 100644 --- a/src/controllers/SubscriptionController.ts +++ b/src/controllers/SubscriptionController.ts @@ -124,4 +124,86 @@ export class SubscriptionController { handleError(req, res, error); } }; + + /** + * Create a trial subscription. + * POST /api/subscriptions/trial + * + * @param req - Express request with tier and optional trialDurationDays in body + * @param res - Express response + */ + createTrialSubscription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError(req, res, { message: 'User not authenticated', statusCode: 401 }); + } + + const { tier, trialDurationDays } = req.body; + + const subscription = await this.subscriptionService.createTrialSubscription( + userId, + tier, + trialDurationDays, + ); + + res.status(HTTP_STATUS.CREATED).json({ + message: 'Trial subscription created successfully', + subscription: { + id: subscription.id, + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + isTrial: subscription.isTrial, + trialDurationDays: subscription.trialDurationDays, + }, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + /** + * Convert a trial subscription to a paid subscription. + * POST /api/subscriptions/trial/convert + * + * @param req - Express request with endDate in body + * @param res - Express response + */ + convertTrialToPaid = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user?.id; + + if (!userId) { + return handleError(req, res, { message: 'User not authenticated', statusCode: 401 }); + } + + const { endDate } = req.body; + + if (!endDate) { + return handleError(req, res, { message: 'endDate is required', statusCode: 400 }); + } + + const subscription = await this.subscriptionService.convertTrialToPaid( + userId, + new Date(endDate), + ); + + res.status(HTTP_STATUS.OK).json({ + message: 'Trial subscription converted to paid successfully', + subscription: { + id: subscription.id, + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + isTrial: subscription.isTrial, + }, + }); + } catch (error) { + handleError(req, res, error); + } + }; } diff --git a/src/dtos/CreateTrialSubscriptionDTO.ts b/src/dtos/CreateTrialSubscriptionDTO.ts new file mode 100644 index 0000000..dfda861 --- /dev/null +++ b/src/dtos/CreateTrialSubscriptionDTO.ts @@ -0,0 +1,13 @@ +import { IsEnum, IsInt, IsOptional, Min, Max } from 'class-validator'; +import { SubscriptionTier } from '../entities/Subscription'; + +export class CreateTrialSubscriptionDTO { + @IsEnum(SubscriptionTier) + tier!: SubscriptionTier; + + @IsInt() + @Min(1) + @Max(30) + @IsOptional() + trialDurationDays?: number = 14; +} diff --git a/src/entities/Subscription.ts b/src/entities/Subscription.ts index 35215eb..07d183b 100644 --- a/src/entities/Subscription.ts +++ b/src/entities/Subscription.ts @@ -26,6 +26,7 @@ export enum SubscriptionStatus { ACTIVE = 'active', CANCELLED = 'cancelled', EXPIRED = 'expired', + TRIAL = 'trial', } /** @@ -66,6 +67,15 @@ export class Subscription { @Column({ type: 'timestamp', nullable: true }) endDate?: Date; + @Column({ type: 'boolean', default: false }) + isTrial!: boolean; + + @Column({ type: 'int', default: 0 }) + trialDaysUsed!: number; + + @Column({ type: 'int', nullable: true }) + trialDurationDays?: number; + @CreateDateColumn() createdAt!: Date; diff --git a/src/migrations/1753200000000-AddTrialPeriodSupport.ts b/src/migrations/1753200000000-AddTrialPeriodSupport.ts new file mode 100644 index 0000000..274ff7b --- /dev/null +++ b/src/migrations/1753200000000-AddTrialPeriodSupport.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Migration to add trial period support to the subscriptions table. + * Adds isTrial, trialDaysUsed, and trialDurationDays columns. + */ +export class AddTrialPeriodSupport1753200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add isTrial column + await queryRunner.addColumn( + 'subscriptions', + new TableColumn({ + name: 'isTrial', + type: 'boolean', + default: false, + }), + ); + + // Add trialDaysUsed column + await queryRunner.addColumn( + 'subscriptions', + new TableColumn({ + name: 'trialDaysUsed', + type: 'int', + default: 0, + }), + ); + + // Add trialDurationDays column + await queryRunner.addColumn( + 'subscriptions', + new TableColumn({ + name: 'trialDurationDays', + type: 'int', + isNullable: true, + }), + ); + + // Update status check constraint to include 'trial' + await queryRunner.query(` + ALTER TABLE "subscriptions" + DROP CONSTRAINT "CHK_subscriptions_status_valid"; + `); + + await queryRunner.query(` + ALTER TABLE "subscriptions" + ADD CONSTRAINT "CHK_subscriptions_status_valid" + CHECK (status IN ('active', 'cancelled', 'expired', 'trial')); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove columns + await queryRunner.dropColumn('subscriptions', 'trialDurationDays'); + await queryRunner.dropColumn('subscriptions', 'trialDaysUsed'); + await queryRunner.dropColumn('subscriptions', 'isTrial'); + + // Restore status check constraint + await queryRunner.query(` + ALTER TABLE "subscriptions" + DROP CONSTRAINT "CHK_subscriptions_status_valid"; + `); + + await queryRunner.query(` + ALTER TABLE "subscriptions" + ADD CONSTRAINT "CHK_subscriptions_status_valid" + CHECK (status IN ('active', 'cancelled', 'expired')); + `); + } +} diff --git a/src/routes/subscriptionRoutes.ts b/src/routes/subscriptionRoutes.ts index eba6d8e..0816d45 100644 --- a/src/routes/subscriptionRoutes.ts +++ b/src/routes/subscriptionRoutes.ts @@ -3,6 +3,7 @@ import { SubscriptionController } from '../controllers/SubscriptionController'; import { requireAuth } from '../middlewares/authMiddleware'; import { validateDTO } from '../middlewares/validate'; import { CreateSubscriptionDTO } from '../dtos/CreateSubscriptionDTO'; +import { CreateTrialSubscriptionDTO } from '../dtos/CreateTrialSubscriptionDTO'; const router = Router(); const subscriptionController = new SubscriptionController(); @@ -20,6 +21,19 @@ router.post( subscriptionController.createOrUpgradeSubscription, ); +// Create trial subscription +router.post( + '/trial', + validateDTO(CreateTrialSubscriptionDTO), + subscriptionController.createTrialSubscription, +); + +// Convert trial to paid +router.post( + '/trial/convert', + subscriptionController.convertTrialToPaid, +); + // Cancel subscription router.delete('/', subscriptionController.cancelSubscription); diff --git a/src/services/SubscriptionService.ts b/src/services/SubscriptionService.ts index 89b6eb7..5ff368d 100644 --- a/src/services/SubscriptionService.ts +++ b/src/services/SubscriptionService.ts @@ -141,4 +141,84 @@ export class SubscriptionService { return tierHierarchy[subscription.tier] >= tierHierarchy[requiredTier]; } + + /** + * Create a trial subscription for a user. + * + * @param userId - The user's UUID + * @param tier - The subscription tier for the trial + * @param trialDurationDays - Number of days for the trial (default: 14) + * @returns The created trial subscription + */ + async createTrialSubscription( + userId: string, + tier: SubscriptionTier, + trialDurationDays: number = 14, + ): Promise { + validateRequired(userId, 'userId'); + validateRequired(tier, 'tier'); + + // Validate tier + if (!Object.values(SubscriptionTier).includes(tier)) { + throw AppError.validation(`Invalid tier: ${tier}`, [ + { + field: 'tier', + message: `Tier must be one of: ${Object.values(SubscriptionTier).join(', ')}`, + }, + ]); + } + + // Check for existing active or trial subscription + const existingSubscription = await this.getUserSubscription(userId); + + if (existingSubscription) { + throw AppError.conflict('User already has an active subscription'); + } + + // Calculate trial end date + const trialEndDate = new Date(); + trialEndDate.setDate(trialEndDate.getDate() + trialDurationDays); + + // Create trial subscription + const subscription = this.subscriptionRepo.create({ + userId, + tier, + status: SubscriptionStatus.TRIAL, + startDate: new Date(), + endDate: trialEndDate, + isTrial: true, + trialDaysUsed: 0, + trialDurationDays, + }); + + return await this.subscriptionRepo.save(subscription); + } + + /** + * Convert a trial subscription to a paid subscription. + * + * @param userId - The user's UUID + * @param endDate - End date for the paid subscription + * @returns The updated subscription + */ + async convertTrialToPaid(userId: string, endDate: Date): Promise { + validateRequired(userId, 'userId'); + + const subscription = await this.subscriptionRepo.findOne({ + where: { + userId, + status: SubscriptionStatus.TRIAL, + }, + }); + + if (!subscription) { + throw AppError.notFound('No active trial subscription found'); + } + + subscription.status = SubscriptionStatus.ACTIVE; + subscription.isTrial = false; + subscription.endDate = endDate; + + return await this.subscriptionRepo.save(subscription); + } }