From 5c7834c7e4b75ccdda928c05e19694b74b4affc4 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 20 Jan 2026 11:28:54 +0100 Subject: [PATCH] fix licensing --- apps/backend/.env.example | 15 + apps/backend/package-lock.json | 17 +- apps/backend/package.json | 4 +- apps/backend/scripts/list-stripe-prices.js | 55 ++ apps/backend/src/app.module.ts | 11 + .../src/application/auth/auth.module.ts | 4 + .../src/application/auth/auth.service.ts | 14 +- .../controllers/subscriptions.controller.ts | 266 +++++++ .../controllers/users.controller.ts | 23 +- .../src/application/dto/subscription.dto.ts | 378 ++++++++++ .../services/invitation.service.ts | 17 +- .../services/subscription.service.ts | 684 ++++++++++++++++++ .../subscriptions/subscriptions.module.ts | 71 ++ .../src/application/users/users.module.ts | 4 +- apps/backend/src/domain/entities/index.ts | 2 + .../domain/entities/license.entity.spec.ts | 270 +++++++ .../src/domain/entities/license.entity.ts | 167 +++++ .../entities/subscription.entity.spec.ts | 405 +++++++++++ .../domain/entities/subscription.entity.ts | 355 +++++++++ apps/backend/src/domain/exceptions/index.ts | 1 + .../exceptions/subscription.exceptions.ts | 85 +++ apps/backend/src/domain/ports/out/index.ts | 3 + .../domain/ports/out/license.repository.ts | 62 ++ .../src/domain/ports/out/stripe.port.ts | 113 +++ .../ports/out/subscription.repository.ts | 46 ++ .../backend/src/domain/value-objects/index.ts | 3 + .../domain/value-objects/license-status.vo.ts | 74 ++ .../subscription-plan.vo.spec.ts | 223 ++++++ .../value-objects/subscription-plan.vo.ts | 203 ++++++ .../value-objects/subscription-status.vo.ts | 215 ++++++ .../persistence/typeorm/entities/index.ts | 2 + .../typeorm/entities/license.orm-entity.ts | 60 ++ .../entities/subscription.orm-entity.ts | 108 +++ .../persistence/typeorm/mappers/index.ts | 2 + .../typeorm/mappers/license-orm.mapper.ts | 48 ++ .../mappers/subscription-orm.mapper.ts | 58 ++ .../1738000000001-CreateSubscriptions.ts | 98 +++ .../1738000000002-CreateLicenses.ts | 72 ++ .../1738000000003-SeedFreeSubscriptions.ts | 75 ++ .../persistence/typeorm/repositories/index.ts | 2 + .../typeorm-license.repository.ts | 90 +++ .../typeorm-subscription.repository.ts | 60 ++ .../src/infrastructure/stripe/index.ts | 6 + .../infrastructure/stripe/stripe.adapter.ts | 233 ++++++ .../infrastructure/stripe/stripe.module.ts | 23 + apps/backend/src/main.ts | 2 + .../dashboard/settings/organization/page.tsx | 124 ++-- .../dashboard/settings/subscription/page.tsx | 31 + .../app/dashboard/settings/users/page.tsx | 110 ++- .../components/organization/LicensesTab.tsx | 360 +++++++++ .../organization/SubscriptionTab.tsx | 443 ++++++++++++ apps/frontend/src/lib/api/index.ts | 22 + apps/frontend/src/lib/api/subscriptions.ts | 226 ++++++ docs/STRIPE_SETUP.md | 219 ++++++ 54 files changed, 6202 insertions(+), 62 deletions(-) create mode 100644 apps/backend/scripts/list-stripe-prices.js create mode 100644 apps/backend/src/application/controllers/subscriptions.controller.ts create mode 100644 apps/backend/src/application/dto/subscription.dto.ts create mode 100644 apps/backend/src/application/services/subscription.service.ts create mode 100644 apps/backend/src/application/subscriptions/subscriptions.module.ts create mode 100644 apps/backend/src/domain/entities/license.entity.spec.ts create mode 100644 apps/backend/src/domain/entities/license.entity.ts create mode 100644 apps/backend/src/domain/entities/subscription.entity.spec.ts create mode 100644 apps/backend/src/domain/entities/subscription.entity.ts create mode 100644 apps/backend/src/domain/exceptions/subscription.exceptions.ts create mode 100644 apps/backend/src/domain/ports/out/license.repository.ts create mode 100644 apps/backend/src/domain/ports/out/stripe.port.ts create mode 100644 apps/backend/src/domain/ports/out/subscription.repository.ts create mode 100644 apps/backend/src/domain/value-objects/license-status.vo.ts create mode 100644 apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts create mode 100644 apps/backend/src/domain/value-objects/subscription-plan.vo.ts create mode 100644 apps/backend/src/domain/value-objects/subscription-status.vo.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts create mode 100644 apps/backend/src/infrastructure/stripe/index.ts create mode 100644 apps/backend/src/infrastructure/stripe/stripe.adapter.ts create mode 100644 apps/backend/src/infrastructure/stripe/stripe.module.ts create mode 100644 apps/frontend/app/dashboard/settings/subscription/page.tsx create mode 100644 apps/frontend/src/components/organization/LicensesTab.tsx create mode 100644 apps/frontend/src/components/organization/SubscriptionTab.tsx create mode 100644 apps/frontend/src/lib/api/subscriptions.ts create mode 100644 docs/STRIPE_SETUP.md diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 3da8e77..831f9b8 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -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 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 914fd4b..ec5c11b 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -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", diff --git a/apps/backend/package.json b/apps/backend/package.json index c5763d4..82ec0e6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/list-stripe-prices.js b/apps/backend/scripts/list-stripe-prices.js new file mode 100644 index 0000000..2756851 --- /dev/null +++ b/apps/backend/scripts/list-stripe-prices.js @@ -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(); diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3bcde64..cc54c5c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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: [ diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index bdb3bbf..98af8bc 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -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: [ diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index f01e9f9..cbcc17d 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -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}`); diff --git a/apps/backend/src/application/controllers/subscriptions.controller.ts b/apps/backend/src/application/controllers/subscriptions.controller.ts new file mode 100644 index 0000000..f3e933b --- /dev/null +++ b/apps/backend/src/application/controllers/subscriptions.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): 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 }; + } + } +} diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 1609a16..99793db 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -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); diff --git a/apps/backend/src/application/dto/subscription.dto.ts b/apps/backend/src/application/dto/subscription.dto.ts new file mode 100644 index 0000000..a8e7f75 --- /dev/null +++ b/apps/backend/src/application/dto/subscription.dto.ts @@ -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[]; +} diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index d3ac67e..1dc6d6e 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -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(); diff --git a/apps/backend/src/application/services/subscription.service.ts b/apps/backend/src/application/services/subscription.service.ts new file mode 100644 index 0000000..90951de --- /dev/null +++ b/apps/backend/src/application/services/subscription.service.ts @@ -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 { + 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 { + 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 { + 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( + '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 { + 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( + '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 { + 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 { + 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 { + 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 { + 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 { + 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, + ): Promise { + const metadata = session.metadata as Record | 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, + ): Promise { + 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, + ): Promise { + 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): Promise { + 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], + }; + } +} diff --git a/apps/backend/src/application/subscriptions/subscriptions.module.ts b/apps/backend/src/application/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..6e71a25 --- /dev/null +++ b/apps/backend/src/application/subscriptions/subscriptions.module.ts @@ -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 {} diff --git a/apps/backend/src/application/users/users.module.ts b/apps/backend/src/application/users/users.module.ts index da76372..a5f714c 100644 --- a/apps/backend/src/application/users/users.module.ts +++ b/apps/backend/src/application/users/users.module.ts @@ -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: [ diff --git a/apps/backend/src/domain/entities/index.ts b/apps/backend/src/domain/entities/index.ts index 862b4a7..409cffc 100644 --- a/apps/backend/src/domain/entities/index.ts +++ b/apps/backend/src/domain/entities/index.ts @@ -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'; diff --git a/apps/backend/src/domain/entities/license.entity.spec.ts b/apps/backend/src/domain/entities/license.entity.spec.ts new file mode 100644 index 0000000..d9bf155 --- /dev/null +++ b/apps/backend/src/domain/entities/license.entity.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/backend/src/domain/entities/license.entity.ts b/apps/backend/src/domain/entities/license.entity.ts new file mode 100644 index 0000000..75da6b7 --- /dev/null +++ b/apps/backend/src/domain/entities/license.entity.ts @@ -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, + }; + } +} diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts new file mode 100644 index 0000000..9b554de --- /dev/null +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/backend/src/domain/entities/subscription.entity.ts b/apps/backend/src/domain/entities/subscription.entity.ts new file mode 100644 index 0000000..572af04 --- /dev/null +++ b/apps/backend/src/domain/entities/subscription.entity.ts @@ -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, + }; + } +} diff --git a/apps/backend/src/domain/exceptions/index.ts b/apps/backend/src/domain/exceptions/index.ts index d8ae549..a35e026 100644 --- a/apps/backend/src/domain/exceptions/index.ts +++ b/apps/backend/src/domain/exceptions/index.ts @@ -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'; diff --git a/apps/backend/src/domain/exceptions/subscription.exceptions.ts b/apps/backend/src/domain/exceptions/subscription.exceptions.ts new file mode 100644 index 0000000..55cdcbd --- /dev/null +++ b/apps/backend/src/domain/exceptions/subscription.exceptions.ts @@ -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, + ); + } +} diff --git a/apps/backend/src/domain/ports/out/index.ts b/apps/backend/src/domain/ports/out/index.ts index 2f50b35..9f47d85 100644 --- a/apps/backend/src/domain/ports/out/index.ts +++ b/apps/backend/src/domain/ports/out/index.ts @@ -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'; diff --git a/apps/backend/src/domain/ports/out/license.repository.ts b/apps/backend/src/domain/ports/out/license.repository.ts new file mode 100644 index 0000000..c857d1e --- /dev/null +++ b/apps/backend/src/domain/ports/out/license.repository.ts @@ -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; + + /** + * Find a license by its ID + */ + findById(id: string): Promise; + + /** + * Find a license by user ID + */ + findByUserId(userId: string): Promise; + + /** + * Find all licenses for a subscription + */ + findBySubscriptionId(subscriptionId: string): Promise; + + /** + * Find all active licenses for a subscription + */ + findActiveBySubscriptionId(subscriptionId: string): Promise; + + /** + * Count active licenses for a subscription + */ + countActiveBySubscriptionId(subscriptionId: string): Promise; + + /** + * 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; + + /** + * Find all active licenses for a subscription, excluding ADMIN users + */ + findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise; + + /** + * Delete a license + */ + delete(id: string): Promise; + + /** + * Delete all licenses for a subscription + */ + deleteBySubscriptionId(subscriptionId: string): Promise; +} diff --git a/apps/backend/src/domain/ports/out/stripe.port.ts b/apps/backend/src/domain/ports/out/stripe.port.ts new file mode 100644 index 0000000..564dbfa --- /dev/null +++ b/apps/backend/src/domain/ports/out/stripe.port.ts @@ -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; +} + +export interface StripeWebhookEvent { + type: string; + data: { + object: Record; + }; +} + +export interface StripePort { + /** + * Create a Stripe Checkout session for subscription purchase + */ + createCheckoutSession( + input: CreateCheckoutSessionInput, + ): Promise; + + /** + * Create a Stripe Customer Portal session for subscription management + */ + createPortalSession( + input: CreatePortalSessionInput, + ): Promise; + + /** + * Retrieve subscription details from Stripe + */ + getSubscription(subscriptionId: string): Promise; + + /** + * Retrieve checkout session details from Stripe + */ + getCheckoutSession(sessionId: string): Promise; + + /** + * Cancel a subscription at period end + */ + cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise; + + /** + * Cancel a subscription immediately + */ + cancelSubscriptionImmediately(subscriptionId: string): Promise; + + /** + * Resume a canceled subscription + */ + resumeSubscription(subscriptionId: string): Promise; + + /** + * Verify and parse a Stripe webhook event + */ + constructWebhookEvent( + payload: string | Buffer, + signature: string, + ): Promise; + + /** + * Map a Stripe price ID to a subscription plan + */ + mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null; +} diff --git a/apps/backend/src/domain/ports/out/subscription.repository.ts b/apps/backend/src/domain/ports/out/subscription.repository.ts new file mode 100644 index 0000000..2793601 --- /dev/null +++ b/apps/backend/src/domain/ports/out/subscription.repository.ts @@ -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; + + /** + * Find a subscription by its ID + */ + findById(id: string): Promise; + + /** + * Find a subscription by organization ID + */ + findByOrganizationId(organizationId: string): Promise; + + /** + * Find a subscription by Stripe subscription ID + */ + findByStripeSubscriptionId(stripeSubscriptionId: string): Promise; + + /** + * Find a subscription by Stripe customer ID + */ + findByStripeCustomerId(stripeCustomerId: string): Promise; + + /** + * Find all subscriptions + */ + findAll(): Promise; + + /** + * Delete a subscription + */ + delete(id: string): Promise; +} diff --git a/apps/backend/src/domain/value-objects/index.ts b/apps/backend/src/domain/value-objects/index.ts index 13d1f43..1773663 100644 --- a/apps/backend/src/domain/value-objects/index.ts +++ b/apps/backend/src/domain/value-objects/index.ts @@ -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'; diff --git a/apps/backend/src/domain/value-objects/license-status.vo.ts b/apps/backend/src/domain/value-objects/license-status.vo.ts new file mode 100644 index 0000000..70707e6 --- /dev/null +++ b/apps/backend/src/domain/value-objects/license-status.vo.ts @@ -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; + } +} diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts new file mode 100644 index 0000000..81564a3 --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts new file mode 100644 index 0000000..b82192a --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -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 = { + 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; + } +} diff --git a/apps/backend/src/domain/value-objects/subscription-status.vo.ts b/apps/backend/src/domain/value-objects/subscription-status.vo.ts new file mode 100644 index 0000000..de87862 --- /dev/null +++ b/apps/backend/src/domain/value-objects/subscription-status.vo.ts @@ -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 = { + 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 = { + 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 = { + 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, '-'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts index 31d7f2c..7d3cac9 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/index.ts @@ -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'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts new file mode 100644 index 0000000..afde22a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/license.orm-entity.ts @@ -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; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts new file mode 100644 index 0000000..941b744 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/subscription.orm-entity.ts @@ -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[]; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts index 7521113..f50e66c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/index.ts @@ -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'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts new file mode 100644 index 0000000..9a4ceb5 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/license-orm.mapper.ts @@ -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)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts new file mode 100644 index 0000000..95c65d0 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -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)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts new file mode 100644 index 0000000..c28f7fd --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000001-CreateSubscriptions.ts @@ -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 { + // 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 { + 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"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts new file mode 100644 index 0000000..d48a8fc --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000002-CreateLicenses.ts @@ -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 { + // 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "licenses" CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS "license_status_enum"`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts new file mode 100644 index 0000000..536aa32 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738000000003-SeedFreeSubscriptions.ts @@ -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 { + // 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 { + // 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' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts index cefb832..3b1a091 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/index.ts @@ -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'; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts new file mode 100644 index 0000000..8c74cd6 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-license.repository.ts @@ -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, + ) {} + + async save(license: License): Promise { + const orm = LicenseOrmMapper.toOrm(license); + const saved = await this.repository.save(orm); + return LicenseOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? LicenseOrmMapper.toDomain(orm) : null; + } + + async findByUserId(userId: string): Promise { + const orm = await this.repository.findOne({ where: { userId } }); + return orm ? LicenseOrmMapper.toDomain(orm) : null; + } + + async findBySubscriptionId(subscriptionId: string): Promise { + const orms = await this.repository.find({ + where: { subscriptionId }, + order: { assignedAt: 'DESC' }, + }); + return LicenseOrmMapper.toDomainMany(orms); + } + + async findActiveBySubscriptionId(subscriptionId: string): Promise { + const orms = await this.repository.find({ + where: { subscriptionId, status: 'ACTIVE' }, + order: { assignedAt: 'DESC' }, + }); + return LicenseOrmMapper.toDomainMany(orms); + } + + async countActiveBySubscriptionId(subscriptionId: string): Promise { + return this.repository.count({ + where: { subscriptionId, status: 'ACTIVE' }, + }); + } + + async countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise { + 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 { + 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 { + await this.repository.delete({ id }); + } + + async deleteBySubscriptionId(subscriptionId: string): Promise { + await this.repository.delete({ subscriptionId }); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts new file mode 100644 index 0000000..5469475 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository.ts @@ -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, + ) {} + + async save(subscription: Subscription): Promise { + const orm = SubscriptionOrmMapper.toOrm(subscription); + const saved = await this.repository.save(orm); + return SubscriptionOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repository.findOne({ where: { id } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByOrganizationId(organizationId: string): Promise { + const orm = await this.repository.findOne({ where: { organizationId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByStripeSubscriptionId( + stripeSubscriptionId: string, + ): Promise { + const orm = await this.repository.findOne({ where: { stripeSubscriptionId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findByStripeCustomerId(stripeCustomerId: string): Promise { + const orm = await this.repository.findOne({ where: { stripeCustomerId } }); + return orm ? SubscriptionOrmMapper.toDomain(orm) : null; + } + + async findAll(): Promise { + const orms = await this.repository.find({ + order: { createdAt: 'DESC' }, + }); + return SubscriptionOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/apps/backend/src/infrastructure/stripe/index.ts b/apps/backend/src/infrastructure/stripe/index.ts new file mode 100644 index 0000000..fee172c --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/index.ts @@ -0,0 +1,6 @@ +/** + * Stripe Infrastructure Barrel Export + */ + +export * from './stripe.adapter'; +export * from './stripe.module'; diff --git a/apps/backend/src/infrastructure/stripe/stripe.adapter.ts b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts new file mode 100644 index 0000000..cf5386a --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/stripe.adapter.ts @@ -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; + private readonly planPriceMap: Map; + + constructor(private readonly configService: ConfigService) { + const apiKey = this.configService.get('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('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('STRIPE_STARTER_MONTHLY_PRICE_ID'); + const starterYearly = this.configService.get('STRIPE_STARTER_YEARLY_PRICE_ID'); + const proMonthly = this.configService.get('STRIPE_PRO_MONTHLY_PRICE_ID'); + const proYearly = this.configService.get('STRIPE_PRO_YEARLY_PRICE_ID'); + const enterpriseMonthly = this.configService.get('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID'); + const enterpriseYearly = this.configService.get('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 { + 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 { + 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 { + 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 { + 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, + }; + } 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 { + 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 { + await this.stripe.subscriptions.cancel(subscriptionId); + + this.logger.log(`Cancelled subscription ${subscriptionId} immediately`); + } + + async resumeSubscription(subscriptionId: string): Promise { + 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 { + const event = this.stripe.webhooks.constructEvent( + payload, + signature, + this.webhookSecret, + ); + + return { + type: event.type, + data: { + object: event.data.object as Record, + }, + }; + } + + mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null { + return this.priceIdMap.get(priceId) || null; + } +} diff --git a/apps/backend/src/infrastructure/stripe/stripe.module.ts b/apps/backend/src/infrastructure/stripe/stripe.module.ts new file mode 100644 index 0000000..47654a9 --- /dev/null +++ b/apps/backend/src/infrastructure/stripe/stripe.module.ts @@ -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 {} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index ad779af..ed82eae 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -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 diff --git a/apps/frontend/app/dashboard/settings/organization/page.tsx b/apps/frontend/app/dashboard/settings/organization/page.tsx index 993b406..f376da3 100644 --- a/apps/frontend/app/dashboard/settings/organization/page.tsx +++ b/apps/frontend/app/dashboard/settings/organization/page.tsx @@ -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('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(null); const [formData, setFormData] = useState({ name: '', @@ -152,16 +165,56 @@ export default function OrganizationSettingsPage() { ); } + const tabs = [ + { + id: 'information' as TabType, + label: 'Informations', + icon: ( + + + + ), + }, + { + id: 'address' as TabType, + label: 'Adresse', + icon: ( + + + + + ), + }, + { + id: 'subscription' as TabType, + label: 'Abonnement', + icon: ( + + + + ), + }, + { + id: 'licenses' as TabType, + label: 'Licences', + icon: ( + + + + ), + }, + ]; + return (
{/* Header */}
-

ParamĂštres de l'organisation

+

ParamĂštres de l'organisation

Gérez les informations de votre organisation

{/* Success Message */} - {successMessage && ( + {successMessage && (activeTab === 'information' || activeTab === 'address') && (
@@ -173,7 +226,7 @@ export default function OrganizationSettingsPage() { )} {/* Error Message */} - {error && ( + {error && (activeTab === 'information' || activeTab === 'address') && (
@@ -185,13 +238,13 @@ export default function OrganizationSettingsPage() { )} {/* Read-only warning for USER role */} - {!canEdit && ( + {!canEdit && (activeTab === 'information' || activeTab === 'address') && (
-

Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation

+

Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation

)} @@ -199,38 +252,23 @@ export default function OrganizationSettingsPage() { {/* Tabs */}
-
@@ -258,7 +296,7 @@ export default function OrganizationSettingsPage() {
)} + + {activeTab === 'subscription' && } + + {activeTab === 'licenses' && }
- {/* Actions */} - {canEdit && ( + {/* Actions (only for information and address tabs) */} + {canEdit && (activeTab === 'information' || activeTab === 'address') && (
+ {licenseStatus?.canInvite ? ( + + ) : ( + + + + Upgrade to Invite + + )}
{success && ( @@ -319,13 +389,23 @@ export default function UsersManagementPage() {

No users

Get started by inviting a team member

- + {licenseStatus?.canInvite ? ( + + ) : ( + + + + Upgrade to Invite + + )}
)} diff --git a/apps/frontend/src/components/organization/LicensesTab.tsx b/apps/frontend/src/components/organization/LicensesTab.tsx new file mode 100644 index 0000000..6f93a71 --- /dev/null +++ b/apps/frontend/src/components/organization/LicensesTab.tsx @@ -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 ( +
+
+
+
+
+
+ ); + } + + 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 ( +
+ {/* Alerts */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* License Summary */} +
+

Résumé des licences

+
+
+

Licences utilisées

+

+ {subscription?.usedLicenses || 0} +

+

Hors ADMIN (illimité)

+
+
+

Licences disponibles

+

+ {subscription?.availableLicenses === -1 + ? 'Illimité' + : subscription?.availableLicenses || 0} +

+
+
+

Licences totales

+

+ {subscription?.maxLicenses === -1 + ? 'Illimité' + : subscription?.maxLicenses || 0} +

+
+
+ + {/* Usage Bar */} + {subscription && subscription.maxLicenses !== -1 && ( +
+
+ Utilisation + + {Math.round(usagePercentage)}% + +
+
+
= 90 + ? 'bg-red-600' + : usagePercentage >= 70 + ? 'bg-yellow-500' + : 'bg-blue-600' + }`} + style={{ width: `${Math.min(usagePercentage, 100)}%` }} + >
+
+
+ )} +
+ + {/* Active Licenses */} +
+
+

+ Licences actives ({activeLicenses.length}) +

+
+ {activeLicenses.length === 0 ? ( +
+ Aucune licence active +
+ ) : ( +
+ + + + + + + + + + + + {activeLicenses.map((license) => { + const isAdmin = license.userRole === 'ADMIN'; + return ( + + + + + + + + ); + })} + +
+ Utilisateur + + Email + + RÎle + + Assignée le + + Licence +
+
+ {license.userName} +
+
+
{license.userEmail}
+
+ + {license.userRole} + + +
+ {new Date(license.assignedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+
+ {isAdmin ? ( + + Illimité + + ) : ( + + Active + + )} +
+
+ )} +
+ + {/* Revoked Licenses (History) */} + {revokedLicenses.length > 0 && ( +
+
+

+ Historique des licences révoquées ({revokedLicenses.length}) +

+
+
+ + + + + + + + + + + + + {revokedLicenses.map((license) => ( + + + + + + + + + ))} + +
+ Utilisateur + + Email + + RÎle + + Assignée le + + Révoquée le + + Statut +
+
+ {license.userName} +
+
+
{license.userEmail}
+
+ + {license.userRole} + + +
+ {new Date(license.assignedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+
+
+ {license.revokedAt + ? new Date(license.revokedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + : '-'} +
+
+ + Révoquée + +
+
+
+ )} + + {/* Info Box */} +
+
+
+ + + +
+
+

+ Comment fonctionnent les licences ? +

+
+
    +
  • + Chaque utilisateur actif de votre organisation consomme une licence +
  • +
  • + Les administrateurs (ADMIN) ont des licences illimitĂ©es et ne sont pas comptĂ©s dans le quota +
  • +
  • + Les licences sont automatiquement assignĂ©es lors de l'ajout d'un + utilisateur +
  • +
  • + Les licences sont libĂ©rĂ©es lorsqu'un utilisateur est dĂ©sactivĂ© ou + supprimĂ© +
  • +
  • + Pour ajouter plus d'utilisateurs, passez Ă  un plan supĂ©rieur dans + l'onglet Abonnement +
  • +
+
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/organization/SubscriptionTab.tsx b/apps/frontend/src/components/organization/SubscriptionTab.tsx new file mode 100644 index 0000000..30efbff --- /dev/null +++ b/apps/frontend/src/components/organization/SubscriptionTab.tsx @@ -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('monthly'); + const [selectedPlan, setSelectedPlan] = useState(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 ( +
+
+
+
+
+
+
+ ); + } + + const usagePercentage = subscription + ? subscription.maxLicenses === -1 + ? 0 + : (subscription.usedLicenses / subscription.maxLicenses) * 100 + : 0; + + return ( +
+ {/* Alerts */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} + {isRefreshing && ( + + + + + + Mise Ă  jour... + + )} +
+ )} + + {/* Current Plan */} + {subscription && ( +
+
+

Plan actuel

+ +
+
+
+ + {subscription.planDetails.name} + + + {subscription.status === 'ACTIVE' ? 'Actif' : subscription.status} + + {subscription.cancelAtPeriodEnd && ( + + Annulation prévue + + )} +
+ {subscription.plan !== 'FREE' && ( + + )} +
+ + {/* License Usage */} +
+
+ Utilisation des licences + + {subscription.usedLicenses} /{' '} + {subscription.maxLicenses === -1 ? 'Illimité' : subscription.maxLicenses} + +
+
+
= 90 + ? 'bg-red-600' + : usagePercentage >= 70 + ? 'bg-yellow-500' + : 'bg-blue-600' + }`} + style={{ + width: + subscription.maxLicenses === -1 + ? '10%' + : `${Math.min(usagePercentage, 100)}%`, + }} + >
+
+ {subscription.availableLicenses !== -1 && subscription.availableLicenses <= 2 && ( +

+ {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'}.`} +

+ )} +

+ Les administrateurs (ADMIN) ont des licences illimitées et ne sont pas comptés. +

+
+ + {/* Billing Period */} + {subscription.currentPeriodEnd && ( +
+ Période actuelle : jusqu'au{' '} + {new Date(subscription.currentPeriodEnd).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +
+ )} +
+ )} + + {/* Plans Grid */} +
+
+

Plans disponibles

+
+ + +
+
+ +
+ {plansData?.plans.map((plan) => ( +
+
+

{plan.name}

+
+ + {plan.plan === 'ENTERPRISE' + ? 'Sur devis' + : formatPrice( + billingInterval === 'yearly' + ? plan.yearlyPriceEur + : plan.monthlyPriceEur, + )} + + {plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && ( + + /{billingInterval === 'yearly' ? 'an' : 'mois'} + + )} +
+

+ {plan.maxLicenses === -1 + ? 'Utilisateurs illimités' + : `Jusqu'à ${plan.maxLicenses} utilisateurs`} +

+
+ +
    + {plan.features.slice(0, 4).map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ +
+ {isCurrentPlan(plan.plan) ? ( + + ) : plan.plan === 'ENTERPRISE' ? ( + + Nous contacter + + ) : canUpgrade(plan.plan) ? ( + + ) : ( + + )} +
+
+ ))} +
+
+ + {/* Info about webhooks in development */} + {process.env.NODE_ENV === 'development' && ( +
+
+
+ + + +
+
+

Mode développement

+
+

+ Pour que les webhooks Stripe fonctionnent en local, exécutez :{' '} + stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook +

+
+
+
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 88905ac..7a850c8 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -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; diff --git a/apps/frontend/src/lib/api/subscriptions.ts b/apps/frontend/src/lib/api/subscriptions.ts new file mode 100644 index 0000000..2e6b96b --- /dev/null +++ b/apps/frontend/src/lib/api/subscriptions.ts @@ -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 { + return get('/api/v1/subscriptions'); +} + +/** + * Get all available plans + */ +export async function getAllPlans(): Promise { + return get('/api/v1/subscriptions/plans'); +} + +/** + * Check if organization can invite more users + */ +export async function canInviteUser(): Promise { + return get('/api/v1/subscriptions/can-invite'); +} + +/** + * Create a Stripe Checkout session for subscription upgrade + */ +export async function createCheckoutSession( + data: CreateCheckoutSessionRequest, +): Promise { + return post('/api/v1/subscriptions/checkout', data); +} + +/** + * Create a Stripe Customer Portal session + */ +export async function createPortalSession( + data?: CreatePortalSessionRequest, +): Promise { + return post('/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 { + return post('/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'; + } +} diff --git a/docs/STRIPE_SETUP.md b/docs/STRIPE_SETUP.md new file mode 100644 index 0000000..fcfc599 --- /dev/null +++ b/docs/STRIPE_SETUP.md @@ -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