gg
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user