This commit is contained in:
2026-05-10 10:37:45 +03:00
parent 4f7090e2d9
commit c525b12dfd
32 changed files with 2374 additions and 209 deletions
@@ -0,0 +1,334 @@
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<SubscriptionResponseDto | null> {
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<void> {
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<string, unknown>).id}`,
);
break;
case "transaction.payment_failed":
this.logger.warn(
`Payment failed for transaction: ${(data as Record<string, unknown>).id}`,
);
break;
default:
this.logger.debug(`Unhandled Paddle event: ${eventType}`);
}
}
/**
* Cancel subscription for user
*/
async cancelSubscription(userId: string): Promise<void> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<void> {
// 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<void> {
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(),
},
});
}
}