fix licensing

This commit is contained in:
David 2026-01-20 11:28:54 +01:00
parent dd5d806180
commit 5c7834c7e4
54 changed files with 6202 additions and 62 deletions

View File

@ -84,3 +84,18 @@ RATE_LIMIT_MAX=100
# Monitoring
SENTRY_DSN=your-sentry-dsn
# Frontend URL (for redirects)
FRONTEND_URL=http://localhost:3000
# Stripe (Subscriptions & Payments)
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Stripe Price IDs (create these in Stripe Dashboard)
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly

View File

@ -59,7 +59,9 @@
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"typeorm": "^0.3.17"
"stripe": "^14.14.0",
"typeorm": "^0.3.17",
"uuid": "^9.0.1"
},
"devDependencies": {
"@faker-js/faker": "^10.0.0",
@ -14570,6 +14572,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "14.25.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",

View File

@ -75,7 +75,9 @@
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"typeorm": "^0.3.17"
"stripe": "^14.14.0",
"typeorm": "^0.3.17",
"uuid": "^9.0.1"
},
"devDependencies": {
"@faker-js/faker": "^10.0.0",

View File

@ -0,0 +1,55 @@
/**
* Script to list all Stripe prices
* Run with: node scripts/list-stripe-prices.js
*/
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
async function listPrices() {
console.log('Fetching Stripe prices...\n');
try {
const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] });
if (prices.data.length === 0) {
console.log('No prices found. You need to create prices in Stripe Dashboard.');
console.log('\nSteps:');
console.log('1. Go to https://dashboard.stripe.com/products');
console.log('2. Click on each product (Starter, Pro, Enterprise)');
console.log('3. Add a recurring price (monthly and yearly)');
console.log('4. Copy the Price IDs (format: price_xxxxx)');
return;
}
console.log('Available Prices:\n');
console.log('='.repeat(100));
for (const price of prices.data) {
const product = typeof price.product === 'object' ? price.product : { name: price.product };
const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time';
const amount = (price.unit_amount / 100).toFixed(2);
console.log(`Price ID: ${price.id}`);
console.log(`Product: ${product.name || product.id}`);
console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`);
console.log(`Interval: ${interval}`);
console.log(`Active: ${price.active}`);
console.log('-'.repeat(100));
}
console.log('\n\nCopy the relevant Price IDs to your .env file:');
console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
} catch (error) {
console.error('Error fetching prices:', error.message);
}
}
listPrices();

View File

@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
@ -56,6 +57,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
SMTP_PASS: Joi.string().required(),
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
SMTP_SECURE: Joi.boolean().default(false),
// Stripe Configuration (optional for development)
STRIPE_SECRET_KEY: Joi.string().optional(),
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
}),
}),
@ -117,6 +127,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
WebhooksModule,
GDPRModule,
AdminModule,
SubscriptionsModule,
],
controllers: [],
providers: [

View File

@ -20,6 +20,7 @@ import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeo
import { InvitationService } from '../services/invitation.service';
import { InvitationsController } from '../controllers/invitations.controller';
import { EmailModule } from '../../infrastructure/email/email.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
@Module({
imports: [
@ -43,6 +44,9 @@ import { EmailModule } from '../../infrastructure/email/email.module';
// Email module for sending invitations
EmailModule,
// Subscriptions module for license checks
SubscriptionsModule,
],
controllers: [AuthController, InvitationsController],
providers: [

View File

@ -18,6 +18,7 @@ import {
import { Organization } from '@domain/entities/organization.entity';
import { v4 as uuidv4 } from 'uuid';
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service';
export interface JwtPayload {
sub: string; // user ID
@ -37,7 +38,8 @@ export class AuthService {
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
private readonly jwtService: JwtService,
private readonly configService: ConfigService
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService,
) {}
/**
@ -100,6 +102,16 @@ export class AuthService {
const savedUser = await this.userRepository.save(user);
// Allocate a license for the new user
try {
await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId);
this.logger.log(`License allocated for user: ${email}`);
} catch (error) {
this.logger.error(`Failed to allocate license for user ${email}:`, error);
// Note: We don't throw here because the user is already created.
// The license check should happen before invitation.
}
const tokens = await this.generateTokens(savedUser);
this.logger.log(`User registered successfully: ${email}`);

View File

@ -0,0 +1,266 @@
/**
* Subscriptions Controller
*
* Handles subscription management endpoints:
* - GET /subscriptions - Get subscription overview
* - GET /subscriptions/plans - Get all available plans
* - GET /subscriptions/can-invite - Check if can invite users
* - POST /subscriptions/checkout - Create Stripe checkout session
* - POST /subscriptions/portal - Create Stripe portal session
* - POST /subscriptions/webhook - Handle Stripe webhooks
*/
import {
Controller,
Get,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
Logger,
Headers,
RawBodyRequest,
Req,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiExcludeEndpoint,
} from '@nestjs/swagger';
import { Request } from 'express';
import { SubscriptionService } from '../services/subscription.service';
import {
CreateCheckoutSessionDto,
CreatePortalSessionDto,
SyncSubscriptionDto,
SubscriptionOverviewResponseDto,
CanInviteResponseDto,
CheckoutSessionResponseDto,
PortalSessionResponseDto,
AllPlansResponseDto,
} from '../dto/subscription.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Public } from '../decorators/public.decorator';
@ApiTags('Subscriptions')
@Controller('subscriptions')
export class SubscriptionsController {
private readonly logger = new Logger(SubscriptionsController.name);
constructor(private readonly subscriptionService: SubscriptionService) {}
/**
* Get subscription overview for current organization
*/
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Get subscription overview',
description:
'Get the subscription details including licenses for the current organization. Admin/manager only.',
})
@ApiResponse({
status: 200,
description: 'Subscription overview retrieved successfully',
type: SubscriptionOverviewResponseDto,
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async getSubscriptionOverview(
@CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
}
/**
* Get all available plans
*/
@Get('plans')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get all plans',
description: 'Get details of all available subscription plans.',
})
@ApiResponse({
status: 200,
description: 'Plans retrieved successfully',
type: AllPlansResponseDto,
})
getAllPlans(): AllPlansResponseDto {
this.logger.log('Getting all subscription plans');
return this.subscriptionService.getAllPlans();
}
/**
* Check if organization can invite more users
*/
@Get('can-invite')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Check license availability',
description:
'Check if the organization can invite more users based on license availability. Admin/manager only.',
})
@ApiResponse({
status: 200,
description: 'License availability check result',
type: CanInviteResponseDto,
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
this.logger.log(`[User: ${user.email}] Checking license availability`);
return this.subscriptionService.canInviteUser(user.organizationId);
}
/**
* Create Stripe Checkout session for subscription upgrade
*/
@Post('checkout')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Create checkout session',
description:
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Checkout session created successfully',
type: CheckoutSessionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - invalid plan or already subscribed',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async createCheckoutSession(
@Body() dto: CreateCheckoutSessionDto,
@CurrentUser() user: UserPayload,
): Promise<CheckoutSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
return this.subscriptionService.createCheckoutSession(
user.organizationId,
user.id,
dto,
);
}
/**
* Create Stripe Customer Portal session
*/
@Post('portal')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Create portal session',
description:
'Create a Stripe Customer Portal session for subscription management. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Portal session created successfully',
type: PortalSessionResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - no Stripe customer found',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async createPortalSession(
@Body() dto: CreatePortalSessionDto,
@CurrentUser() user: UserPayload,
): Promise<PortalSessionResponseDto> {
this.logger.log(`[User: ${user.email}] Creating portal session`);
return this.subscriptionService.createPortalSession(user.organizationId, dto);
}
/**
* Sync subscription from Stripe
* Useful when webhooks are not available (e.g., local development)
*/
@Post('sync')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiBearerAuth()
@ApiOperation({
summary: 'Sync subscription from Stripe',
description:
'Manually sync subscription data from Stripe. Useful when webhooks are not working (local dev). Pass sessionId after checkout to sync new subscription. Admin/Manager only.',
})
@ApiResponse({
status: 200,
description: 'Subscription synced successfully',
type: SubscriptionOverviewResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - no Stripe subscription found',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
async syncFromStripe(
@Body() dto: SyncSubscriptionDto,
@CurrentUser() user: UserPayload,
): Promise<SubscriptionOverviewResponseDto> {
this.logger.log(
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
);
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
}
/**
* Handle Stripe webhook events
*/
@Post('webhook')
@Public()
@HttpCode(HttpStatus.OK)
@ApiExcludeEndpoint()
async handleWebhook(
@Headers('stripe-signature') signature: string,
@Req() req: RawBodyRequest<Request>,
): Promise<{ received: boolean }> {
const rawBody = req.rawBody;
if (!rawBody) {
this.logger.error('No raw body found in request');
return { received: false };
}
try {
await this.subscriptionService.handleStripeWebhook(rawBody, signature);
return { received: true };
} catch (error) {
this.logger.error('Webhook processing failed', error);
return { received: false };
}
}
}

View File

@ -49,6 +49,7 @@ import { Roles } from '../decorators/roles.decorator';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { SubscriptionService } from '../services/subscription.service';
/**
* Users Controller
@ -68,7 +69,10 @@ import * as crypto from 'crypto';
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository) {}
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService,
) {}
/**
* Create/Invite a new user
@ -273,8 +277,21 @@ export class UsersController {
if (dto.isActive !== undefined) {
if (dto.isActive) {
user.activate();
// Reallocate license if reactivating user
try {
await this.subscriptionService.allocateLicense(id, user.organizationId);
this.logger.log(`License reallocated for reactivated user: ${id}`);
} catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
);
}
} else {
user.deactivate();
// Revoke license when deactivating user
await this.subscriptionService.revokeLicense(id);
this.logger.log(`License revoked for deactivated user: ${id}`);
}
}
@ -321,6 +338,10 @@ export class UsersController {
throw new NotFoundException(`User ${id} not found`);
}
// Revoke license before deleting user
await this.subscriptionService.revokeLicense(id);
this.logger.log(`License revoked for user being deleted: ${id}`);
// Permanently delete user from database
await this.userRepository.deleteById(id);

View File

@ -0,0 +1,378 @@
/**
* Subscription DTOs
*
* Data Transfer Objects for subscription management API
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsEnum,
IsNotEmpty,
IsUrl,
IsOptional,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
/**
* Subscription plan types
*/
export enum SubscriptionPlanDto {
FREE = 'FREE',
STARTER = 'STARTER',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}
/**
* Subscription status types
*/
export enum SubscriptionStatusDto {
ACTIVE = 'ACTIVE',
PAST_DUE = 'PAST_DUE',
CANCELED = 'CANCELED',
INCOMPLETE = 'INCOMPLETE',
INCOMPLETE_EXPIRED = 'INCOMPLETE_EXPIRED',
TRIALING = 'TRIALING',
UNPAID = 'UNPAID',
PAUSED = 'PAUSED',
}
/**
* Billing interval types
*/
export enum BillingIntervalDto {
MONTHLY = 'monthly',
YEARLY = 'yearly',
}
/**
* Create Checkout Session DTO
*/
export class CreateCheckoutSessionDto {
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
description: 'The subscription plan to purchase',
enum: SubscriptionPlanDto,
})
@IsEnum(SubscriptionPlanDto)
plan: SubscriptionPlanDto;
@ApiProperty({
example: BillingIntervalDto.MONTHLY,
description: 'Billing interval (monthly or yearly)',
enum: BillingIntervalDto,
})
@IsEnum(BillingIntervalDto)
billingInterval: BillingIntervalDto;
@ApiPropertyOptional({
example: 'https://app.xpeditis.com/dashboard/settings/subscription?success=true',
description: 'URL to redirect to after successful payment',
})
@IsUrl()
@IsOptional()
successUrl?: string;
@ApiPropertyOptional({
example: 'https://app.xpeditis.com/dashboard/settings/subscription?canceled=true',
description: 'URL to redirect to if payment is canceled',
})
@IsUrl()
@IsOptional()
cancelUrl?: string;
}
/**
* Create Portal Session DTO
*/
export class CreatePortalSessionDto {
@ApiPropertyOptional({
example: 'https://app.xpeditis.com/dashboard/settings/subscription',
description: 'URL to return to after using the portal',
})
@IsUrl()
@IsOptional()
returnUrl?: string;
}
/**
* Sync Subscription DTO
*/
export class SyncSubscriptionDto {
@ApiPropertyOptional({
example: 'cs_test_a1b2c3d4e5f6g7h8',
description: 'Stripe checkout session ID (used after checkout completes)',
})
@IsString()
@IsOptional()
sessionId?: string;
}
/**
* Checkout Session Response DTO
*/
export class CheckoutSessionResponseDto {
@ApiProperty({
example: 'cs_test_a1b2c3d4e5f6g7h8',
description: 'Stripe checkout session ID',
})
sessionId: string;
@ApiProperty({
example: 'https://checkout.stripe.com/pay/cs_test_a1b2c3',
description: 'URL to redirect user to for payment',
})
sessionUrl: string;
}
/**
* Portal Session Response DTO
*/
export class PortalSessionResponseDto {
@ApiProperty({
example: 'https://billing.stripe.com/session/test_YWNjdF8x',
description: 'URL to redirect user to for subscription management',
})
sessionUrl: string;
}
/**
* License Response DTO
*/
export class LicenseResponseDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'License ID',
})
id: string;
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440001',
description: 'User ID',
})
userId: string;
@ApiProperty({
example: 'john.doe@example.com',
description: 'User email',
})
userEmail: string;
@ApiProperty({
example: 'John Doe',
description: 'User full name',
})
userName: string;
@ApiProperty({
example: 'ADMIN',
description: 'User role (ADMIN users have unlimited licenses)',
})
userRole: string;
@ApiProperty({
example: 'ACTIVE',
description: 'License status',
})
status: string;
@ApiProperty({
example: '2025-01-15T10:00:00Z',
description: 'When the license was assigned',
})
assignedAt: Date;
@ApiPropertyOptional({
example: '2025-02-15T10:00:00Z',
description: 'When the license was revoked (if applicable)',
})
revokedAt?: Date;
}
/**
* Plan Details DTO
*/
export class PlanDetailsDto {
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
description: 'Plan identifier',
enum: SubscriptionPlanDto,
})
plan: SubscriptionPlanDto;
@ApiProperty({
example: 'Starter',
description: 'Plan display name',
})
name: string;
@ApiProperty({
example: 5,
description: 'Maximum number of licenses (-1 for unlimited)',
})
maxLicenses: number;
@ApiProperty({
example: 49,
description: 'Monthly price in EUR',
})
monthlyPriceEur: number;
@ApiProperty({
example: 470,
description: 'Yearly price in EUR',
})
yearlyPriceEur: number;
@ApiProperty({
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
description: 'List of features included in this plan',
type: [String],
})
features: string[];
}
/**
* Subscription Response DTO
*/
export class SubscriptionResponseDto {
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Subscription ID',
})
id: string;
@ApiProperty({
example: '550e8400-e29b-41d4-a716-446655440001',
description: 'Organization ID',
})
organizationId: string;
@ApiProperty({
example: SubscriptionPlanDto.STARTER,
description: 'Current subscription plan',
enum: SubscriptionPlanDto,
})
plan: SubscriptionPlanDto;
@ApiProperty({
description: 'Details about the current plan',
type: PlanDetailsDto,
})
planDetails: PlanDetailsDto;
@ApiProperty({
example: SubscriptionStatusDto.ACTIVE,
description: 'Current subscription status',
enum: SubscriptionStatusDto,
})
status: SubscriptionStatusDto;
@ApiProperty({
example: 3,
description: 'Number of licenses currently in use',
})
usedLicenses: number;
@ApiProperty({
example: 5,
description: 'Maximum licenses available (-1 for unlimited)',
})
maxLicenses: number;
@ApiProperty({
example: 2,
description: 'Number of licenses available',
})
availableLicenses: number;
@ApiProperty({
example: false,
description: 'Whether the subscription is scheduled for cancellation',
})
cancelAtPeriodEnd: boolean;
@ApiPropertyOptional({
example: '2025-01-01T00:00:00Z',
description: 'Start of current billing period',
})
currentPeriodStart?: Date;
@ApiPropertyOptional({
example: '2025-02-01T00:00:00Z',
description: 'End of current billing period',
})
currentPeriodEnd?: Date;
@ApiProperty({
example: '2025-01-01T00:00:00Z',
description: 'When the subscription was created',
})
createdAt: Date;
@ApiProperty({
example: '2025-01-15T10:00:00Z',
description: 'When the subscription was last updated',
})
updatedAt: Date;
}
/**
* Subscription Overview Response DTO (includes licenses)
*/
export class SubscriptionOverviewResponseDto extends SubscriptionResponseDto {
@ApiProperty({
description: 'List of active licenses',
type: [LicenseResponseDto],
})
licenses: LicenseResponseDto[];
}
/**
* Can Invite Response DTO
*/
export class CanInviteResponseDto {
@ApiProperty({
example: true,
description: 'Whether the organization can invite more users',
})
canInvite: boolean;
@ApiProperty({
example: 2,
description: 'Number of available licenses',
})
availableLicenses: number;
@ApiProperty({
example: 3,
description: 'Number of used licenses',
})
usedLicenses: number;
@ApiProperty({
example: 5,
description: 'Maximum licenses allowed (-1 for unlimited)',
})
maxLicenses: number;
@ApiPropertyOptional({
example: 'Upgrade to Starter plan to add more users',
description: 'Message explaining why invitations are blocked',
})
message?: string;
}
/**
* All Plans Response DTO
*/
export class AllPlansResponseDto {
@ApiProperty({
description: 'List of all available plans',
type: [PlanDetailsDto],
})
plans: PlanDetailsDto[];
}

View File

@ -5,6 +5,7 @@ import {
ConflictException,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
@ -19,6 +20,7 @@ import {
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { InvitationToken } from '@domain/entities/invitation-token.entity';
import { UserRole } from '@domain/entities/user.entity';
import { SubscriptionService } from './subscription.service';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
@ -35,7 +37,8 @@ export class InvitationService {
private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT)
private readonly emailService: EmailPort,
private readonly configService: ConfigService
private readonly configService: ConfigService,
private readonly subscriptionService: SubscriptionService,
) {}
/**
@ -65,6 +68,18 @@ export class InvitationService {
);
}
// Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
if (!canInviteResult.canInvite) {
this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
);
throw new ForbiddenException(
canInviteResult.message ||
`License limit reached. Please upgrade your subscription to invite more users.`,
);
}
// Generate unique token
const token = this.generateToken();

View File

@ -0,0 +1,684 @@
/**
* Subscription Service
*
* Business logic for subscription and license management.
*/
import {
Injectable,
Inject,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import {
LicenseRepository,
LICENSE_REPOSITORY,
} from '@domain/ports/out/license.repository';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
import { Subscription } from '@domain/entities/subscription.entity';
import { License } from '@domain/entities/license.entity';
import {
SubscriptionPlan,
SubscriptionPlanType,
} from '@domain/value-objects/subscription-plan.vo';
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
import {
NoLicensesAvailableException,
SubscriptionNotFoundException,
LicenseAlreadyAssignedException,
} from '@domain/exceptions/subscription.exceptions';
import {
CreateCheckoutSessionDto,
CreatePortalSessionDto,
SubscriptionOverviewResponseDto,
CanInviteResponseDto,
CheckoutSessionResponseDto,
PortalSessionResponseDto,
LicenseResponseDto,
PlanDetailsDto,
AllPlansResponseDto,
SubscriptionPlanDto,
SubscriptionStatusDto,
} from '../dto/subscription.dto';
@Injectable()
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
constructor(
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository,
@Inject(LICENSE_REPOSITORY)
private readonly licenseRepository: LicenseRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository,
@Inject(STRIPE_PORT)
private readonly stripeAdapter: StripePort,
private readonly configService: ConfigService,
) {}
/**
* Get subscription overview for an organization
*/
async getSubscriptionOverview(
organizationId: string,
): Promise<SubscriptionOverviewResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId);
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
subscription.id,
);
// Enrich licenses with user information
const enrichedLicenses = await Promise.all(
activeLicenses.map(async (license) => {
const user = await this.userRepository.findById(license.userId);
return this.mapLicenseToDto(license, user);
}),
);
// Count only non-ADMIN licenses for quota calculation
// ADMIN users have unlimited licenses and don't count against the quota
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id,
);
const maxLicenses = subscription.maxLicenses;
const availableLicenses = subscription.isUnlimited()
? -1
: Math.max(0, maxLicenses - usedLicenses);
return {
id: subscription.id,
organizationId: subscription.organizationId,
plan: subscription.plan.value as SubscriptionPlanDto,
planDetails: this.mapPlanToDto(subscription.plan),
status: subscription.status.value as SubscriptionStatusDto,
usedLicenses,
maxLicenses,
availableLicenses,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
currentPeriodStart: subscription.currentPeriodStart || undefined,
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
licenses: enrichedLicenses,
};
}
/**
* Get all available plans
*/
getAllPlans(): AllPlansResponseDto {
const plans = SubscriptionPlan.getAllPlans().map((plan) =>
this.mapPlanToDto(plan),
);
return { plans };
}
/**
* Check if organization can invite more users
* Note: ADMIN users don't count against the license quota
*/
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
const subscription = await this.getOrCreateSubscription(organizationId);
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id,
);
const maxLicenses = subscription.maxLicenses;
const canInvite =
subscription.isActive() &&
(subscription.isUnlimited() || usedLicenses < maxLicenses);
const availableLicenses = subscription.isUnlimited()
? -1
: Math.max(0, maxLicenses - usedLicenses);
let message: string | undefined;
if (!subscription.isActive()) {
message = 'Your subscription is not active. Please update your payment method.';
} else if (!canInvite) {
message = `You have reached the maximum number of users (${maxLicenses}) for your ${subscription.plan.name} plan. Upgrade to add more users.`;
}
return {
canInvite,
availableLicenses,
usedLicenses,
maxLicenses,
message,
};
}
/**
* Create a Stripe Checkout session for subscription upgrade
*/
async createCheckoutSession(
organizationId: string,
userId: string,
dto: CreateCheckoutSessionDto,
): Promise<CheckoutSessionResponseDto> {
const organization = await this.organizationRepository.findById(organizationId);
if (!organization) {
throw new NotFoundException('Organization not found');
}
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Cannot checkout for FREE plan
if (dto.plan === SubscriptionPlanDto.FREE) {
throw new BadRequestException('Cannot create checkout session for FREE plan');
}
const subscription = await this.getOrCreateSubscription(organizationId);
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
const successUrl =
dto.successUrl ||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl =
dto.cancelUrl ||
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
const result = await this.stripeAdapter.createCheckoutSession({
organizationId,
organizationName: organization.name,
email: user.email,
plan: dto.plan as SubscriptionPlanType,
billingInterval: dto.billingInterval as 'monthly' | 'yearly',
successUrl,
cancelUrl,
customerId: subscription.stripeCustomerId || undefined,
});
this.logger.log(
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
);
return {
sessionId: result.sessionId,
sessionUrl: result.sessionUrl,
};
}
/**
* Create a Stripe Customer Portal session
*/
async createPortalSession(
organizationId: string,
dto: CreatePortalSessionDto,
): Promise<PortalSessionResponseDto> {
const subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription?.stripeCustomerId) {
throw new BadRequestException(
'No Stripe customer found for this organization. Please complete a checkout first.',
);
}
const frontendUrl = this.configService.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
const returnUrl =
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
const result = await this.stripeAdapter.createPortalSession({
customerId: subscription.stripeCustomerId,
returnUrl,
});
this.logger.log(`Created portal session for organization ${organizationId}`);
return {
sessionUrl: result.sessionUrl,
};
}
/**
* Sync subscription from Stripe
* Useful when webhooks are not available (e.g., local development)
* @param organizationId - The organization ID
* @param sessionId - Optional Stripe checkout session ID (used after checkout completes)
*/
async syncFromStripe(
organizationId: string,
sessionId?: string,
): Promise<SubscriptionOverviewResponseDto> {
let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) {
subscription = await this.getOrCreateSubscription(organizationId);
}
let stripeSubscriptionId = subscription.stripeSubscriptionId;
let stripeCustomerId = subscription.stripeCustomerId;
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details
// This is important for upgrades where Stripe may create a new subscription
if (sessionId) {
this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
if (checkoutSession) {
this.logger.log(
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
);
// Always use the subscription ID from the checkout session if available
// This handles upgrades where a new subscription is created
if (checkoutSession.subscriptionId) {
stripeSubscriptionId = checkoutSession.subscriptionId;
}
if (checkoutSession.customerId) {
stripeCustomerId = checkoutSession.customerId;
}
// Update subscription with customer ID if we got it from checkout session
if (stripeCustomerId && !subscription.stripeCustomerId) {
subscription = subscription.updateStripeCustomerId(stripeCustomerId);
}
} else {
this.logger.warn(`Checkout session ${sessionId} not found`);
}
}
if (!stripeSubscriptionId) {
this.logger.log(`No Stripe subscription found for organization ${organizationId}`);
// Return current subscription data without syncing
return this.getSubscriptionOverview(organizationId);
}
// Get fresh data from Stripe
const stripeData = await this.stripeAdapter.getSubscription(stripeSubscriptionId);
if (!stripeData) {
this.logger.warn(`Could not retrieve Stripe subscription ${stripeSubscriptionId}`);
return this.getSubscriptionOverview(organizationId);
}
// Map the price ID to our plan
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
let updatedSubscription = subscription;
if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id,
);
const newPlan = SubscriptionPlan.create(plan);
// Update plan
updatedSubscription = updatedSubscription.updatePlan(newPlan, usedLicenses);
this.logger.log(`Updated plan to ${plan} for organization ${organizationId}`);
}
// Update Stripe IDs if not already set
if (!updatedSubscription.stripeCustomerId && stripeData.customerId) {
updatedSubscription = updatedSubscription.updateStripeCustomerId(stripeData.customerId);
}
// Update Stripe subscription data
updatedSubscription = updatedSubscription.updateStripeSubscription({
stripeSubscriptionId: stripeData.subscriptionId,
currentPeriodStart: stripeData.currentPeriodStart,
currentPeriodEnd: stripeData.currentPeriodEnd,
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
});
// Update status
updatedSubscription = updatedSubscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status),
);
await this.subscriptionRepository.save(updatedSubscription);
this.logger.log(
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
);
return this.getSubscriptionOverview(organizationId);
}
/**
* Handle Stripe webhook events
*/
async handleStripeWebhook(payload: string | Buffer, signature: string): Promise<void> {
const event = await this.stripeAdapter.constructWebhookEvent(payload, signature);
this.logger.log(`Processing Stripe webhook event: ${event.type}`);
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object);
break;
default:
this.logger.log(`Unhandled Stripe event type: ${event.type}`);
}
}
/**
* Allocate a license to a user
* Note: ADMIN users always get a license (unlimited) and don't count against the quota
*/
async allocateLicense(userId: string, organizationId: string): Promise<License> {
const subscription = await this.getOrCreateSubscription(organizationId);
// Check if user already has a license
const existingLicense = await this.licenseRepository.findByUserId(userId);
if (existingLicense?.isActive()) {
throw new LicenseAlreadyAssignedException(userId);
}
// Get the user to check if they're an ADMIN
const user = await this.userRepository.findById(userId);
const isAdmin = user?.role === 'ADMIN';
// ADMIN users have unlimited licenses - skip quota check for them
if (!isAdmin) {
// Count only non-ADMIN licenses for quota check
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id,
);
if (!subscription.canAllocateLicenses(usedLicenses)) {
throw new NoLicensesAvailableException(
organizationId,
usedLicenses,
subscription.maxLicenses,
);
}
}
// If there's a revoked license, reactivate it
if (existingLicense?.isRevoked()) {
const reactivatedLicense = existingLicense.reactivate();
return this.licenseRepository.save(reactivatedLicense);
}
// Create new license
const license = License.create({
id: uuidv4(),
subscriptionId: subscription.id,
userId,
});
const savedLicense = await this.licenseRepository.save(license);
this.logger.log(`Allocated license ${savedLicense.id} to user ${userId} (isAdmin: ${isAdmin})`);
return savedLicense;
}
/**
* Revoke a user's license
*/
async revokeLicense(userId: string): Promise<void> {
const license = await this.licenseRepository.findByUserId(userId);
if (!license) {
this.logger.warn(`No license found for user ${userId}`);
return;
}
if (license.isRevoked()) {
this.logger.warn(`License for user ${userId} is already revoked`);
return;
}
const revokedLicense = license.revoke();
await this.licenseRepository.save(revokedLicense);
this.logger.log(`Revoked license ${license.id} for user ${userId}`);
}
/**
* Get or create a subscription for an organization
*/
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
let subscription = await this.subscriptionRepository.findByOrganizationId(
organizationId,
);
if (!subscription) {
// Create FREE subscription for the organization
subscription = Subscription.create({
id: uuidv4(),
organizationId,
plan: SubscriptionPlan.free(),
});
subscription = await this.subscriptionRepository.save(subscription);
this.logger.log(
`Created FREE subscription for organization ${organizationId}`,
);
}
return subscription;
}
// Private helper methods
private async handleCheckoutCompleted(
session: Record<string, unknown>,
): Promise<void> {
const metadata = session.metadata as Record<string, string> | undefined;
const organizationId = metadata?.organizationId;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
if (!organizationId || !customerId || !subscriptionId) {
this.logger.warn('Checkout session missing required metadata');
return;
}
// Get subscription details from Stripe
const stripeSubscription = await this.stripeAdapter.getSubscription(subscriptionId);
if (!stripeSubscription) {
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
return;
}
// Get or create our subscription
let subscription = await this.getOrCreateSubscription(organizationId);
// Map the price ID to our plan
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeSubscription.planId);
if (!plan) {
this.logger.error(`Unknown Stripe price ID: ${stripeSubscription.planId}`);
return;
}
// Update subscription
subscription = subscription.updateStripeCustomerId(customerId);
subscription = subscription.updateStripeSubscription({
stripeSubscriptionId: subscriptionId,
currentPeriodStart: stripeSubscription.currentPeriodStart,
currentPeriodEnd: stripeSubscription.currentPeriodEnd,
cancelAtPeriodEnd: stripeSubscription.cancelAtPeriodEnd,
});
subscription = subscription.updatePlan(
SubscriptionPlan.create(plan),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
);
subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
);
await this.subscriptionRepository.save(subscription);
this.logger.log(
`Updated subscription for organization ${organizationId} to plan ${plan}`,
);
}
private async handleSubscriptionUpdated(
stripeSubscription: Record<string, unknown>,
): Promise<void> {
const subscriptionId = stripeSubscription.id as string;
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
subscriptionId,
);
if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
return;
}
// Get fresh data from Stripe
const stripeData = await this.stripeAdapter.getSubscription(subscriptionId);
if (!stripeData) {
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
return;
}
// Map the price ID to our plan
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
if (plan) {
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
subscription.id,
);
const newPlan = SubscriptionPlan.create(plan);
// Only update plan if it can accommodate current non-ADMIN users
if (newPlan.canAccommodateUsers(usedLicenses)) {
subscription = subscription.updatePlan(newPlan, usedLicenses);
} else {
this.logger.warn(
`Cannot update to plan ${plan} - would exceed license limit`,
);
}
}
subscription = subscription.updateStripeSubscription({
stripeSubscriptionId: subscriptionId,
currentPeriodStart: stripeData.currentPeriodStart,
currentPeriodEnd: stripeData.currentPeriodEnd,
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
});
subscription = subscription.updateStatus(
SubscriptionStatus.fromStripeStatus(stripeData.status),
);
await this.subscriptionRepository.save(subscription);
this.logger.log(`Updated subscription ${subscriptionId}`);
}
private async handleSubscriptionDeleted(
stripeSubscription: Record<string, unknown>,
): Promise<void> {
const subscriptionId = stripeSubscription.id as string;
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
subscriptionId,
);
if (!subscription) {
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
return;
}
// Downgrade to FREE plan - count only non-ADMIN licenses
const canceledSubscription = subscription
.updatePlan(
SubscriptionPlan.free(),
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
)
.updateStatus(SubscriptionStatus.canceled());
await this.subscriptionRepository.save(canceledSubscription);
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
}
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
const customerId = invoice.customer as string;
const subscription = await this.subscriptionRepository.findByStripeCustomerId(
customerId,
);
if (!subscription) {
this.logger.warn(`Subscription for customer ${customerId} not found`);
return;
}
const updatedSubscription = subscription.updateStatus(
SubscriptionStatus.pastDue(),
);
await this.subscriptionRepository.save(updatedSubscription);
this.logger.log(
`Subscription ${subscription.id} marked as past due due to payment failure`,
);
}
private mapLicenseToDto(
license: License,
user: { email: string; firstName: string; lastName: string; role: string } | null,
): LicenseResponseDto {
return {
id: license.id,
userId: license.userId,
userEmail: user?.email || 'Unknown',
userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown User',
userRole: user?.role || 'USER',
status: license.status.value,
assignedAt: license.assignedAt,
revokedAt: license.revokedAt || undefined,
};
}
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
return {
plan: plan.value as SubscriptionPlanDto,
name: plan.name,
maxLicenses: plan.maxLicenses,
monthlyPriceEur: plan.monthlyPriceEur,
yearlyPriceEur: plan.yearlyPriceEur,
features: [...plan.features],
};
}
}

View File

@ -0,0 +1,71 @@
/**
* Subscriptions Module
*
* Provides subscription and license management endpoints.
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
// Controller
import { SubscriptionsController } from '../controllers/subscriptions.controller';
// Service
import { SubscriptionService } from '../services/subscription.service';
// ORM Entities
import { SubscriptionOrmEntity } from '@infrastructure/persistence/typeorm/entities/subscription.orm-entity';
import { LicenseOrmEntity } from '@infrastructure/persistence/typeorm/entities/license.orm-entity';
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
// Repositories
import { TypeOrmSubscriptionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository';
import { TypeOrmLicenseRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-license.repository';
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
// Repository tokens
import { SUBSCRIPTION_REPOSITORY } from '@domain/ports/out/subscription.repository';
import { LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
// Stripe
import { StripeModule } from '@infrastructure/stripe/stripe.module';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([
SubscriptionOrmEntity,
LicenseOrmEntity,
OrganizationOrmEntity,
UserOrmEntity,
]),
StripeModule,
],
controllers: [SubscriptionsController],
providers: [
SubscriptionService,
{
provide: SUBSCRIPTION_REPOSITORY,
useClass: TypeOrmSubscriptionRepository,
},
{
provide: LICENSE_REPOSITORY,
useClass: TypeOrmLicenseRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [SubscriptionService, SUBSCRIPTION_REPOSITORY, LICENSE_REPOSITORY],
})
export class SubscriptionsModule {}

View File

@ -6,10 +6,12 @@ import { UsersController } from '../controllers/users.controller';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
TypeOrmModule.forFeature([UserOrmEntity]),
SubscriptionsModule,
],
controllers: [UsersController],
providers: [

View File

@ -11,3 +11,5 @@ export * from './port.entity';
export * from './rate-quote.entity';
export * from './container.entity';
export * from './booking.entity';
export * from './subscription.entity';
export * from './license.entity';

View File

@ -0,0 +1,270 @@
/**
* License Entity Tests
*
* Unit tests for the License domain entity
*/
import { License } from './license.entity';
describe('License Entity', () => {
const createValidLicense = () => {
return License.create({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
});
};
describe('create', () => {
it('should create a license with valid data', () => {
const license = createValidLicense();
expect(license.id).toBe('license-123');
expect(license.subscriptionId).toBe('sub-123');
expect(license.userId).toBe('user-123');
expect(license.status.value).toBe('ACTIVE');
expect(license.assignedAt).toBeInstanceOf(Date);
expect(license.revokedAt).toBeNull();
});
it('should create a license with different user', () => {
const license = License.create({
id: 'license-456',
subscriptionId: 'sub-123',
userId: 'user-456',
});
expect(license.userId).toBe('user-456');
});
});
describe('fromPersistence', () => {
it('should reconstitute an active license from persistence data', () => {
const assignedAt = new Date('2024-01-15');
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'ACTIVE',
assignedAt,
revokedAt: null,
});
expect(license.id).toBe('license-123');
expect(license.status.value).toBe('ACTIVE');
expect(license.assignedAt).toEqual(assignedAt);
expect(license.revokedAt).toBeNull();
});
it('should reconstitute a revoked license from persistence data', () => {
const revokedAt = new Date('2024-02-01');
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt,
});
expect(license.status.value).toBe('REVOKED');
expect(license.revokedAt).toEqual(revokedAt);
});
});
describe('isActive', () => {
it('should return true for active license', () => {
const license = createValidLicense();
expect(license.isActive()).toBe(true);
});
it('should return false for revoked license', () => {
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt: new Date('2024-02-01'),
});
expect(license.isActive()).toBe(false);
});
});
describe('isRevoked', () => {
it('should return false for active license', () => {
const license = createValidLicense();
expect(license.isRevoked()).toBe(false);
});
it('should return true for revoked license', () => {
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt: new Date('2024-02-01'),
});
expect(license.isRevoked()).toBe(true);
});
});
describe('revoke', () => {
it('should revoke an active license', () => {
const license = createValidLicense();
const revoked = license.revoke();
expect(revoked.status.value).toBe('REVOKED');
expect(revoked.revokedAt).toBeInstanceOf(Date);
expect(revoked.isActive()).toBe(false);
});
it('should throw when revoking an already revoked license', () => {
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt: new Date('2024-02-01'),
});
expect(() => license.revoke()).toThrow('License is already revoked');
});
it('should preserve original data when revoking', () => {
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-456',
userId: 'user-789',
status: 'ACTIVE',
assignedAt: new Date('2024-01-15'),
revokedAt: null,
});
const revoked = license.revoke();
expect(revoked.id).toBe('license-123');
expect(revoked.subscriptionId).toBe('sub-456');
expect(revoked.userId).toBe('user-789');
expect(revoked.assignedAt).toEqual(new Date('2024-01-15'));
});
});
describe('reactivate', () => {
it('should reactivate a revoked license', () => {
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt: new Date('2024-02-01'),
});
const reactivated = license.reactivate();
expect(reactivated.status.value).toBe('ACTIVE');
expect(reactivated.revokedAt).toBeNull();
});
it('should throw when reactivating an active license', () => {
const license = createValidLicense();
expect(() => license.reactivate()).toThrow('License is already active');
});
});
describe('getActiveDuration', () => {
it('should calculate duration for active license', () => {
const assignedAt = new Date();
assignedAt.setHours(assignedAt.getHours() - 1); // 1 hour ago
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'ACTIVE',
assignedAt,
revokedAt: null,
});
const duration = license.getActiveDuration();
// Should be approximately 1 hour in milliseconds (allow some variance)
expect(duration).toBeGreaterThan(3600000 - 1000);
expect(duration).toBeLessThan(3600000 + 1000);
});
it('should calculate duration for revoked license', () => {
const assignedAt = new Date('2024-01-15T10:00:00Z');
const revokedAt = new Date('2024-01-15T12:00:00Z'); // 2 hours later
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt,
revokedAt,
});
const duration = license.getActiveDuration();
expect(duration).toBe(2 * 60 * 60 * 1000); // 2 hours in ms
});
});
describe('toObject', () => {
it('should convert to plain object for persistence', () => {
const license = createValidLicense();
const obj = license.toObject();
expect(obj.id).toBe('license-123');
expect(obj.subscriptionId).toBe('sub-123');
expect(obj.userId).toBe('user-123');
expect(obj.status).toBe('ACTIVE');
expect(obj.assignedAt).toBeInstanceOf(Date);
expect(obj.revokedAt).toBeNull();
});
it('should include revokedAt for revoked license', () => {
const revokedAt = new Date('2024-02-01');
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-123',
userId: 'user-123',
status: 'REVOKED',
assignedAt: new Date('2024-01-15'),
revokedAt,
});
const obj = license.toObject();
expect(obj.status).toBe('REVOKED');
expect(obj.revokedAt).toEqual(revokedAt);
});
});
describe('property accessors', () => {
it('should correctly expose all properties', () => {
const assignedAt = new Date('2024-01-15');
const license = License.fromPersistence({
id: 'license-123',
subscriptionId: 'sub-456',
userId: 'user-789',
status: 'ACTIVE',
assignedAt,
revokedAt: null,
});
expect(license.id).toBe('license-123');
expect(license.subscriptionId).toBe('sub-456');
expect(license.userId).toBe('user-789');
expect(license.status.value).toBe('ACTIVE');
expect(license.assignedAt).toEqual(assignedAt);
expect(license.revokedAt).toBeNull();
});
});
});

View File

@ -0,0 +1,167 @@
/**
* License Entity
*
* Represents a user license within a subscription.
* Each active user in an organization consumes one license.
*/
import {
LicenseStatus,
LicenseStatusType,
} from '../value-objects/license-status.vo';
export interface LicenseProps {
readonly id: string;
readonly subscriptionId: string;
readonly userId: string;
readonly status: LicenseStatus;
readonly assignedAt: Date;
readonly revokedAt: Date | null;
}
export class License {
private readonly props: LicenseProps;
private constructor(props: LicenseProps) {
this.props = props;
}
/**
* Create a new license for a user
*/
static create(props: {
id: string;
subscriptionId: string;
userId: string;
}): License {
return new License({
id: props.id,
subscriptionId: props.subscriptionId,
userId: props.userId,
status: LicenseStatus.active(),
assignedAt: new Date(),
revokedAt: null,
});
}
/**
* Reconstitute from persistence
*/
static fromPersistence(props: {
id: string;
subscriptionId: string;
userId: string;
status: LicenseStatusType;
assignedAt: Date;
revokedAt: Date | null;
}): License {
return new License({
id: props.id,
subscriptionId: props.subscriptionId,
userId: props.userId,
status: LicenseStatus.create(props.status),
assignedAt: props.assignedAt,
revokedAt: props.revokedAt,
});
}
// Getters
get id(): string {
return this.props.id;
}
get subscriptionId(): string {
return this.props.subscriptionId;
}
get userId(): string {
return this.props.userId;
}
get status(): LicenseStatus {
return this.props.status;
}
get assignedAt(): Date {
return this.props.assignedAt;
}
get revokedAt(): Date | null {
return this.props.revokedAt;
}
// Business logic
/**
* Check if the license is currently active
*/
isActive(): boolean {
return this.props.status.isActive();
}
/**
* Check if the license has been revoked
*/
isRevoked(): boolean {
return this.props.status.isRevoked();
}
/**
* Revoke this license
*/
revoke(): License {
if (this.isRevoked()) {
throw new Error('License is already revoked');
}
return new License({
...this.props,
status: LicenseStatus.revoked(),
revokedAt: new Date(),
});
}
/**
* Reactivate a revoked license
*/
reactivate(): License {
if (this.isActive()) {
throw new Error('License is already active');
}
return new License({
...this.props,
status: LicenseStatus.active(),
revokedAt: null,
});
}
/**
* Get the duration the license was/is active
*/
getActiveDuration(): number {
const endTime = this.props.revokedAt ?? new Date();
return endTime.getTime() - this.props.assignedAt.getTime();
}
/**
* Convert to plain object for persistence
*/
toObject(): {
id: string;
subscriptionId: string;
userId: string;
status: LicenseStatusType;
assignedAt: Date;
revokedAt: Date | null;
} {
return {
id: this.props.id,
subscriptionId: this.props.subscriptionId,
userId: this.props.userId,
status: this.props.status.value,
assignedAt: this.props.assignedAt,
revokedAt: this.props.revokedAt,
};
}
}

View File

@ -0,0 +1,405 @@
/**
* Subscription Entity Tests
*
* Unit tests for the Subscription domain entity
*/
import { Subscription } from './subscription.entity';
import { SubscriptionPlan } from '../value-objects/subscription-plan.vo';
import { SubscriptionStatus } from '../value-objects/subscription-status.vo';
import {
InvalidSubscriptionDowngradeException,
SubscriptionNotActiveException,
} from '../exceptions/subscription.exceptions';
describe('Subscription Entity', () => {
const createValidSubscription = () => {
return Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
});
};
describe('create', () => {
it('should create a subscription with default FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.id).toBe('sub-123');
expect(subscription.organizationId).toBe('org-123');
expect(subscription.plan.value).toBe('FREE');
expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(false);
});
it('should create a subscription with custom plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.plan.value).toBe('STARTER');
});
it('should create a subscription with Stripe IDs', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_stripe_123',
});
expect(subscription.stripeCustomerId).toBe('cus_123');
expect(subscription.stripeSubscriptionId).toBe('sub_stripe_123');
});
});
describe('fromPersistence', () => {
it('should reconstitute a subscription from persistence data', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'PRO',
status: 'ACTIVE',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_stripe_123',
currentPeriodStart: new Date('2024-01-01'),
currentPeriodEnd: new Date('2024-02-01'),
cancelAtPeriodEnd: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15'),
});
expect(subscription.id).toBe('sub-123');
expect(subscription.plan.value).toBe('PRO');
expect(subscription.status.value).toBe('ACTIVE');
expect(subscription.cancelAtPeriodEnd).toBe(true);
});
});
describe('maxLicenses', () => {
it('should return correct limits for FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.maxLicenses).toBe(2);
});
it('should return correct limits for STARTER plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.maxLicenses).toBe(5);
});
it('should return correct limits for PRO plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.pro(),
});
expect(subscription.maxLicenses).toBe(20);
});
it('should return -1 for ENTERPRISE plan (unlimited)', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.maxLicenses).toBe(-1);
});
});
describe('isUnlimited', () => {
it('should return false for FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.isUnlimited()).toBe(false);
});
it('should return true for ENTERPRISE plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.isUnlimited()).toBe(true);
});
});
describe('isActive', () => {
it('should return true for ACTIVE status', () => {
const subscription = createValidSubscription();
expect(subscription.isActive()).toBe(true);
});
it('should return true for TRIALING status', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'FREE',
status: 'TRIALING',
stripeCustomerId: null,
stripeSubscriptionId: null,
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(subscription.isActive()).toBe(true);
});
it('should return false for CANCELED status', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(subscription.isActive()).toBe(false);
});
});
describe('canAllocateLicenses', () => {
it('should return true when licenses are available', () => {
const subscription = createValidSubscription();
expect(subscription.canAllocateLicenses(0, 1)).toBe(true);
expect(subscription.canAllocateLicenses(1, 1)).toBe(true);
});
it('should return false when no licenses available', () => {
const subscription = createValidSubscription();
expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses
});
it('should always return true for ENTERPRISE plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.enterprise(),
});
expect(subscription.canAllocateLicenses(1000, 100)).toBe(true);
});
it('should return false when subscription is not active', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(subscription.canAllocateLicenses(0, 1)).toBe(false);
});
});
describe('canUpgradeTo', () => {
it('should allow upgrade from FREE to STARTER', () => {
const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
});
it('should allow upgrade from FREE to PRO', () => {
const subscription = createValidSubscription();
expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should not allow downgrade via canUpgradeTo', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false);
});
});
describe('canDowngradeTo', () => {
it('should allow downgrade when user count fits', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
});
it('should prevent downgrade when user count exceeds new plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
});
});
describe('updatePlan', () => {
it('should update to new plan when valid', () => {
const subscription = createValidSubscription();
const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1);
expect(updated.plan.value).toBe('STARTER');
});
it('should throw when subscription is not active', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'FREE',
status: 'CANCELED',
stripeCustomerId: null,
stripeSubscriptionId: null,
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
SubscriptionNotActiveException,
);
});
it('should throw when downgrading with too many users', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.pro(),
});
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
InvalidSubscriptionDowngradeException,
);
});
});
describe('updateStatus', () => {
it('should update subscription status', () => {
const subscription = createValidSubscription();
const updated = subscription.updateStatus(SubscriptionStatus.pastDue());
expect(updated.status.value).toBe('PAST_DUE');
});
});
describe('updateStripeCustomerId', () => {
it('should update Stripe customer ID', () => {
const subscription = createValidSubscription();
const updated = subscription.updateStripeCustomerId('cus_new_123');
expect(updated.stripeCustomerId).toBe('cus_new_123');
});
});
describe('updateStripeSubscription', () => {
it('should update Stripe subscription details', () => {
const subscription = createValidSubscription();
const periodStart = new Date('2024-02-01');
const periodEnd = new Date('2024-03-01');
const updated = subscription.updateStripeSubscription({
stripeSubscriptionId: 'sub_new_123',
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: true,
});
expect(updated.stripeSubscriptionId).toBe('sub_new_123');
expect(updated.currentPeriodStart).toEqual(periodStart);
expect(updated.currentPeriodEnd).toEqual(periodEnd);
expect(updated.cancelAtPeriodEnd).toBe(true);
});
});
describe('scheduleCancellation', () => {
it('should mark subscription for cancellation', () => {
const subscription = createValidSubscription();
const updated = subscription.scheduleCancellation();
expect(updated.cancelAtPeriodEnd).toBe(true);
});
});
describe('unscheduleCancellation', () => {
it('should unmark subscription for cancellation', () => {
const subscription = Subscription.fromPersistence({
id: 'sub-123',
organizationId: 'org-123',
plan: 'STARTER',
status: 'ACTIVE',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(),
cancelAtPeriodEnd: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const updated = subscription.unscheduleCancellation();
expect(updated.cancelAtPeriodEnd).toBe(false);
});
});
describe('cancel', () => {
it('should cancel the subscription immediately', () => {
const subscription = createValidSubscription();
const updated = subscription.cancel();
expect(updated.status.value).toBe('CANCELED');
expect(updated.cancelAtPeriodEnd).toBe(false);
});
});
describe('isFree and isPaid', () => {
it('should return true for isFree when FREE plan', () => {
const subscription = createValidSubscription();
expect(subscription.isFree()).toBe(true);
expect(subscription.isPaid()).toBe(false);
});
it('should return true for isPaid when STARTER plan', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
plan: SubscriptionPlan.starter(),
});
expect(subscription.isFree()).toBe(false);
expect(subscription.isPaid()).toBe(true);
});
});
describe('toObject', () => {
it('should convert to plain object for persistence', () => {
const subscription = Subscription.create({
id: 'sub-123',
organizationId: 'org-123',
stripeCustomerId: 'cus_123',
});
const obj = subscription.toObject();
expect(obj.id).toBe('sub-123');
expect(obj.organizationId).toBe('org-123');
expect(obj.plan).toBe('FREE');
expect(obj.status).toBe('ACTIVE');
expect(obj.stripeCustomerId).toBe('cus_123');
});
});
});

View File

@ -0,0 +1,355 @@
/**
* Subscription Entity
*
* Represents an organization's subscription, including their plan,
* Stripe integration, and billing period information.
*/
import {
SubscriptionPlan,
SubscriptionPlanType,
} from '../value-objects/subscription-plan.vo';
import {
SubscriptionStatus,
SubscriptionStatusType,
} from '../value-objects/subscription-status.vo';
import {
InvalidSubscriptionDowngradeException,
SubscriptionNotActiveException,
} from '../exceptions/subscription.exceptions';
export interface SubscriptionProps {
readonly id: string;
readonly organizationId: string;
readonly plan: SubscriptionPlan;
readonly status: SubscriptionStatus;
readonly stripeCustomerId: string | null;
readonly stripeSubscriptionId: string | null;
readonly currentPeriodStart: Date | null;
readonly currentPeriodEnd: Date | null;
readonly cancelAtPeriodEnd: boolean;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export class Subscription {
private readonly props: SubscriptionProps;
private constructor(props: SubscriptionProps) {
this.props = props;
}
/**
* Create a new subscription (defaults to FREE plan)
*/
static create(props: {
id: string;
organizationId: string;
plan?: SubscriptionPlan;
stripeCustomerId?: string | null;
stripeSubscriptionId?: string | null;
}): Subscription {
const now = new Date();
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: props.plan ?? SubscriptionPlan.free(),
status: SubscriptionStatus.active(),
stripeCustomerId: props.stripeCustomerId ?? null,
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
currentPeriodStart: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
createdAt: now,
updatedAt: now,
});
}
/**
* Reconstitute from persistence
*/
static fromPersistence(props: {
id: string;
organizationId: string;
plan: SubscriptionPlanType;
status: SubscriptionStatusType;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
createdAt: Date;
updatedAt: Date;
}): Subscription {
return new Subscription({
id: props.id,
organizationId: props.organizationId,
plan: SubscriptionPlan.create(props.plan),
status: SubscriptionStatus.create(props.status),
stripeCustomerId: props.stripeCustomerId,
stripeSubscriptionId: props.stripeSubscriptionId,
currentPeriodStart: props.currentPeriodStart,
currentPeriodEnd: props.currentPeriodEnd,
cancelAtPeriodEnd: props.cancelAtPeriodEnd,
createdAt: props.createdAt,
updatedAt: props.updatedAt,
});
}
// Getters
get id(): string {
return this.props.id;
}
get organizationId(): string {
return this.props.organizationId;
}
get plan(): SubscriptionPlan {
return this.props.plan;
}
get status(): SubscriptionStatus {
return this.props.status;
}
get stripeCustomerId(): string | null {
return this.props.stripeCustomerId;
}
get stripeSubscriptionId(): string | null {
return this.props.stripeSubscriptionId;
}
get currentPeriodStart(): Date | null {
return this.props.currentPeriodStart;
}
get currentPeriodEnd(): Date | null {
return this.props.currentPeriodEnd;
}
get cancelAtPeriodEnd(): boolean {
return this.props.cancelAtPeriodEnd;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
// Business logic
/**
* Get the maximum number of licenses allowed by this subscription
*/
get maxLicenses(): number {
return this.props.plan.maxLicenses;
}
/**
* Check if the subscription has unlimited licenses
*/
isUnlimited(): boolean {
return this.props.plan.isUnlimited();
}
/**
* Check if the subscription is active and allows access
*/
isActive(): boolean {
return this.props.status.allowsAccess();
}
/**
* Check if the subscription is in good standing
*/
isInGoodStanding(): boolean {
return this.props.status.isInGoodStanding();
}
/**
* Check if the subscription requires user action
*/
requiresAction(): boolean {
return this.props.status.requiresAction();
}
/**
* Check if this is a free subscription
*/
isFree(): boolean {
return this.props.plan.isFree();
}
/**
* Check if this is a paid subscription
*/
isPaid(): boolean {
return this.props.plan.isPaid();
}
/**
* Check if the subscription is scheduled to be canceled
*/
isScheduledForCancellation(): boolean {
return this.props.cancelAtPeriodEnd;
}
/**
* Check if a given number of licenses can be allocated
*/
canAllocateLicenses(currentCount: number, additionalCount: number = 1): boolean {
if (!this.isActive()) return false;
if (this.isUnlimited()) return true;
return currentCount + additionalCount <= this.maxLicenses;
}
/**
* Check if upgrade to target plan is possible
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
return this.props.plan.canUpgradeTo(targetPlan);
}
/**
* Check if downgrade to target plan is possible given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
return this.props.plan.canDowngradeTo(targetPlan, currentUserCount);
}
/**
* Update the subscription plan
*/
updatePlan(newPlan: SubscriptionPlan, currentUserCount: number): Subscription {
if (!this.isActive()) {
throw new SubscriptionNotActiveException(this.props.id, this.props.status.value);
}
// Check if downgrade is valid
if (!newPlan.canAccommodateUsers(currentUserCount)) {
throw new InvalidSubscriptionDowngradeException(
this.props.plan.value,
newPlan.value,
currentUserCount,
newPlan.maxLicenses,
);
}
return new Subscription({
...this.props,
plan: newPlan,
updatedAt: new Date(),
});
}
/**
* Update subscription status
*/
updateStatus(newStatus: SubscriptionStatus): Subscription {
return new Subscription({
...this.props,
status: newStatus,
updatedAt: new Date(),
});
}
/**
* Update Stripe customer ID
*/
updateStripeCustomerId(stripeCustomerId: string): Subscription {
return new Subscription({
...this.props,
stripeCustomerId,
updatedAt: new Date(),
});
}
/**
* Update Stripe subscription details
*/
updateStripeSubscription(params: {
stripeSubscriptionId: string;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd?: boolean;
}): Subscription {
return new Subscription({
...this.props,
stripeSubscriptionId: params.stripeSubscriptionId,
currentPeriodStart: params.currentPeriodStart,
currentPeriodEnd: params.currentPeriodEnd,
cancelAtPeriodEnd: params.cancelAtPeriodEnd ?? this.props.cancelAtPeriodEnd,
updatedAt: new Date(),
});
}
/**
* Mark subscription as scheduled for cancellation at period end
*/
scheduleCancellation(): Subscription {
return new Subscription({
...this.props,
cancelAtPeriodEnd: true,
updatedAt: new Date(),
});
}
/**
* Unschedule cancellation
*/
unscheduleCancellation(): Subscription {
return new Subscription({
...this.props,
cancelAtPeriodEnd: false,
updatedAt: new Date(),
});
}
/**
* Cancel the subscription immediately
*/
cancel(): Subscription {
return new Subscription({
...this.props,
status: SubscriptionStatus.canceled(),
cancelAtPeriodEnd: false,
updatedAt: new Date(),
});
}
/**
* Convert to plain object for persistence
*/
toObject(): {
id: string;
organizationId: string;
plan: SubscriptionPlanType;
status: SubscriptionStatusType;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
cancelAtPeriodEnd: boolean;
createdAt: Date;
updatedAt: Date;
} {
return {
id: this.props.id,
organizationId: this.props.organizationId,
plan: this.props.plan.value,
status: this.props.status.value,
stripeCustomerId: this.props.stripeCustomerId,
stripeSubscriptionId: this.props.stripeSubscriptionId,
currentPeriodStart: this.props.currentPeriodStart,
currentPeriodEnd: this.props.currentPeriodEnd,
cancelAtPeriodEnd: this.props.cancelAtPeriodEnd,
createdAt: this.props.createdAt,
updatedAt: this.props.updatedAt,
};
}
}

View File

@ -10,3 +10,4 @@ export * from './carrier-timeout.exception';
export * from './carrier-unavailable.exception';
export * from './rate-quote-expired.exception';
export * from './port-not-found.exception';
export * from './subscription.exceptions';

View File

@ -0,0 +1,85 @@
/**
* Subscription Domain Exceptions
*/
export class NoLicensesAvailableException extends Error {
constructor(
public readonly organizationId: string,
public readonly currentLicenses: number,
public readonly maxLicenses: number,
) {
super(
`No licenses available for organization ${organizationId}. ` +
`Currently using ${currentLicenses}/${maxLicenses} licenses.`,
);
this.name = 'NoLicensesAvailableException';
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
}
}
export class SubscriptionNotFoundException extends Error {
constructor(public readonly identifier: string) {
super(`Subscription not found: ${identifier}`);
this.name = 'SubscriptionNotFoundException';
Object.setPrototypeOf(this, SubscriptionNotFoundException.prototype);
}
}
export class LicenseNotFoundException extends Error {
constructor(public readonly identifier: string) {
super(`License not found: ${identifier}`);
this.name = 'LicenseNotFoundException';
Object.setPrototypeOf(this, LicenseNotFoundException.prototype);
}
}
export class LicenseAlreadyAssignedException extends Error {
constructor(public readonly userId: string) {
super(`User ${userId} already has an assigned license`);
this.name = 'LicenseAlreadyAssignedException';
Object.setPrototypeOf(this, LicenseAlreadyAssignedException.prototype);
}
}
export class InvalidSubscriptionDowngradeException extends Error {
constructor(
public readonly currentPlan: string,
public readonly targetPlan: string,
public readonly currentUsers: number,
public readonly targetMaxLicenses: number,
) {
super(
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
);
this.name = 'InvalidSubscriptionDowngradeException';
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
}
}
export class SubscriptionNotActiveException extends Error {
constructor(
public readonly subscriptionId: string,
public readonly currentStatus: string,
) {
super(
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
);
this.name = 'SubscriptionNotActiveException';
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
}
}
export class InvalidSubscriptionStatusTransitionException extends Error {
constructor(
public readonly fromStatus: string,
public readonly toStatus: string,
) {
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
this.name = 'InvalidSubscriptionStatusTransitionException';
Object.setPrototypeOf(
this,
InvalidSubscriptionStatusTransitionException.prototype,
);
}
}

View File

@ -23,3 +23,6 @@ export * from './pdf.port';
export * from './storage.port';
export * from './carrier-connector.port';
export * from './csv-rate-loader.port';
export * from './subscription.repository';
export * from './license.repository';
export * from './stripe.port';

View File

@ -0,0 +1,62 @@
/**
* License Repository Port
*
* Interface for license persistence operations.
*/
import { License } from '../../entities/license.entity';
export const LICENSE_REPOSITORY = 'LICENSE_REPOSITORY';
export interface LicenseRepository {
/**
* Save a license (create or update)
*/
save(license: License): Promise<License>;
/**
* Find a license by its ID
*/
findById(id: string): Promise<License | null>;
/**
* Find a license by user ID
*/
findByUserId(userId: string): Promise<License | null>;
/**
* Find all licenses for a subscription
*/
findBySubscriptionId(subscriptionId: string): Promise<License[]>;
/**
* Find all active licenses for a subscription
*/
findActiveBySubscriptionId(subscriptionId: string): Promise<License[]>;
/**
* Count active licenses for a subscription
*/
countActiveBySubscriptionId(subscriptionId: string): Promise<number>;
/**
* Count active licenses for a subscription, excluding ADMIN users
* ADMIN users have unlimited licenses and don't consume the organization's quota
*/
countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number>;
/**
* Find all active licenses for a subscription, excluding ADMIN users
*/
findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]>;
/**
* Delete a license
*/
delete(id: string): Promise<void>;
/**
* Delete all licenses for a subscription
*/
deleteBySubscriptionId(subscriptionId: string): Promise<void>;
}

View File

@ -0,0 +1,113 @@
/**
* Stripe Port
*
* Interface for Stripe payment integration.
*/
import { SubscriptionPlanType } from '../../value-objects/subscription-plan.vo';
export const STRIPE_PORT = 'STRIPE_PORT';
export interface CreateCheckoutSessionInput {
organizationId: string;
organizationName: string;
email: string;
plan: SubscriptionPlanType;
billingInterval: 'monthly' | 'yearly';
successUrl: string;
cancelUrl: string;
customerId?: string;
}
export interface CreateCheckoutSessionOutput {
sessionId: string;
sessionUrl: string;
}
export interface CreatePortalSessionInput {
customerId: string;
returnUrl: string;
}
export interface CreatePortalSessionOutput {
sessionUrl: string;
}
export interface StripeSubscriptionData {
subscriptionId: string;
customerId: string;
status: string;
planId: string;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
}
export interface StripeCheckoutSessionData {
sessionId: string;
customerId: string | null;
subscriptionId: string | null;
status: string;
metadata: Record<string, string>;
}
export interface StripeWebhookEvent {
type: string;
data: {
object: Record<string, unknown>;
};
}
export interface StripePort {
/**
* Create a Stripe Checkout session for subscription purchase
*/
createCheckoutSession(
input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput>;
/**
* Create a Stripe Customer Portal session for subscription management
*/
createPortalSession(
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput>;
/**
* Retrieve subscription details from Stripe
*/
getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null>;
/**
* Retrieve checkout session details from Stripe
*/
getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null>;
/**
* Cancel a subscription at period end
*/
cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void>;
/**
* Cancel a subscription immediately
*/
cancelSubscriptionImmediately(subscriptionId: string): Promise<void>;
/**
* Resume a canceled subscription
*/
resumeSubscription(subscriptionId: string): Promise<void>;
/**
* Verify and parse a Stripe webhook event
*/
constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent>;
/**
* Map a Stripe price ID to a subscription plan
*/
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null;
}

View File

@ -0,0 +1,46 @@
/**
* Subscription Repository Port
*
* Interface for subscription persistence operations.
*/
import { Subscription } from '../../entities/subscription.entity';
export const SUBSCRIPTION_REPOSITORY = 'SUBSCRIPTION_REPOSITORY';
export interface SubscriptionRepository {
/**
* Save a subscription (create or update)
*/
save(subscription: Subscription): Promise<Subscription>;
/**
* Find a subscription by its ID
*/
findById(id: string): Promise<Subscription | null>;
/**
* Find a subscription by organization ID
*/
findByOrganizationId(organizationId: string): Promise<Subscription | null>;
/**
* Find a subscription by Stripe subscription ID
*/
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null>;
/**
* Find a subscription by Stripe customer ID
*/
findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null>;
/**
* Find all subscriptions
*/
findAll(): Promise<Subscription[]>;
/**
* Delete a subscription
*/
delete(id: string): Promise<void>;
}

View File

@ -11,3 +11,6 @@ export * from './container-type.vo';
export * from './date-range.vo';
export * from './booking-number.vo';
export * from './booking-status.vo';
export * from './subscription-plan.vo';
export * from './subscription-status.vo';
export * from './license-status.vo';

View File

@ -0,0 +1,74 @@
/**
* License Status Value Object
*
* Represents the status of a user license within a subscription.
*/
export type LicenseStatusType = 'ACTIVE' | 'REVOKED';
export class LicenseStatus {
private constructor(private readonly status: LicenseStatusType) {}
static create(status: LicenseStatusType): LicenseStatus {
if (status !== 'ACTIVE' && status !== 'REVOKED') {
throw new Error(`Invalid license status: ${status}`);
}
return new LicenseStatus(status);
}
static fromString(value: string): LicenseStatus {
const upperValue = value.toUpperCase() as LicenseStatusType;
if (upperValue !== 'ACTIVE' && upperValue !== 'REVOKED') {
throw new Error(`Invalid license status: ${value}`);
}
return new LicenseStatus(upperValue);
}
static active(): LicenseStatus {
return new LicenseStatus('ACTIVE');
}
static revoked(): LicenseStatus {
return new LicenseStatus('REVOKED');
}
get value(): LicenseStatusType {
return this.status;
}
isActive(): boolean {
return this.status === 'ACTIVE';
}
isRevoked(): boolean {
return this.status === 'REVOKED';
}
/**
* Revoke this license, returning a new revoked status
*/
revoke(): LicenseStatus {
if (this.status === 'REVOKED') {
throw new Error('License is already revoked');
}
return LicenseStatus.revoked();
}
/**
* Reactivate this license, returning a new active status
*/
reactivate(): LicenseStatus {
if (this.status === 'ACTIVE') {
throw new Error('License is already active');
}
return LicenseStatus.active();
}
equals(other: LicenseStatus): boolean {
return this.status === other.status;
}
toString(): string {
return this.status;
}
}

View File

@ -0,0 +1,223 @@
/**
* SubscriptionPlan Value Object Tests
*
* Unit tests for the SubscriptionPlan value object
*/
import { SubscriptionPlan } from './subscription-plan.vo';
describe('SubscriptionPlan Value Object', () => {
describe('static factory methods', () => {
it('should create FREE plan', () => {
const plan = SubscriptionPlan.free();
expect(plan.value).toBe('FREE');
});
it('should create STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.value).toBe('STARTER');
});
it('should create PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.value).toBe('PRO');
});
it('should create ENTERPRISE plan', () => {
const plan = SubscriptionPlan.enterprise();
expect(plan.value).toBe('ENTERPRISE');
});
});
describe('create', () => {
it('should create plan from valid type', () => {
const plan = SubscriptionPlan.create('STARTER');
expect(plan.value).toBe('STARTER');
});
it('should throw for invalid plan type', () => {
expect(() => SubscriptionPlan.create('INVALID' as any)).toThrow('Invalid subscription plan');
});
});
describe('fromString', () => {
it('should create plan from lowercase string', () => {
const plan = SubscriptionPlan.fromString('starter');
expect(plan.value).toBe('STARTER');
});
it('should throw for invalid string', () => {
expect(() => SubscriptionPlan.fromString('invalid')).toThrow('Invalid subscription plan');
});
});
describe('maxLicenses', () => {
it('should return 2 for FREE plan', () => {
const plan = SubscriptionPlan.free();
expect(plan.maxLicenses).toBe(2);
});
it('should return 5 for STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.maxLicenses).toBe(5);
});
it('should return 20 for PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.maxLicenses).toBe(20);
});
it('should return -1 (unlimited) for ENTERPRISE plan', () => {
const plan = SubscriptionPlan.enterprise();
expect(plan.maxLicenses).toBe(-1);
});
});
describe('isUnlimited', () => {
it('should return false for FREE plan', () => {
expect(SubscriptionPlan.free().isUnlimited()).toBe(false);
});
it('should return false for STARTER plan', () => {
expect(SubscriptionPlan.starter().isUnlimited()).toBe(false);
});
it('should return false for PRO plan', () => {
expect(SubscriptionPlan.pro().isUnlimited()).toBe(false);
});
it('should return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true);
});
});
describe('isPaid', () => {
it('should return false for FREE plan', () => {
expect(SubscriptionPlan.free().isPaid()).toBe(false);
});
it('should return true for STARTER plan', () => {
expect(SubscriptionPlan.starter().isPaid()).toBe(true);
});
it('should return true for PRO plan', () => {
expect(SubscriptionPlan.pro().isPaid()).toBe(true);
});
it('should return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().isPaid()).toBe(true);
});
});
describe('isFree', () => {
it('should return true for FREE plan', () => {
expect(SubscriptionPlan.free().isFree()).toBe(true);
});
it('should return false for STARTER plan', () => {
expect(SubscriptionPlan.starter().isFree()).toBe(false);
});
});
describe('canAccommodateUsers', () => {
it('should return true for FREE plan with 2 users', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true);
});
it('should return false for FREE plan with 3 users', () => {
expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false);
});
it('should return true for STARTER plan with 5 users', () => {
expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true);
});
it('should always return true for ENTERPRISE plan', () => {
expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true);
});
});
describe('canUpgradeTo', () => {
it('should allow upgrade from FREE to STARTER', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
});
it('should allow upgrade from FREE to PRO', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should allow upgrade from FREE to ENTERPRISE', () => {
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true);
});
it('should allow upgrade from STARTER to PRO', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
});
it('should not allow downgrade from STARTER to FREE', () => {
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false);
});
it('should not allow same plan upgrade', () => {
expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false);
});
});
describe('canDowngradeTo', () => {
it('should allow downgrade from STARTER to FREE when users fit', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
});
it('should not allow downgrade from STARTER to FREE when users exceed', () => {
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
});
it('should not allow upgrade via canDowngradeTo', () => {
expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false);
});
});
describe('plan details', () => {
it('should return correct name for FREE plan', () => {
expect(SubscriptionPlan.free().name).toBe('Free');
});
it('should return correct prices for STARTER plan', () => {
const plan = SubscriptionPlan.starter();
expect(plan.monthlyPriceEur).toBe(49);
expect(plan.yearlyPriceEur).toBe(470);
});
it('should return features for PRO plan', () => {
const plan = SubscriptionPlan.pro();
expect(plan.features).toContain('Up to 20 users');
expect(plan.features).toContain('API access');
});
});
describe('getAllPlans', () => {
it('should return all 4 plans', () => {
const plans = SubscriptionPlan.getAllPlans();
expect(plans).toHaveLength(4);
expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']);
});
});
describe('equals', () => {
it('should return true for same plan', () => {
expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true);
});
it('should return false for different plans', () => {
expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false);
});
});
describe('toString', () => {
it('should return plan value as string', () => {
expect(SubscriptionPlan.free().toString()).toBe('FREE');
expect(SubscriptionPlan.starter().toString()).toBe('STARTER');
});
});
});

View File

@ -0,0 +1,203 @@
/**
* Subscription Plan Value Object
*
* Represents the different subscription plans available for organizations.
* Each plan has a maximum number of licenses that determine how many users
* can be active in an organization.
*/
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
interface PlanDetails {
readonly name: string;
readonly maxLicenses: number; // -1 means unlimited
readonly monthlyPriceEur: number;
readonly yearlyPriceEur: number;
readonly features: readonly string[];
}
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
FREE: {
name: 'Free',
maxLicenses: 2,
monthlyPriceEur: 0,
yearlyPriceEur: 0,
features: [
'Up to 2 users',
'Basic rate search',
'Email support',
],
},
STARTER: {
name: 'Starter',
maxLicenses: 5,
monthlyPriceEur: 49,
yearlyPriceEur: 470, // ~20% discount
features: [
'Up to 5 users',
'Advanced rate search',
'CSV imports',
'Priority email support',
],
},
PRO: {
name: 'Pro',
maxLicenses: 20,
monthlyPriceEur: 149,
yearlyPriceEur: 1430, // ~20% discount
features: [
'Up to 20 users',
'All Starter features',
'API access',
'Custom integrations',
'Phone support',
],
},
ENTERPRISE: {
name: 'Enterprise',
maxLicenses: -1, // unlimited
monthlyPriceEur: 0, // custom pricing
yearlyPriceEur: 0, // custom pricing
features: [
'Unlimited users',
'All Pro features',
'Dedicated account manager',
'Custom SLA',
'On-premise deployment option',
],
},
};
export class SubscriptionPlan {
private constructor(private readonly plan: SubscriptionPlanType) {}
static create(plan: SubscriptionPlanType): SubscriptionPlan {
if (!PLAN_DETAILS[plan]) {
throw new Error(`Invalid subscription plan: ${plan}`);
}
return new SubscriptionPlan(plan);
}
static fromString(value: string): SubscriptionPlan {
const upperValue = value.toUpperCase() as SubscriptionPlanType;
if (!PLAN_DETAILS[upperValue]) {
throw new Error(`Invalid subscription plan: ${value}`);
}
return new SubscriptionPlan(upperValue);
}
static free(): SubscriptionPlan {
return new SubscriptionPlan('FREE');
}
static starter(): SubscriptionPlan {
return new SubscriptionPlan('STARTER');
}
static pro(): SubscriptionPlan {
return new SubscriptionPlan('PRO');
}
static enterprise(): SubscriptionPlan {
return new SubscriptionPlan('ENTERPRISE');
}
static getAllPlans(): SubscriptionPlan[] {
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
);
}
get value(): SubscriptionPlanType {
return this.plan;
}
get name(): string {
return PLAN_DETAILS[this.plan].name;
}
get maxLicenses(): number {
return PLAN_DETAILS[this.plan].maxLicenses;
}
get monthlyPriceEur(): number {
return PLAN_DETAILS[this.plan].monthlyPriceEur;
}
get yearlyPriceEur(): number {
return PLAN_DETAILS[this.plan].yearlyPriceEur;
}
get features(): readonly string[] {
return PLAN_DETAILS[this.plan].features;
}
/**
* Returns true if this plan has unlimited licenses
*/
isUnlimited(): boolean {
return this.maxLicenses === -1;
}
/**
* Returns true if this is a paid plan
*/
isPaid(): boolean {
return this.plan !== 'FREE';
}
/**
* Returns true if this is the free plan
*/
isFree(): boolean {
return this.plan === 'FREE';
}
/**
* Check if a given number of users can be accommodated by this plan
*/
canAccommodateUsers(userCount: number): boolean {
if (this.isUnlimited()) return true;
return userCount <= this.maxLicenses;
}
/**
* Check if upgrade to target plan is allowed
*/
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);
return targetIndex > currentIndex;
}
/**
* Check if downgrade to target plan is allowed given current user count
*/
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
const planOrder: SubscriptionPlanType[] = [
'FREE',
'STARTER',
'PRO',
'ENTERPRISE',
];
const currentIndex = planOrder.indexOf(this.plan);
const targetIndex = planOrder.indexOf(targetPlan.value);
if (targetIndex >= currentIndex) return false; // Not a downgrade
return targetPlan.canAccommodateUsers(currentUserCount);
}
equals(other: SubscriptionPlan): boolean {
return this.plan === other.plan;
}
toString(): string {
return this.plan;
}
}

View File

@ -0,0 +1,215 @@
/**
* Subscription Status Value Object
*
* Represents the different statuses a subscription can have.
* Follows Stripe subscription lifecycle states.
*/
export type SubscriptionStatusType =
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCELED'
| 'INCOMPLETE'
| 'INCOMPLETE_EXPIRED'
| 'TRIALING'
| 'UNPAID'
| 'PAUSED';
interface StatusDetails {
readonly label: string;
readonly description: string;
readonly allowsAccess: boolean;
readonly requiresAction: boolean;
}
const STATUS_DETAILS: Record<SubscriptionStatusType, StatusDetails> = {
ACTIVE: {
label: 'Active',
description: 'Subscription is active and fully paid',
allowsAccess: true,
requiresAction: false,
},
PAST_DUE: {
label: 'Past Due',
description: 'Payment failed but subscription still active. Action required.',
allowsAccess: true, // Grace period
requiresAction: true,
},
CANCELED: {
label: 'Canceled',
description: 'Subscription has been canceled',
allowsAccess: false,
requiresAction: false,
},
INCOMPLETE: {
label: 'Incomplete',
description: 'Initial payment failed during subscription creation',
allowsAccess: false,
requiresAction: true,
},
INCOMPLETE_EXPIRED: {
label: 'Incomplete Expired',
description: 'Subscription creation payment window expired',
allowsAccess: false,
requiresAction: false,
},
TRIALING: {
label: 'Trialing',
description: 'Subscription is in trial period',
allowsAccess: true,
requiresAction: false,
},
UNPAID: {
label: 'Unpaid',
description: 'All payment retry attempts have failed',
allowsAccess: false,
requiresAction: true,
},
PAUSED: {
label: 'Paused',
description: 'Subscription has been paused',
allowsAccess: false,
requiresAction: false,
},
};
// Status transitions that are valid
const VALID_TRANSITIONS: Record<SubscriptionStatusType, SubscriptionStatusType[]> = {
ACTIVE: ['PAST_DUE', 'CANCELED', 'PAUSED'],
PAST_DUE: ['ACTIVE', 'CANCELED', 'UNPAID'],
CANCELED: [], // Terminal state
INCOMPLETE: ['ACTIVE', 'INCOMPLETE_EXPIRED'],
INCOMPLETE_EXPIRED: [], // Terminal state
TRIALING: ['ACTIVE', 'PAST_DUE', 'CANCELED'],
UNPAID: ['ACTIVE', 'CANCELED'],
PAUSED: ['ACTIVE', 'CANCELED'],
};
export class SubscriptionStatus {
private constructor(private readonly status: SubscriptionStatusType) {}
static create(status: SubscriptionStatusType): SubscriptionStatus {
if (!STATUS_DETAILS[status]) {
throw new Error(`Invalid subscription status: ${status}`);
}
return new SubscriptionStatus(status);
}
static fromString(value: string): SubscriptionStatus {
const upperValue = value.toUpperCase().replace(/-/g, '_') as SubscriptionStatusType;
if (!STATUS_DETAILS[upperValue]) {
throw new Error(`Invalid subscription status: ${value}`);
}
return new SubscriptionStatus(upperValue);
}
static fromStripeStatus(stripeStatus: string): SubscriptionStatus {
// Map Stripe status to our internal status
const mapping: Record<string, SubscriptionStatusType> = {
active: 'ACTIVE',
past_due: 'PAST_DUE',
canceled: 'CANCELED',
incomplete: 'INCOMPLETE',
incomplete_expired: 'INCOMPLETE_EXPIRED',
trialing: 'TRIALING',
unpaid: 'UNPAID',
paused: 'PAUSED',
};
const mappedStatus = mapping[stripeStatus.toLowerCase()];
if (!mappedStatus) {
throw new Error(`Unknown Stripe subscription status: ${stripeStatus}`);
}
return new SubscriptionStatus(mappedStatus);
}
static active(): SubscriptionStatus {
return new SubscriptionStatus('ACTIVE');
}
static canceled(): SubscriptionStatus {
return new SubscriptionStatus('CANCELED');
}
static pastDue(): SubscriptionStatus {
return new SubscriptionStatus('PAST_DUE');
}
static trialing(): SubscriptionStatus {
return new SubscriptionStatus('TRIALING');
}
get value(): SubscriptionStatusType {
return this.status;
}
get label(): string {
return STATUS_DETAILS[this.status].label;
}
get description(): string {
return STATUS_DETAILS[this.status].description;
}
/**
* Returns true if this status allows access to the platform
*/
allowsAccess(): boolean {
return STATUS_DETAILS[this.status].allowsAccess;
}
/**
* Returns true if this status requires user action (e.g., update payment method)
*/
requiresAction(): boolean {
return STATUS_DETAILS[this.status].requiresAction;
}
/**
* Returns true if this is a terminal state (cannot transition out)
*/
isTerminal(): boolean {
return VALID_TRANSITIONS[this.status].length === 0;
}
/**
* Returns true if the subscription is in good standing
*/
isInGoodStanding(): boolean {
return this.status === 'ACTIVE' || this.status === 'TRIALING';
}
/**
* Check if transition to new status is valid
*/
canTransitionTo(newStatus: SubscriptionStatus): boolean {
return VALID_TRANSITIONS[this.status].includes(newStatus.value);
}
/**
* Transition to new status if valid
*/
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
if (!this.canTransitionTo(newStatus)) {
throw new Error(
`Invalid status transition from ${this.status} to ${newStatus.value}`,
);
}
return newStatus;
}
equals(other: SubscriptionStatus): boolean {
return this.status === other.status;
}
toString(): string {
return this.status;
}
/**
* Convert to Stripe-compatible status string
*/
toStripeStatus(): string {
return this.status.toLowerCase().replace(/_/g, '-');
}
}

View File

@ -10,3 +10,5 @@ export * from './carrier.orm-entity';
export * from './port.orm-entity';
export * from './rate-quote.orm-entity';
export * from './csv-rate-config.orm-entity';
export * from './subscription.orm-entity';
export * from './license.orm-entity';

View File

@ -0,0 +1,60 @@
/**
* License ORM Entity (Infrastructure Layer)
*
* TypeORM entity for license persistence.
* Represents user licenses linked to subscriptions.
*/
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { SubscriptionOrmEntity } from './subscription.orm-entity';
import { UserOrmEntity } from './user.orm-entity';
export type LicenseStatusOrmType = 'ACTIVE' | 'REVOKED';
@Entity('licenses')
@Index('idx_licenses_subscription_id', ['subscriptionId'])
@Index('idx_licenses_user_id', ['userId'])
@Index('idx_licenses_status', ['status'])
@Index('idx_licenses_subscription_status', ['subscriptionId', 'status'])
export class LicenseOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'subscription_id', type: 'uuid' })
subscriptionId: string;
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'subscription_id' })
subscription: SubscriptionOrmEntity;
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
// Status
@Column({
type: 'enum',
enum: ['ACTIVE', 'REVOKED'],
default: 'ACTIVE',
})
status: LicenseStatusOrmType;
// Timestamps
@Column({ name: 'assigned_at', type: 'timestamp', default: () => 'NOW()' })
assignedAt: Date;
@Column({ name: 'revoked_at', type: 'timestamp', nullable: true })
revokedAt: Date | null;
}

View File

@ -0,0 +1,108 @@
/**
* Subscription ORM Entity (Infrastructure Layer)
*
* TypeORM entity for subscription persistence.
* Represents organization subscriptions with plan and Stripe integration.
*/
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { OrganizationOrmEntity } from './organization.orm-entity';
import { LicenseOrmEntity } from './license.orm-entity';
export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
export type SubscriptionStatusOrmType =
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCELED'
| 'INCOMPLETE'
| 'INCOMPLETE_EXPIRED'
| 'TRIALING'
| 'UNPAID'
| 'PAUSED';
@Entity('subscriptions')
@Index('idx_subscriptions_organization_id', ['organizationId'])
@Index('idx_subscriptions_stripe_customer_id', ['stripeCustomerId'])
@Index('idx_subscriptions_stripe_subscription_id', ['stripeSubscriptionId'])
@Index('idx_subscriptions_plan', ['plan'])
@Index('idx_subscriptions_status', ['status'])
export class SubscriptionOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'organization_id', type: 'uuid', unique: true })
organizationId: string;
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization: OrganizationOrmEntity;
// Plan information
@Column({
type: 'enum',
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
default: 'FREE',
})
plan: SubscriptionPlanOrmType;
@Column({
type: 'enum',
enum: [
'ACTIVE',
'PAST_DUE',
'CANCELED',
'INCOMPLETE',
'INCOMPLETE_EXPIRED',
'TRIALING',
'UNPAID',
'PAUSED',
],
default: 'ACTIVE',
})
status: SubscriptionStatusOrmType;
// Stripe integration
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
stripeCustomerId: string | null;
@Column({
name: 'stripe_subscription_id',
type: 'varchar',
length: 255,
nullable: true,
unique: true,
})
stripeSubscriptionId: string | null;
// Billing period
@Column({ name: 'current_period_start', type: 'timestamp', nullable: true })
currentPeriodStart: Date | null;
@Column({ name: 'current_period_end', type: 'timestamp', nullable: true })
currentPeriodEnd: Date | null;
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
cancelAtPeriodEnd: boolean;
// Timestamps
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Relations
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
licenses: LicenseOrmEntity[];
}

View File

@ -9,3 +9,5 @@ export * from './user-orm.mapper';
export * from './carrier-orm.mapper';
export * from './port-orm.mapper';
export * from './rate-quote-orm.mapper';
export * from './subscription-orm.mapper';
export * from './license-orm.mapper';

View File

@ -0,0 +1,48 @@
/**
* License ORM Mapper
*
* Maps between License domain entity and LicenseOrmEntity
*/
import { License } from '@domain/entities/license.entity';
import { LicenseOrmEntity } from '../entities/license.orm-entity';
export class LicenseOrmMapper {
/**
* Map domain entity to ORM entity
*/
static toOrm(domain: License): LicenseOrmEntity {
const orm = new LicenseOrmEntity();
const props = domain.toObject();
orm.id = props.id;
orm.subscriptionId = props.subscriptionId;
orm.userId = props.userId;
orm.status = props.status;
orm.assignedAt = props.assignedAt;
orm.revokedAt = props.revokedAt;
return orm;
}
/**
* Map ORM entity to domain entity
*/
static toDomain(orm: LicenseOrmEntity): License {
return License.fromPersistence({
id: orm.id,
subscriptionId: orm.subscriptionId,
userId: orm.userId,
status: orm.status,
assignedAt: orm.assignedAt,
revokedAt: orm.revokedAt,
});
}
/**
* Map array of ORM entities to domain entities
*/
static toDomainMany(orms: LicenseOrmEntity[]): License[] {
return orms.map((orm) => this.toDomain(orm));
}
}

View File

@ -0,0 +1,58 @@
/**
* Subscription ORM Mapper
*
* Maps between Subscription domain entity and SubscriptionOrmEntity
*/
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
export class SubscriptionOrmMapper {
/**
* Map domain entity to ORM entity
*/
static toOrm(domain: Subscription): SubscriptionOrmEntity {
const orm = new SubscriptionOrmEntity();
const props = domain.toObject();
orm.id = props.id;
orm.organizationId = props.organizationId;
orm.plan = props.plan;
orm.status = props.status;
orm.stripeCustomerId = props.stripeCustomerId;
orm.stripeSubscriptionId = props.stripeSubscriptionId;
orm.currentPeriodStart = props.currentPeriodStart;
orm.currentPeriodEnd = props.currentPeriodEnd;
orm.cancelAtPeriodEnd = props.cancelAtPeriodEnd;
orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt;
return orm;
}
/**
* Map ORM entity to domain entity
*/
static toDomain(orm: SubscriptionOrmEntity): Subscription {
return Subscription.fromPersistence({
id: orm.id,
organizationId: orm.organizationId,
plan: orm.plan,
status: orm.status,
stripeCustomerId: orm.stripeCustomerId,
stripeSubscriptionId: orm.stripeSubscriptionId,
currentPeriodStart: orm.currentPeriodStart,
currentPeriodEnd: orm.currentPeriodEnd,
cancelAtPeriodEnd: orm.cancelAtPeriodEnd,
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
});
}
/**
* Map array of ORM entities to domain entities
*/
static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] {
return orms.map((orm) => this.toDomain(orm));
}
}

View File

@ -0,0 +1,98 @@
/**
* Migration: Create Subscriptions Table
*
* This table stores organization subscription information including
* plan, status, and Stripe integration data.
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSubscriptions1738000000001 implements MigrationInterface {
name = 'CreateSubscriptions1738000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create subscription_plan enum
await queryRunner.query(`
CREATE TYPE "subscription_plan_enum" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')
`);
// Create subscription_status enum
await queryRunner.query(`
CREATE TYPE "subscription_status_enum" AS ENUM (
'ACTIVE',
'PAST_DUE',
'CANCELED',
'INCOMPLETE',
'INCOMPLETE_EXPIRED',
'TRIALING',
'UNPAID',
'PAUSED'
)
`);
// Create subscriptions table
await queryRunner.query(`
CREATE TABLE "subscriptions" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"organization_id" UUID NOT NULL,
-- Plan information
"plan" subscription_plan_enum NOT NULL DEFAULT 'FREE',
"status" subscription_status_enum NOT NULL DEFAULT 'ACTIVE',
-- Stripe integration
"stripe_customer_id" VARCHAR(255) NULL,
"stripe_subscription_id" VARCHAR(255) NULL,
-- Billing period
"current_period_start" TIMESTAMP NULL,
"current_period_end" TIMESTAMP NULL,
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT FALSE,
-- Timestamps
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT "pk_subscriptions" PRIMARY KEY ("id"),
CONSTRAINT "uq_subscriptions_organization_id" UNIQUE ("organization_id"),
CONSTRAINT "uq_subscriptions_stripe_subscription_id" UNIQUE ("stripe_subscription_id"),
CONSTRAINT "fk_subscriptions_organization" FOREIGN KEY ("organization_id")
REFERENCES "organizations"("id") ON DELETE CASCADE
)
`);
// Create indexes
await queryRunner.query(`
CREATE INDEX "idx_subscriptions_organization_id" ON "subscriptions" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_subscriptions_stripe_customer_id" ON "subscriptions" ("stripe_customer_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_subscriptions_stripe_subscription_id" ON "subscriptions" ("stripe_subscription_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_subscriptions_plan" ON "subscriptions" ("plan")
`);
await queryRunner.query(`
CREATE INDEX "idx_subscriptions_status" ON "subscriptions" ("status")
`);
// Add comments
await queryRunner.query(`
COMMENT ON TABLE "subscriptions" IS 'Organization subscriptions for licensing system'
`);
await queryRunner.query(`
COMMENT ON COLUMN "subscriptions"."plan" IS 'Subscription plan: FREE (2 users), STARTER (5), PRO (20), ENTERPRISE (unlimited)'
`);
await queryRunner.query(`
COMMENT ON COLUMN "subscriptions"."cancel_at_period_end" IS 'If true, subscription will be canceled at the end of current period'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "subscriptions" CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_status_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_plan_enum"`);
}
}

View File

@ -0,0 +1,72 @@
/**
* Migration: Create Licenses Table
*
* This table stores user licenses linked to subscriptions.
* Each active user in an organization consumes one license.
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLicenses1738000000002 implements MigrationInterface {
name = 'CreateLicenses1738000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create license_status enum
await queryRunner.query(`
CREATE TYPE "license_status_enum" AS ENUM ('ACTIVE', 'REVOKED')
`);
// Create licenses table
await queryRunner.query(`
CREATE TABLE "licenses" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"subscription_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
-- Status
"status" license_status_enum NOT NULL DEFAULT 'ACTIVE',
-- Timestamps
"assigned_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"revoked_at" TIMESTAMP NULL,
CONSTRAINT "pk_licenses" PRIMARY KEY ("id"),
CONSTRAINT "uq_licenses_user_id" UNIQUE ("user_id"),
CONSTRAINT "fk_licenses_subscription" FOREIGN KEY ("subscription_id")
REFERENCES "subscriptions"("id") ON DELETE CASCADE,
CONSTRAINT "fk_licenses_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE
)
`);
// Create indexes
await queryRunner.query(`
CREATE INDEX "idx_licenses_subscription_id" ON "licenses" ("subscription_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_licenses_user_id" ON "licenses" ("user_id")
`);
await queryRunner.query(`
CREATE INDEX "idx_licenses_status" ON "licenses" ("status")
`);
await queryRunner.query(`
CREATE INDEX "idx_licenses_subscription_status" ON "licenses" ("subscription_id", "status")
`);
// Add comments
await queryRunner.query(`
COMMENT ON TABLE "licenses" IS 'User licenses for subscription-based access control'
`);
await queryRunner.query(`
COMMENT ON COLUMN "licenses"."status" IS 'ACTIVE: license in use, REVOKED: license freed up'
`);
await queryRunner.query(`
COMMENT ON COLUMN "licenses"."revoked_at" IS 'Timestamp when license was revoked, NULL if still active'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "licenses" CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS "license_status_enum"`);
}
}

View File

@ -0,0 +1,75 @@
/**
* Migration: Seed FREE Subscriptions for Existing Organizations
*
* Creates a FREE subscription for all existing organizations that don't have one,
* and assigns licenses to all their active users.
*/
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedFreeSubscriptions1738000000003 implements MigrationInterface {
name = 'SeedFreeSubscriptions1738000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create FREE subscription for each organization that doesn't have one
await queryRunner.query(`
INSERT INTO "subscriptions" (
"id",
"organization_id",
"plan",
"status",
"created_at",
"updated_at"
)
SELECT
uuid_generate_v4(),
o.id,
'FREE',
'ACTIVE',
NOW(),
NOW()
FROM "organizations" o
WHERE NOT EXISTS (
SELECT 1 FROM "subscriptions" s WHERE s.organization_id = o.id
)
`);
// Assign licenses to all active users in organizations with subscriptions
await queryRunner.query(`
INSERT INTO "licenses" (
"id",
"subscription_id",
"user_id",
"status",
"assigned_at"
)
SELECT
uuid_generate_v4(),
s.id,
u.id,
'ACTIVE',
NOW()
FROM "users" u
INNER JOIN "subscriptions" s ON s.organization_id = u.organization_id
WHERE u.is_active = true
AND NOT EXISTS (
SELECT 1 FROM "licenses" l WHERE l.user_id = u.id
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Remove licenses created by this migration
// Note: This is a destructive operation that cannot perfectly reverse
// We'll delete all licenses and subscriptions with FREE plan created after a certain point
// In practice, you wouldn't typically revert this in production
await queryRunner.query(`
DELETE FROM "licenses"
`);
await queryRunner.query(`
DELETE FROM "subscriptions" WHERE "plan" = 'FREE'
`);
}
}

View File

@ -9,3 +9,5 @@ export * from './typeorm-user.repository';
export * from './typeorm-carrier.repository';
export * from './typeorm-port.repository';
export * from './typeorm-rate-quote.repository';
export * from './typeorm-subscription.repository';
export * from './typeorm-license.repository';

View File

@ -0,0 +1,90 @@
/**
* TypeORM License Repository
*
* Implements LicenseRepository interface using TypeORM
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { License } from '@domain/entities/license.entity';
import { LicenseRepository } from '@domain/ports/out/license.repository';
import { LicenseOrmEntity } from '../entities/license.orm-entity';
import { LicenseOrmMapper } from '../mappers/license-orm.mapper';
@Injectable()
export class TypeOrmLicenseRepository implements LicenseRepository {
constructor(
@InjectRepository(LicenseOrmEntity)
private readonly repository: Repository<LicenseOrmEntity>,
) {}
async save(license: License): Promise<License> {
const orm = LicenseOrmMapper.toOrm(license);
const saved = await this.repository.save(orm);
return LicenseOrmMapper.toDomain(saved);
}
async findById(id: string): Promise<License | null> {
const orm = await this.repository.findOne({ where: { id } });
return orm ? LicenseOrmMapper.toDomain(orm) : null;
}
async findByUserId(userId: string): Promise<License | null> {
const orm = await this.repository.findOne({ where: { userId } });
return orm ? LicenseOrmMapper.toDomain(orm) : null;
}
async findBySubscriptionId(subscriptionId: string): Promise<License[]> {
const orms = await this.repository.find({
where: { subscriptionId },
order: { assignedAt: 'DESC' },
});
return LicenseOrmMapper.toDomainMany(orms);
}
async findActiveBySubscriptionId(subscriptionId: string): Promise<License[]> {
const orms = await this.repository.find({
where: { subscriptionId, status: 'ACTIVE' },
order: { assignedAt: 'DESC' },
});
return LicenseOrmMapper.toDomainMany(orms);
}
async countActiveBySubscriptionId(subscriptionId: string): Promise<number> {
return this.repository.count({
where: { subscriptionId, status: 'ACTIVE' },
});
}
async countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number> {
const result = await this.repository
.createQueryBuilder('license')
.innerJoin('license.user', 'user')
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
.andWhere('license.status = :status', { status: 'ACTIVE' })
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
.getCount();
return result;
}
async findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]> {
const orms = await this.repository
.createQueryBuilder('license')
.innerJoin('license.user', 'user')
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
.andWhere('license.status = :status', { status: 'ACTIVE' })
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
.orderBy('license.assignedAt', 'DESC')
.getMany();
return LicenseOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
async deleteBySubscriptionId(subscriptionId: string): Promise<void> {
await this.repository.delete({ subscriptionId });
}
}

View File

@ -0,0 +1,60 @@
/**
* TypeORM Subscription Repository
*
* Implements SubscriptionRepository interface using TypeORM
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
@Injectable()
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
constructor(
@InjectRepository(SubscriptionOrmEntity)
private readonly repository: Repository<SubscriptionOrmEntity>,
) {}
async save(subscription: Subscription): Promise<Subscription> {
const orm = SubscriptionOrmMapper.toOrm(subscription);
const saved = await this.repository.save(orm);
return SubscriptionOrmMapper.toDomain(saved);
}
async findById(id: string): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { id } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
}
async findByOrganizationId(organizationId: string): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { organizationId } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
}
async findByStripeSubscriptionId(
stripeSubscriptionId: string,
): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null> {
const orm = await this.repository.findOne({ where: { stripeCustomerId } });
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
}
async findAll(): Promise<Subscription[]> {
const orms = await this.repository.find({
order: { createdAt: 'DESC' },
});
return SubscriptionOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
}

View File

@ -0,0 +1,6 @@
/**
* Stripe Infrastructure Barrel Export
*/
export * from './stripe.adapter';
export * from './stripe.module';

View File

@ -0,0 +1,233 @@
/**
* Stripe Adapter
*
* Implementation of the StripePort interface using the Stripe SDK.
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import {
StripePort,
CreateCheckoutSessionInput,
CreateCheckoutSessionOutput,
CreatePortalSessionInput,
CreatePortalSessionOutput,
StripeSubscriptionData,
StripeCheckoutSessionData,
StripeWebhookEvent,
} from '@domain/ports/out/stripe.port';
import { SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
@Injectable()
export class StripeAdapter implements StripePort {
private readonly logger = new Logger(StripeAdapter.name);
private readonly stripe: Stripe;
private readonly webhookSecret: string;
private readonly priceIdMap: Map<string, SubscriptionPlanType>;
private readonly planPriceMap: Map<string, { monthly: string; yearly: string }>;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
if (!apiKey) {
this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe features will be disabled');
}
this.stripe = new Stripe(apiKey || 'sk_test_placeholder');
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
// Map Stripe price IDs to plans
this.priceIdMap = new Map();
this.planPriceMap = new Map();
// Configure plan price IDs from environment
const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID');
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID');
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID');
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID');
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID');
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID');
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER');
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER');
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO');
if (proYearly) this.priceIdMap.set(proYearly, 'PRO');
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE');
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE');
this.planPriceMap.set('STARTER', {
monthly: starterMonthly || '',
yearly: starterYearly || '',
});
this.planPriceMap.set('PRO', {
monthly: proMonthly || '',
yearly: proYearly || '',
});
this.planPriceMap.set('ENTERPRISE', {
monthly: enterpriseMonthly || '',
yearly: enterpriseYearly || '',
});
}
async createCheckoutSession(
input: CreateCheckoutSessionInput,
): Promise<CreateCheckoutSessionOutput> {
const planPrices = this.planPriceMap.get(input.plan);
if (!planPrices) {
throw new Error(`No price configuration for plan: ${input.plan}`);
}
const priceId = input.billingInterval === 'yearly'
? planPrices.yearly
: planPrices.monthly;
if (!priceId) {
throw new Error(
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
);
}
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: input.successUrl,
cancel_url: input.cancelUrl,
customer_email: input.customerId ? undefined : input.email,
customer: input.customerId || undefined,
metadata: {
organizationId: input.organizationId,
organizationName: input.organizationName,
plan: input.plan,
},
subscription_data: {
metadata: {
organizationId: input.organizationId,
plan: input.plan,
},
},
allow_promotion_codes: true,
billing_address_collection: 'required',
};
const session = await this.stripe.checkout.sessions.create(sessionParams);
this.logger.log(
`Created checkout session ${session.id} for organization ${input.organizationId}`,
);
return {
sessionId: session.id,
sessionUrl: session.url || '',
};
}
async createPortalSession(
input: CreatePortalSessionInput,
): Promise<CreatePortalSessionOutput> {
const session = await this.stripe.billingPortal.sessions.create({
customer: input.customerId,
return_url: input.returnUrl,
});
this.logger.log(`Created portal session for customer ${input.customerId}`);
return {
sessionUrl: session.url,
};
}
async getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null> {
try {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
// Get the price ID from the first item
const priceId = subscription.items.data[0]?.price.id || '';
return {
subscriptionId: subscription.id,
customerId: subscription.customer as string,
status: subscription.status,
planId: priceId,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
};
} catch (error) {
if ((error as any).code === 'resource_missing') {
return null;
}
throw error;
}
}
async getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null> {
try {
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
return {
sessionId: session.id,
customerId: session.customer as string | null,
subscriptionId: session.subscription as string | null,
status: session.status || 'unknown',
metadata: (session.metadata || {}) as Record<string, string>,
};
} catch (error) {
if ((error as any).code === 'resource_missing') {
return null;
}
this.logger.error(`Failed to retrieve checkout session ${sessionId}:`, error);
throw error;
}
}
async cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
this.logger.log(`Scheduled subscription ${subscriptionId} for cancellation at period end`);
}
async cancelSubscriptionImmediately(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.cancel(subscriptionId);
this.logger.log(`Cancelled subscription ${subscriptionId} immediately`);
}
async resumeSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
this.logger.log(`Resumed subscription ${subscriptionId}`);
}
async constructWebhookEvent(
payload: string | Buffer,
signature: string,
): Promise<StripeWebhookEvent> {
const event = this.stripe.webhooks.constructEvent(
payload,
signature,
this.webhookSecret,
);
return {
type: event.type,
data: {
object: event.data.object as Record<string, unknown>,
},
};
}
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null {
return this.priceIdMap.get(priceId) || null;
}
}

View File

@ -0,0 +1,23 @@
/**
* Stripe Module
*
* NestJS module for Stripe payment integration.
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { StripeAdapter } from './stripe.adapter';
import { STRIPE_PORT } from '@domain/ports/out/stripe.port';
@Module({
imports: [ConfigModule],
providers: [
StripeAdapter,
{
provide: STRIPE_PORT,
useExisting: StripeAdapter,
},
],
exports: [STRIPE_PORT, StripeAdapter],
})
export class StripeModule {}

View File

@ -11,6 +11,8 @@ import { helmetConfig, corsConfig } from './infrastructure/security/security.con
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
// Enable rawBody for Stripe webhooks signature verification
rawBody: true,
});
// Get config service

View File

@ -1,9 +1,12 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/context/auth-context';
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
import type { OrganizationResponse } from '@/types/api';
import SubscriptionTab from '@/components/organization/SubscriptionTab';
import LicensesTab from '@/components/organization/LicensesTab';
interface OrganizationForm {
name: string;
@ -17,11 +20,21 @@ interface OrganizationForm {
address_country: string;
}
type TabType = 'information' | 'address';
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
export default function OrganizationSettingsPage() {
const { user } = useAuth();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<TabType>('information');
// Auto-switch to subscription tab if coming back from Stripe
useEffect(() => {
const isSuccess = searchParams.get('success') === 'true';
const isCanceled = searchParams.get('canceled') === 'true';
if (isSuccess || isCanceled) {
setActiveTab('subscription');
}
}, [searchParams]);
const [organization, setOrganization] = useState<OrganizationResponse | null>(null);
const [formData, setFormData] = useState<OrganizationForm>({
name: '',
@ -152,16 +165,56 @@ export default function OrganizationSettingsPage() {
);
}
const tabs = [
{
id: 'information' as TabType,
label: 'Informations',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
id: 'address' as TabType,
label: 'Adresse',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
id: 'subscription' as TabType,
label: 'Abonnement',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
},
{
id: 'licenses' as TabType,
label: 'Licences',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
},
];
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l&apos;organisation</h1>
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
</div>
{/* Success Message */}
{successMessage && (
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -173,7 +226,7 @@ export default function OrganizationSettingsPage() {
)}
{/* Error Message */}
{error && (
{error && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -185,13 +238,13 @@ export default function OrganizationSettingsPage() {
)}
{/* Read-only warning for USER role */}
{!canEdit && (
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l&apos;organisation</p>
</div>
</div>
)}
@ -199,38 +252,23 @@ export default function OrganizationSettingsPage() {
{/* Tabs */}
<div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('information')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'information'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Informations</span>
</div>
</button>
<button
onClick={() => setActiveTab('address')}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'address'
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Adresse</span>
</div>
</button>
<nav className="flex -mb-px overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
{tab.icon}
<span>{tab.label}</span>
</div>
</button>
))}
</nav>
</div>
@ -258,7 +296,7 @@ export default function OrganizationSettingsPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
SIREN
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
<span className="ml-2 text-xs text-gray-500">(Système d&apos;Identification du Répertoire des Entreprises)</span>
</label>
<input
type="text"
@ -393,10 +431,14 @@ export default function OrganizationSettingsPage() {
</div>
</div>
)}
{activeTab === 'subscription' && <SubscriptionTab />}
{activeTab === 'licenses' && <LicensesTab />}
</div>
{/* Actions */}
{canEdit && (
{/* Actions (only for information and address tabs) */}
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
<button
type="button"

View File

@ -0,0 +1,31 @@
/**
* Subscription Management Page
*
* Redirects to Organization settings with Subscription tab
*/
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function SubscriptionPage() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
// Preserve any query parameters (success, canceled) from Stripe redirects
const params = searchParams.toString();
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
router.replace(redirectUrl);
}, [router, searchParams]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">Redirection...</p>
</div>
</div>
);
}

View File

@ -9,9 +9,10 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listUsers, updateUser, deleteUser } from '@/lib/api';
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
import { createInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
import Link from 'next/link';
export default function UsersManagementPage() {
const router = useRouter();
@ -34,6 +35,12 @@ export default function UsersManagementPage() {
queryFn: () => listUsers(),
});
// Check license availability
const { data: licenseStatus } = useQuery({
queryKey: ['canInvite'],
queryFn: () => canInviteUser(),
});
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm) => {
return createInvitation({
@ -45,6 +52,7 @@ export default function UsersManagementPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setShowInviteModal(false);
setInviteForm({
@ -82,6 +90,7 @@ export default function UsersManagementPage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User status updated successfully');
setTimeout(() => setSuccess(''), 3000);
},
@ -95,6 +104,7 @@ export default function UsersManagementPage() {
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
setSuccess('User deleted successfully');
setTimeout(() => setSuccess(''), 3000);
},
@ -159,19 +169,79 @@ export default function UsersManagementPage() {
return (
<div className="space-y-6">
{/* License Warning */}
{licenseStatus && !licenseStatus.canInvite && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
<p className="mt-1 text-sm text-amber-700">
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
Upgrade your subscription to invite more users.
</p>
<div className="mt-3">
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
>
Upgrade Subscription
</Link>
</div>
</div>
</div>
</div>
)}
{/* License Usage Info */}
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<span className="text-sm text-blue-800">
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
</span>
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Manage Subscription
</Link>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
Invite User
</button>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2">+</span>
Invite User
</button>
) : (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
Upgrade to Invite
</Link>
)}
</div>
{success && (
@ -319,13 +389,23 @@ export default function UsersManagementPage() {
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
<div className="mt-6">
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
Invite User
</button>
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2">+</span>
Invite User
</button>
) : (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
>
<span className="mr-2">+</span>
Upgrade to Invite
</Link>
)}
</div>
</div>
)}

View File

@ -0,0 +1,360 @@
/**
* Licenses Tab Component
*
* Manages user licenses within the organization
*/
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
export default function LicensesTab() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const { data: subscription, isLoading } = useQuery({
queryKey: ['subscription'],
queryFn: getSubscriptionOverview,
});
if (isLoading) {
return (
<div className="space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
const licenses = subscription?.licenses || [];
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
const revokedLicenses = licenses.filter((l) => l.status === 'REVOKED');
const usagePercentage = subscription
? subscription.maxLicenses === -1
? 0
: (subscription.usedLicenses / subscription.maxLicenses) * 100
: 0;
return (
<div className="space-y-6">
{/* Alerts */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
{success}
</div>
)}
{/* License Summary */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Résumé des licences</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-500">Licences utilisées</p>
<p className="text-2xl font-bold text-gray-900">
{subscription?.usedLicenses || 0}
</p>
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
</div>
<div className="bg-white rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-500">Licences disponibles</p>
<p className="text-2xl font-bold text-gray-900">
{subscription?.availableLicenses === -1
? 'Illimité'
: subscription?.availableLicenses || 0}
</p>
</div>
<div className="bg-white rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-500">Licences totales</p>
<p className="text-2xl font-bold text-gray-900">
{subscription?.maxLicenses === -1
? 'Illimité'
: subscription?.maxLicenses || 0}
</p>
</div>
</div>
{/* Usage Bar */}
{subscription && subscription.maxLicenses !== -1 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Utilisation</span>
<span className="text-sm text-gray-500">
{Math.round(usagePercentage)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${
usagePercentage >= 90
? 'bg-red-600'
: usagePercentage >= 70
? 'bg-yellow-500'
: 'bg-blue-600'
}`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div>
</div>
</div>
)}
</div>
{/* Active Licenses */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
Licences actives ({activeLicenses.length})
</h3>
</div>
{activeLicenses.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500">
Aucune licence active
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Utilisateur
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Rôle
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Assignée le
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Licence
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{activeLicenses.map((license) => {
const isAdmin = license.userRole === 'ADMIN';
return (
<tr key={license.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{license.userName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{license.userEmail}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
isAdmin
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-600'
}`}>
{license.userRole}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isAdmin ? (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-amber-100 text-amber-800">
Illimité
</span>
) : (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
Active
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Revoked Licenses (History) */}
{revokedLicenses.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
Historique des licences révoquées ({revokedLicenses.length})
</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Utilisateur
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Rôle
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Assignée le
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Révoquée le
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Statut
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{revokedLicenses.map((license) => (
<tr key={license.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{license.userName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{license.userEmail}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
license.userRole === 'ADMIN'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-600'
}`}>
{license.userRole}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{license.revokedAt
? new Date(license.revokedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
: '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
Révoquée
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Comment fonctionnent les licences ?
</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc list-inside space-y-1">
<li>
Chaque utilisateur actif de votre organisation consomme une licence
</li>
<li>
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
</li>
<li>
Les licences sont automatiquement assignées lors de l&apos;ajout d&apos;un
utilisateur
</li>
<li>
Les licences sont libérées lorsqu&apos;un utilisateur est désactivé ou
supprimé
</li>
<li>
Pour ajouter plus d&apos;utilisateurs, passez à un plan supérieur dans
l&apos;onglet Abonnement
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,443 @@
/**
* Subscription Tab Component
*
* Manages subscription plan, billing, and upgrade flows
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getSubscriptionOverview,
getAllPlans,
createCheckoutSession,
createPortalSession,
syncSubscriptionFromStripe,
formatPrice,
getPlanBadgeColor,
getStatusBadgeColor,
type SubscriptionPlan,
type BillingInterval,
} from '@/lib/api/subscriptions';
export default function SubscriptionTab() {
const searchParams = useSearchParams();
const router = useRouter();
const queryClient = useQueryClient();
const [billingInterval, setBillingInterval] = useState<BillingInterval>('monthly');
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const { data: subscription, isLoading: loadingSubscription, refetch: refetchSubscription } = useQuery({
queryKey: ['subscription'],
queryFn: getSubscriptionOverview,
// Refetch more frequently when we're waiting for webhook
refetchInterval: isRefreshing ? 2000 : false,
});
const { data: plansData, isLoading: loadingPlans } = useQuery({
queryKey: ['plans'],
queryFn: getAllPlans,
});
// Handle success/cancel from Stripe redirect
const handleStripeRedirect = useCallback(async () => {
const isSuccess = searchParams.get('success') === 'true';
const isCanceled = searchParams.get('canceled') === 'true';
const sessionId = searchParams.get('session_id') || undefined;
if (isSuccess) {
setSuccess('Votre abonnement a été mis à jour avec succès !');
setIsRefreshing(true);
try {
// Sync from Stripe using the session ID (works even without webhooks)
// The session ID allows us to retrieve the subscription from the checkout session
console.log('Syncing subscription with sessionId:', sessionId);
await syncSubscriptionFromStripe(sessionId);
// Then invalidate and refetch to get fresh data
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
await refetchSubscription();
// Wait a bit and refetch again to ensure data is up to date
await new Promise(resolve => setTimeout(resolve, 1500));
await syncSubscriptionFromStripe(sessionId);
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
await refetchSubscription();
} catch (err) {
console.error('Error syncing subscription:', err);
// Fallback: just refetch
await refetchSubscription();
}
setIsRefreshing(false);
// Clear the URL params after processing
router.replace('/dashboard/settings/organization', { scroll: false });
setTimeout(() => setSuccess(''), 5000);
} else if (isCanceled) {
setError('Le paiement a été annulé. Votre abonnement n\'a pas été modifié.');
router.replace('/dashboard/settings/organization', { scroll: false });
setTimeout(() => setError(''), 5000);
}
}, [searchParams, queryClient, refetchSubscription, router]);
useEffect(() => {
handleStripeRedirect();
}, [handleStripeRedirect]);
const checkoutMutation = useMutation({
mutationFn: (plan: SubscriptionPlan) =>
createCheckoutSession({ plan, billingInterval }),
onSuccess: (data) => {
window.location.href = data.sessionUrl;
},
onError: (err: Error) => {
setError(err.message || 'Erreur lors de la création de la session de paiement');
setTimeout(() => setError(''), 5000);
},
});
const portalMutation = useMutation({
mutationFn: () => createPortalSession(),
onSuccess: (data) => {
window.location.href = data.sessionUrl;
},
onError: (err: Error) => {
setError(err.message || 'Erreur lors de l\'ouverture du portail de facturation');
setTimeout(() => setError(''), 5000);
},
});
const handleUpgrade = (plan: SubscriptionPlan) => {
if (plan === 'FREE') return;
setSelectedPlan(plan);
checkoutMutation.mutate(plan);
};
const handleManageBilling = () => {
portalMutation.mutate();
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
// Sync from Stripe first
await syncSubscriptionFromStripe();
// Then invalidate and refetch
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
await refetchSubscription();
} catch (err) {
console.error('Error syncing subscription:', err);
// Fallback: just refetch
await refetchSubscription();
}
setIsRefreshing(false);
};
const isCurrentPlan = (plan: SubscriptionPlan): boolean => {
return subscription?.plan === plan;
};
const canUpgrade = (plan: SubscriptionPlan): boolean => {
if (!subscription) return false;
const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
};
const isLoading = loadingSubscription || loadingPlans;
if (isLoading) {
return (
<div className="space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="h-32 bg-gray-200 rounded"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
</div>
);
}
const usagePercentage = subscription
? subscription.maxLicenses === -1
? 0
: (subscription.usedLicenses / subscription.maxLicenses) * 100
: 0;
return (
<div className="space-y-6">
{/* Alerts */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg flex items-center justify-between">
<span>{success}</span>
{isRefreshing && (
<span className="text-sm text-green-600 flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Mise à jour...
</span>
)}
</div>
)}
{/* Current Plan */}
{subscription && (
<div className="bg-gray-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Plan actuel</h3>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50 flex items-center gap-1"
>
<svg className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{isRefreshing ? 'Actualisation...' : 'Actualiser'}
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${getPlanBadgeColor(subscription.plan)}`}
>
{subscription.planDetails.name}
</span>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadgeColor(subscription.status)}`}
>
{subscription.status === 'ACTIVE' ? 'Actif' : subscription.status}
</span>
{subscription.cancelAtPeriodEnd && (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
Annulation prévue
</span>
)}
</div>
{subscription.plan !== 'FREE' && (
<button
onClick={handleManageBilling}
disabled={portalMutation.isPending}
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg disabled:opacity-50 transition-colors"
>
{portalMutation.isPending ? 'Chargement...' : 'Gérer la facturation'}
</button>
)}
</div>
{/* License Usage */}
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Utilisation des licences</span>
<span className="text-sm text-gray-500">
{subscription.usedLicenses} /{' '}
{subscription.maxLicenses === -1 ? 'Illimité' : subscription.maxLicenses}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${
usagePercentage >= 90
? 'bg-red-600'
: usagePercentage >= 70
? 'bg-yellow-500'
: 'bg-blue-600'
}`}
style={{
width:
subscription.maxLicenses === -1
? '10%'
: `${Math.min(usagePercentage, 100)}%`,
}}
></div>
</div>
{subscription.availableLicenses !== -1 && subscription.availableLicenses <= 2 && (
<p className="mt-2 text-sm text-amber-600">
{subscription.availableLicenses === 0
? 'Aucune licence disponible. Passez à un plan supérieur pour ajouter des utilisateurs.'
: `Plus que ${subscription.availableLicenses} licence${subscription.availableLicenses === 1 ? '' : 's'} disponible${subscription.availableLicenses === 1 ? '' : 's'}.`}
</p>
)}
<p className="mt-2 text-xs text-gray-400">
Les administrateurs (ADMIN) ont des licences illimitées et ne sont pas comptés.
</p>
</div>
{/* Billing Period */}
{subscription.currentPeriodEnd && (
<div className="mt-4 text-sm text-gray-500">
Période actuelle : jusqu&apos;au{' '}
{new Date(subscription.currentPeriodEnd).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</div>
)}
</div>
)}
{/* Plans Grid */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h3 className="text-lg font-medium text-gray-900">Plans disponibles</h3>
<div className="flex items-center gap-2 bg-gray-100 p-1 rounded-lg">
<button
onClick={() => setBillingInterval('monthly')}
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
billingInterval === 'monthly'
? 'bg-white shadow text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Mensuel
</button>
<button
onClick={() => setBillingInterval('yearly')}
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
billingInterval === 'yearly'
? 'bg-white shadow text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Annuel
<span className="ml-1 text-xs text-green-600">-20%</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{plansData?.plans.map((plan) => (
<div
key={plan.plan}
className={`bg-white rounded-lg border-2 p-5 ${
isCurrentPlan(plan.plan)
? 'border-blue-500 shadow-md'
: 'border-gray-200 hover:border-gray-300 hover:shadow transition'
}`}
>
<div className="text-center">
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
<div className="mt-3">
<span className="text-2xl font-bold text-gray-900">
{plan.plan === 'ENTERPRISE'
? 'Sur devis'
: formatPrice(
billingInterval === 'yearly'
? plan.yearlyPriceEur
: plan.monthlyPriceEur,
)}
</span>
{plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && (
<span className="text-gray-500 text-sm">
/{billingInterval === 'yearly' ? 'an' : 'mois'}
</span>
)}
</div>
<p className="mt-2 text-sm text-gray-500">
{plan.maxLicenses === -1
? 'Utilisateurs illimités'
: `Jusqu'à ${plan.maxLicenses} utilisateurs`}
</p>
</div>
<ul className="mt-4 space-y-2">
{plan.features.slice(0, 4).map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<svg
className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-600">{feature}</span>
</li>
))}
</ul>
<div className="mt-5">
{isCurrentPlan(plan.plan) ? (
<button
disabled
className="w-full px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 rounded-lg cursor-not-allowed"
>
Plan actuel
</button>
) : plan.plan === 'ENTERPRISE' ? (
<a
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition"
>
Nous contacter
</a>
) : canUpgrade(plan.plan) ? (
<button
onClick={() => handleUpgrade(plan.plan)}
disabled={
checkoutMutation.isPending && selectedPlan === plan.plan
}
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
>
{checkoutMutation.isPending && selectedPlan === plan.plan
? 'Chargement...'
: 'Passer à ce plan'}
</button>
) : (
<button
disabled
className="w-full px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 rounded-lg cursor-not-allowed"
>
Rétrograder via Facturation
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* Info about webhooks in development */}
{process.env.NODE_ENV === 'development' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Mode développement</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
Pour que les webhooks Stripe fonctionnent en local, exécutez :{' '}
<code className="bg-yellow-100 px-1 rounded">stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook</code>
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -136,6 +136,26 @@ export {
type DashboardAlert,
} from './dashboard';
// Subscriptions (5 endpoints)
export {
getSubscriptionOverview,
getAllPlans,
canInviteUser,
createCheckoutSession,
createPortalSession,
formatPrice,
getPlanBadgeColor,
getStatusBadgeColor,
type SubscriptionPlan,
type SubscriptionStatus,
type BillingInterval,
type PlanDetails,
type LicenseResponse,
type SubscriptionOverviewResponse,
type CanInviteResponse,
type AllPlansResponse,
} from './subscriptions';
// Re-export as API objects for backward compatibility
import * as bookingsModule from './bookings';
import * as ratesModule from './rates';
@ -146,6 +166,7 @@ import * as notificationsModule from './notifications';
import * as auditModule from './audit';
import * as webhooksModule from './webhooks';
import * as gdprModule from './gdpr';
import * as subscriptionsModule from './subscriptions';
export const bookingsApi = bookingsModule;
export const ratesApi = ratesModule;
@ -156,3 +177,4 @@ export const notificationsApi = notificationsModule;
export const auditApi = auditModule;
export const webhooksApi = webhooksModule;
export const gdprApi = gdprModule;
export const subscriptionsApi = subscriptionsModule;

View File

@ -0,0 +1,226 @@
/**
* Subscriptions API Client
*
* API functions for subscription and license management
*/
import { get, post } from './client';
/**
* Subscription plan types
*/
export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
/**
* Subscription status types
*/
export type SubscriptionStatus =
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCELED'
| 'INCOMPLETE'
| 'INCOMPLETE_EXPIRED'
| 'TRIALING'
| 'UNPAID'
| 'PAUSED';
/**
* Billing interval types
*/
export type BillingInterval = 'monthly' | 'yearly';
/**
* Plan details
*/
export interface PlanDetails {
plan: SubscriptionPlan;
name: string;
maxLicenses: number;
monthlyPriceEur: number;
yearlyPriceEur: number;
features: string[];
}
/**
* License response
*/
export interface LicenseResponse {
id: string;
userId: string;
userEmail: string;
userName: string;
userRole: string;
status: 'ACTIVE' | 'REVOKED';
assignedAt: string;
revokedAt?: string;
}
/**
* Subscription overview response
*/
export interface SubscriptionOverviewResponse {
id: string;
organizationId: string;
plan: SubscriptionPlan;
planDetails: PlanDetails;
status: SubscriptionStatus;
usedLicenses: number;
maxLicenses: number;
availableLicenses: number;
cancelAtPeriodEnd: boolean;
currentPeriodStart?: string;
currentPeriodEnd?: string;
createdAt: string;
updatedAt: string;
licenses: LicenseResponse[];
}
/**
* Can invite response
*/
export interface CanInviteResponse {
canInvite: boolean;
availableLicenses: number;
usedLicenses: number;
maxLicenses: number;
message?: string;
}
/**
* All plans response
*/
export interface AllPlansResponse {
plans: PlanDetails[];
}
/**
* Checkout session request
*/
export interface CreateCheckoutSessionRequest {
plan: SubscriptionPlan;
billingInterval: BillingInterval;
successUrl?: string;
cancelUrl?: string;
}
/**
* Checkout session response
*/
export interface CheckoutSessionResponse {
sessionId: string;
sessionUrl: string;
}
/**
* Portal session request
*/
export interface CreatePortalSessionRequest {
returnUrl?: string;
}
/**
* Portal session response
*/
export interface PortalSessionResponse {
sessionUrl: string;
}
/**
* Get subscription overview for current organization
*/
export async function getSubscriptionOverview(): Promise<SubscriptionOverviewResponse> {
return get<SubscriptionOverviewResponse>('/api/v1/subscriptions');
}
/**
* Get all available plans
*/
export async function getAllPlans(): Promise<AllPlansResponse> {
return get<AllPlansResponse>('/api/v1/subscriptions/plans');
}
/**
* Check if organization can invite more users
*/
export async function canInviteUser(): Promise<CanInviteResponse> {
return get<CanInviteResponse>('/api/v1/subscriptions/can-invite');
}
/**
* Create a Stripe Checkout session for subscription upgrade
*/
export async function createCheckoutSession(
data: CreateCheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
return post<CheckoutSessionResponse>('/api/v1/subscriptions/checkout', data);
}
/**
* Create a Stripe Customer Portal session
*/
export async function createPortalSession(
data?: CreatePortalSessionRequest,
): Promise<PortalSessionResponse> {
return post<PortalSessionResponse>('/api/v1/subscriptions/portal', data || {});
}
/**
* Sync subscription from Stripe
* Useful when webhooks are not available (e.g., local development)
* @param sessionId - Optional Stripe checkout session ID (pass after checkout completes)
*/
export async function syncSubscriptionFromStripe(sessionId?: string): Promise<SubscriptionOverviewResponse> {
return post<SubscriptionOverviewResponse>('/api/v1/subscriptions/sync', { sessionId });
}
/**
* Format price for display
*/
export function formatPrice(amount: number, currency = 'EUR'): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
/**
* Get plan badge color class
*/
export function getPlanBadgeColor(plan: SubscriptionPlan): string {
switch (plan) {
case 'FREE':
return 'bg-gray-100 text-gray-800';
case 'STARTER':
return 'bg-blue-100 text-blue-800';
case 'PRO':
return 'bg-purple-100 text-purple-800';
case 'ENTERPRISE':
return 'bg-amber-100 text-amber-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
/**
* Get status badge color class
*/
export function getStatusBadgeColor(status: SubscriptionStatus): string {
switch (status) {
case 'ACTIVE':
case 'TRIALING':
return 'bg-green-100 text-green-800';
case 'PAST_DUE':
return 'bg-yellow-100 text-yellow-800';
case 'CANCELED':
case 'INCOMPLETE_EXPIRED':
case 'UNPAID':
return 'bg-red-100 text-red-800';
case 'INCOMPLETE':
case 'PAUSED':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
}

219
docs/STRIPE_SETUP.md Normal file
View File

@ -0,0 +1,219 @@
# Configuration Stripe pour Xpeditis
Ce guide explique comment configurer Stripe pour le système de licences et d'abonnements.
## 1. Prérequis
- Compte Stripe (https://dashboard.stripe.com)
- Accès aux clés API Stripe
## 2. Configuration du Dashboard Stripe
### 2.1 Créer les Produits
Dans le Dashboard Stripe, allez dans **Products** et créez les produits suivants :
| Produit | Description |
|---------|-------------|
| **Xpeditis Starter** | Plan Starter - Jusqu'à 5 utilisateurs |
| **Xpeditis Pro** | Plan Pro - Jusqu'à 20 utilisateurs |
| **Xpeditis Enterprise** | Plan Enterprise - Utilisateurs illimités |
### 2.2 Créer les Prix
Pour chaque produit, créez 2 prix (mensuel et annuel) :
#### Starter
| Type | Prix | Récurrence |
|------|------|------------|
| Mensuel | 49 EUR | /mois |
| Annuel | 470 EUR | /an (~20% de réduction) |
#### Pro
| Type | Prix | Récurrence |
|------|------|------------|
| Mensuel | 149 EUR | /mois |
| Annuel | 1430 EUR | /an (~20% de réduction) |
#### Enterprise
| Type | Prix | Récurrence |
|------|------|------------|
| Mensuel | Prix personnalisé | /mois |
| Annuel | Prix personnalisé | /an |
### 2.3 Récupérer les Price IDs
Après avoir créé les prix, notez les **Price IDs** (format: `price_xxxxx`) pour chaque prix.
## 3. Configuration du Webhook
### 3.1 Créer le Webhook Endpoint
1. Allez dans **Developers > Webhooks**
2. Cliquez sur **Add endpoint**
3. Configurez :
- **Endpoint URL**: `https://votre-domaine.com/api/v1/subscriptions/webhook`
- **Events to send**: Sélectionnez les événements suivants :
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
4. Cliquez sur **Add endpoint**
5. Notez le **Webhook signing secret** (format: `whsec_xxxxx`)
### 3.2 Test Local avec Stripe CLI
Pour tester les webhooks en local :
```bash
# Installer Stripe CLI
brew install stripe/stripe-cli/stripe
# Se connecter à Stripe
stripe login
# Écouter les webhooks et les transférer en local
stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook
# Le CLI affichera le webhook secret à utiliser localement
# > Ready! Your webhook signing secret is whsec_xxxxx
```
## 4. Variables d'environnement
Ajoutez ces variables dans votre fichier `.env` du backend :
```bash
# Clés API Stripe
STRIPE_SECRET_KEY=sk_test_xxxxx # Clé secrète (test ou live)
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret du webhook
# Price IDs (créés dans le Dashboard)
STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx
STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx
STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx
# URL Frontend (pour les redirections)
FRONTEND_URL=http://localhost:3000
```
## 5. Configurer le Customer Portal
Le Customer Portal permet aux clients de gérer leur abonnement (changer de plan, annuler, mettre à jour le paiement).
1. Allez dans **Settings > Billing > Customer portal**
2. Activez les options souhaitées :
- [x] Allow customers to update their payment methods
- [x] Allow customers to update subscriptions
- [x] Allow customers to cancel subscriptions
- [x] Show invoice history
3. Configurez les produits autorisés dans le portal
## 6. Mode Test vs Production
### Mode Test (Développement)
- Utilisez `sk_test_xxxxx` comme clé secrète
- Les paiements ne sont pas réels
- Utilisez les cartes de test Stripe :
- Succès: `4242 4242 4242 4242`
- Échec: `4000 0000 0000 0002`
- 3D Secure: `4000 0025 0000 3155`
### Mode Production
- Utilisez `sk_live_xxxxx` comme clé secrète
- Activez le mode live dans le Dashboard
- Assurez-vous d'avoir complété la vérification de compte
## 7. Flux d'abonnement
```
┌─────────────────┐
│ Page Subscription│
│ (Frontend) │
└────────┬────────┘
│ Clic "Upgrade"
┌─────────────────┐
│ POST /checkout │
│ (Backend) │
└────────┬────────┘
│ Crée Checkout Session
┌─────────────────┐
│ Stripe Checkout │
│ (Stripe) │
└────────┬────────┘
│ Paiement réussi
┌─────────────────┐
│ Webhook │
│ checkout. │
│ session.completed│
└────────┬────────┘
│ Met à jour la subscription
┌─────────────────┐
│ Base de données │
│ (PostgreSQL) │
└─────────────────┘
```
## 8. Gestion des erreurs
### Paiement échoué
- Le webhook `invoice.payment_failed` est déclenché
- L'abonnement passe en statut `PAST_DUE`
- L'utilisateur est informé et peut mettre à jour son moyen de paiement
### Annulation
- Via le Customer Portal ou l'API
- L'abonnement reste actif jusqu'à la fin de la période
- À la fin de la période, le webhook `customer.subscription.deleted` est déclenché
- L'organisation repasse au plan FREE
## 9. Vérification
### Checklist de configuration
- [ ] Produits créés dans Stripe Dashboard
- [ ] Prix créés (mensuel + annuel pour chaque plan)
- [ ] Webhook endpoint configuré
- [ ] Customer Portal configuré
- [ ] Variables d'environnement ajoutées au `.env`
- [ ] Test avec Stripe CLI en local
- [ ] Test d'un paiement complet (checkout → webhook)
### Test manuel
1. Lancez le backend et le frontend
2. Connectez-vous en tant qu'ADMIN
3. Allez dans Settings > Subscription
4. Cliquez sur "Upgrade" sur un plan payant
5. Utilisez la carte de test `4242 4242 4242 4242`
6. Vérifiez que le plan est mis à jour dans la base de données
7. Vérifiez que les licences sont correctement comptées
## 10. Commandes utiles
```bash
# Voir les webhooks reçus
stripe events list --limit 10
# Déclencher un webhook manuellement
stripe trigger checkout.session.completed
# Voir les logs
stripe logs tail
```
## Support
Pour toute question sur Stripe :
- Documentation Stripe : https://stripe.com/docs
- Support Stripe : https://support.stripe.com