/** * 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 { PLATFORM_ADMIN_ROLE, effectivePlan as resolveEffectivePlan, } from '@domain/services/subscription-access'; import { NoLicensesAvailableException, 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 * ADMIN users always see a PLATINIUM plan with no expiration */ async getSubscriptionOverview( organizationId: string, userRole?: 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 ); // ADMIN users always have PLATINIUM plan with no expiration. // La regle vit dans le domaine : l'assistant la lit au meme endroit. const isAdmin = userRole === PLATFORM_ADMIN_ROLE; const effectivePlan = resolveEffectivePlan(userRole, subscription.accessPlan); const maxLicenses = effectivePlan.maxLicenses; const availableLicenses = effectivePlan.isUnlimited() ? -1 : Math.max(0, maxLicenses - usedLicenses); return { id: subscription.id, organizationId: subscription.organizationId, plan: effectivePlan.value as SubscriptionPlanDto, planDetails: this.mapPlanToDto(effectivePlan), status: subscription.status.value as SubscriptionStatusDto, usedLicenses, maxLicenses, availableLicenses, cancelAtPeriodEnd: false, currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined, currentPeriodEnd: isAdmin ? undefined : 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 and always have unlimited licenses */ async canInviteUser(organizationId: string, userRole?: string): Promise { // ADMIN users always have unlimited invitations if (userRole === 'ADMIN') { return { canInvite: true, availableLicenses: -1, usedLicenses: 0, maxLicenses: -1, message: undefined, }; } 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.BRONZE) { throw new BadRequestException('Cannot create checkout session for Bronze 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) { if (checkoutSession.metadata?.organizationId !== organizationId) { throw new ForbiddenException('Checkout session does not belong to this organization'); } 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.bronze(), }); subscription = await this.subscriptionRepository.save(subscription); this.logger.log(`Created Bronze 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); // Update organization status badge to match the plan await this.updateOrganizationBadge(organizationId, subscription.statusBadge); 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); // Update organization status badge to match the plan if (subscription.organizationId) { await this.updateOrganizationBadge(subscription.organizationId, subscription.statusBadge); } 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.cancel(); await this.subscriptionRepository.save(canceledSubscription); // Reset organization badge to 'none' on cancellation if (subscription.organizationId) { await this.updateOrganizationBadge(subscription.organizationId, 'none'); } this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to Bronze`); } 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 async updateOrganizationBadge(organizationId: string, badge: string): Promise { try { const organization = await this.organizationRepository.findById(organizationId); if (organization) { organization.updateStatusBadge(badge as 'none' | 'silver' | 'gold' | 'platinium'); await this.organizationRepository.save(organization); this.logger.log(`Updated status badge for organization ${organizationId} to ${badge}`); } } catch (error: any) { this.logger.error(`Failed to update organization badge: ${error?.message}`, error?.stack); } } private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto { return { plan: plan.value as SubscriptionPlanDto, name: plan.name, maxLicenses: plan.maxLicenses, monthlyPriceEur: plan.monthlyPriceEur, yearlyPriceEur: plan.yearlyPriceEur, maxShipmentsPerYear: plan.maxShipmentsPerYear, bookingFeeEur: plan.bookingFeeEur, supportLevel: plan.supportLevel, statusBadge: plan.statusBadge, planFeatures: [...plan.planFeatures], features: [...plan.features], }; } }