Skip to content
Open
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
82 changes: 82 additions & 0 deletions src/controllers/SubscriptionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
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);
}
};
}
13 changes: 13 additions & 0 deletions src/dtos/CreateTrialSubscriptionDTO.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions src/entities/Subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export enum SubscriptionStatus {
ACTIVE = 'active',
CANCELLED = 'cancelled',
EXPIRED = 'expired',
TRIAL = 'trial',
}

/**
Expand Down Expand Up @@ -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;

Expand Down
70 changes: 70 additions & 0 deletions src/migrations/1753200000000-AddTrialPeriodSupport.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
// 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'));
`);
}
}
14 changes: 14 additions & 0 deletions src/routes/subscriptionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);

Expand Down
80 changes: 80 additions & 0 deletions src/services/SubscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subscription> {
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<Subscription> {
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);
}
}