/** * 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], }; } }