import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../database/prisma.service"; import { PaddleService, PaddleWebhookEvent } from "./paddle.service"; import { PlanType, BillingIntervalType, PLAN_LIMITS, PLANS, SubscriptionResponseDto, } from "./dto/subscription.dto"; import { plainToInstance } from "class-transformer"; @Injectable() export class SubscriptionsService { private readonly logger = new Logger(SubscriptionsService.name); constructor( private readonly prisma: PrismaService, private readonly paddleService: PaddleService, ) {} /** * Get current subscription for user */ async getCurrentSubscription( userId: string, ): Promise { const subscription = await this.prisma.subscription.findUnique({ where: { userId }, }); if (!subscription) { return null; } return plainToInstance(SubscriptionResponseDto, subscription); } /** * Get or create subscription record for user */ async getOrCreateSubscription(userId: string) { let subscription = await this.prisma.subscription.findUnique({ where: { userId }, }); if (!subscription) { subscription = await this.prisma.subscription.create({ data: { userId, plan: "free", }, }); } return subscription; } /** * Get all available plans */ getPlans() { return PLANS; } /** * Get checkout configuration (client-side token + price ID) */ getCheckoutConfig( plan: PlanType, billingInterval: BillingIntervalType, ): { priceId: string; clientToken: string; environment: string } { if (plan === PlanType.FREE) { throw new Error("Cannot checkout for free plan"); } const paddlePlan = plan as "plus" | "premium"; const paddleInterval = billingInterval as "monthly" | "yearly"; const priceId = this.paddleService.getPriceId(paddlePlan, paddleInterval); const clientToken = this.paddleService.getClientToken(); const environment = this.paddleService.getEnvironment(); return { priceId, clientToken, environment }; } /** * Handle incoming Paddle webhook event */ async handleWebhookEvent(event: PaddleWebhookEvent): Promise { const eventType = event.event_type; const data = event.data; this.logger.log(`Processing Paddle webhook: ${eventType}`); switch (eventType) { case "subscription.created": case "subscription.updated": await this.handleSubscriptionUpdate(data); break; case "subscription.canceled": await this.handleSubscriptionCancelled(data); break; case "subscription.past_due": await this.handleSubscriptionPastDue(data); break; case "subscription.resumed": await this.handleSubscriptionResumed(data); break; case "transaction.completed": this.logger.log( `Transaction completed: ${(data as Record).id}`, ); break; case "transaction.payment_failed": this.logger.warn( `Payment failed for transaction: ${(data as Record).id}`, ); break; default: this.logger.debug(`Unhandled Paddle event: ${eventType}`); } } /** * Cancel subscription for user */ async cancelSubscription(userId: string): Promise { const subscription = await this.prisma.subscription.findUnique({ where: { userId }, }); if (!subscription?.paddleSubscriptionId) { throw new NotFoundException("No active subscription found"); } await this.paddleService.cancelSubscription( subscription.paddleSubscriptionId, "next_billing_period", ); this.logger.log(`Cancellation requested for user ${userId}`); } // ── Private Handlers ── private async handleSubscriptionUpdate( data: Record, ): Promise { const paddleSubId = data.id as string; const customerId = data.customer_id as string; const status = data.status as string; const customData = data.custom_data as { userId?: string } | undefined; const items = data.items as Array<{ price: { id: string } }> | undefined; const currentBillingPeriod = data.current_billing_period as | { starts_at: string; ends_at: string } | undefined; const userId = customData?.userId; if (!userId) { this.logger.warn( `No userId in custom_data for subscription ${paddleSubId}`, ); return; } // Determine plan from price ID const priceId = items?.[0]?.price?.id; let plan: PlanType = PlanType.FREE; let interval: BillingIntervalType = BillingIntervalType.MONTHLY; if (priceId) { const mapped = this.paddleService.mapPriceIdToPlan(priceId); if (mapped) { plan = mapped.plan as PlanType; interval = mapped.interval as BillingIntervalType; } } // Determine effective plan based on Paddle status const effectivePlan = status === "active" || status === "trialing" ? plan : PlanType.FREE; // Upsert subscription record await this.prisma.subscription.upsert({ where: { userId }, update: { paddleSubscriptionId: paddleSubId, paddleCustomerId: customerId, plan: effectivePlan, billingInterval: interval, paddlePriceId: priceId ?? null, currentPeriodStart: currentBillingPeriod?.starts_at ? new Date(currentBillingPeriod.starts_at) : null, currentPeriodEnd: currentBillingPeriod?.ends_at ? new Date(currentBillingPeriod.ends_at) : null, cancelledAt: null, cancelEffectiveDate: null, }, create: { userId, paddleSubscriptionId: paddleSubId, paddleCustomerId: customerId, plan: effectivePlan, billingInterval: interval, paddlePriceId: priceId ?? null, currentPeriodStart: currentBillingPeriod?.starts_at ? new Date(currentBillingPeriod.starts_at) : null, currentPeriodEnd: currentBillingPeriod?.ends_at ? new Date(currentBillingPeriod.ends_at) : null, }, }); // Sync user subscription status await this.prisma.user.update({ where: { id: userId }, data: { subscriptionStatus: effectivePlan }, }); // Sync usage limits with plan await this.syncLimitsWithPlan(userId, effectivePlan); this.logger.log( `Subscription updated: user=${userId}, plan=${effectivePlan}, interval=${interval}`, ); } private async handleSubscriptionCancelled( data: Record, ): Promise { const paddleSubId = data.id as string; const canceledAt = data.canceled_at as string | undefined; const currentBillingPeriod = data.current_billing_period as | { ends_at: string } | undefined; const subscription = await this.prisma.subscription.findUnique({ where: { paddleSubscriptionId: paddleSubId }, }); if (!subscription) { this.logger.warn(`Subscription not found for cancel: ${paddleSubId}`); return; } const effectiveDate = currentBillingPeriod?.ends_at ? new Date(currentBillingPeriod.ends_at) : new Date(); await this.prisma.subscription.update({ where: { id: subscription.id }, data: { plan: "cancelled", cancelledAt: canceledAt ? new Date(canceledAt) : new Date(), cancelEffectiveDate: effectiveDate, }, }); // Downgrade user to free await this.prisma.user.update({ where: { id: subscription.userId }, data: { subscriptionStatus: "free" }, }); await this.syncLimitsWithPlan(subscription.userId, PlanType.FREE); this.logger.log( `Subscription cancelled: user=${subscription.userId}, effective=${effectiveDate.toISOString()}`, ); } private async handleSubscriptionPastDue( data: Record, ): Promise { const paddleSubId = data.id as string; const subscription = await this.prisma.subscription.findUnique({ where: { paddleSubscriptionId: paddleSubId }, }); if (!subscription) { return; } await this.prisma.subscription.update({ where: { id: subscription.id }, data: { plan: "past_due" }, }); await this.prisma.user.update({ where: { id: subscription.userId }, data: { subscriptionStatus: "past_due" }, }); this.logger.warn(`Subscription past due: user=${subscription.userId}`); } private async handleSubscriptionResumed( data: Record, ): Promise { // Re-process as an update to restore the plan await this.handleSubscriptionUpdate(data); } /** * Sync usage limits with plan tier */ public async syncLimitsWithPlan( userId: string, plan: PlanType, ): Promise { const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS[PlanType.FREE]; await this.prisma.usageLimit.upsert({ where: { userId }, update: { maxAnalyses: limits.maxAnalyses, maxCoupons: limits.maxCoupons, }, create: { userId, analysisCount: 0, couponCount: 0, maxAnalyses: limits.maxAnalyses, maxCoupons: limits.maxCoupons, lastResetDate: new Date(), }, }); } }