xpeditis2.0/apps/backend/src/application/services/subscription.service.ts
2026-01-20 11:28:54 +01:00

685 lines
22 KiB
TypeScript

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