335 lines
9.3 KiB
TypeScript
335 lines
9.3 KiB
TypeScript
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(),
|
|
},
|
|
});
|
|
}
|
|
}
|