fix licensing
This commit is contained in:
parent
dd5d806180
commit
5c7834c7e4
@ -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
|
||||
|
||||
17
apps/backend/package-lock.json
generated
17
apps/backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
55
apps/backend/scripts/list-stripe-prices.js
Normal file
55
apps/backend/scripts/list-stripe-prices.js
Normal 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();
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
378
apps/backend/src/application/dto/subscription.dto.ts
Normal file
378
apps/backend/src/application/dto/subscription.dto.ts
Normal 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[];
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
684
apps/backend/src/application/services/subscription.service.ts
Normal file
684
apps/backend/src/application/services/subscription.service.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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: [
|
||||
|
||||
@ -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';
|
||||
|
||||
270
apps/backend/src/domain/entities/license.entity.spec.ts
Normal file
270
apps/backend/src/domain/entities/license.entity.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
167
apps/backend/src/domain/entities/license.entity.ts
Normal file
167
apps/backend/src/domain/entities/license.entity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
405
apps/backend/src/domain/entities/subscription.entity.spec.ts
Normal file
405
apps/backend/src/domain/entities/subscription.entity.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
355
apps/backend/src/domain/entities/subscription.entity.ts
Normal file
355
apps/backend/src/domain/entities/subscription.entity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
62
apps/backend/src/domain/ports/out/license.repository.ts
Normal file
62
apps/backend/src/domain/ports/out/license.repository.ts
Normal 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>;
|
||||
}
|
||||
113
apps/backend/src/domain/ports/out/stripe.port.ts
Normal file
113
apps/backend/src/domain/ports/out/stripe.port.ts
Normal 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;
|
||||
}
|
||||
46
apps/backend/src/domain/ports/out/subscription.repository.ts
Normal file
46
apps/backend/src/domain/ports/out/subscription.repository.ts
Normal 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>;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
74
apps/backend/src/domain/value-objects/license-status.vo.ts
Normal file
74
apps/backend/src/domain/value-objects/license-status.vo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
203
apps/backend/src/domain/value-objects/subscription-plan.vo.ts
Normal file
203
apps/backend/src/domain/value-objects/subscription-plan.vo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
215
apps/backend/src/domain/value-objects/subscription-status.vo.ts
Normal file
215
apps/backend/src/domain/value-objects/subscription-status.vo.ts
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
6
apps/backend/src/infrastructure/stripe/index.ts
Normal file
6
apps/backend/src/infrastructure/stripe/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Stripe Infrastructure Barrel Export
|
||||
*/
|
||||
|
||||
export * from './stripe.adapter';
|
||||
export * from './stripe.module';
|
||||
233
apps/backend/src/infrastructure/stripe/stripe.adapter.ts
Normal file
233
apps/backend/src/infrastructure/stripe/stripe.adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/infrastructure/stripe/stripe.module.ts
Normal file
23
apps/backend/src/infrastructure/stripe/stripe.module.ts
Normal 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 {}
|
||||
@ -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
|
||||
|
||||
@ -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'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'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'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"
|
||||
|
||||
31
apps/frontend/app/dashboard/settings/subscription/page.tsx
Normal file
31
apps/frontend/app/dashboard/settings/subscription/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
360
apps/frontend/src/components/organization/LicensesTab.tsx
Normal file
360
apps/frontend/src/components/organization/LicensesTab.tsx
Normal 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'ajout d'un
|
||||
utilisateur
|
||||
</li>
|
||||
<li>
|
||||
Les licences sont libérées lorsqu'un utilisateur est désactivé ou
|
||||
supprimé
|
||||
</li>
|
||||
<li>
|
||||
Pour ajouter plus d'utilisateurs, passez à un plan supérieur dans
|
||||
l'onglet Abonnement
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
apps/frontend/src/components/organization/SubscriptionTab.tsx
Normal file
443
apps/frontend/src/components/organization/SubscriptionTab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
226
apps/frontend/src/lib/api/subscriptions.ts
Normal file
226
apps/frontend/src/lib/api/subscriptions.ts
Normal 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
219
docs/STRIPE_SETUP.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user