685 lines
22 KiB
TypeScript
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],
|
|
};
|
|
}
|
|
}
|