gg
This commit is contained in:
@@ -51,6 +51,7 @@ import { AnalysisModule } from "./modules/analysis/analysis.module";
|
||||
import { CouponsModule } from "./modules/coupons/coupons.module";
|
||||
import { SporTotoModule } from "./modules/spor-toto/spor-toto.module";
|
||||
import { AiProxyModule } from "./modules/ai-proxy/ai-proxy.module";
|
||||
import { SubscriptionsModule } from "./modules/subscriptions/subscriptions.module";
|
||||
|
||||
// Services and Tasks
|
||||
import { ServicesModule } from "./services/services.module";
|
||||
@@ -204,6 +205,7 @@ const historicalFeederMode = process.env.FEEDER_MODE === "historical";
|
||||
CouponsModule,
|
||||
SporTotoModule,
|
||||
AiProxyModule,
|
||||
SubscriptionsModule,
|
||||
|
||||
// Services and Scheduled Tasks
|
||||
ServicesModule,
|
||||
|
||||
@@ -243,7 +243,7 @@ export class AiEngineClient {
|
||||
// - 502/503/504 (proxy/gateway errors) → infrastructure
|
||||
// Do NOT count 500 (app-level crash in AI Engine) — it may be
|
||||
// match-specific and shouldn't block all other matches.
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return true;
|
||||
}
|
||||
const status = error.response.status;
|
||||
|
||||
@@ -72,6 +72,16 @@ export const envSchema = z.object({
|
||||
OLLAMA_BASE_URL: z.string().url().optional(),
|
||||
OLLAMA_MODEL: z.string().optional(),
|
||||
|
||||
// Paddle (Subscription Billing)
|
||||
PADDLE_API_KEY: z.string().optional(),
|
||||
PADDLE_WEBHOOK_SECRET: z.string().optional(),
|
||||
PADDLE_CLIENT_TOKEN: z.string().optional(),
|
||||
PADDLE_ENVIRONMENT: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||
PADDLE_PLUS_MONTHLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PLUS_YEARLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PREMIUM_MONTHLY_PRICE_ID: z.string().optional(),
|
||||
PADDLE_PREMIUM_YEARLY_PRICE_ID: z.string().optional(),
|
||||
|
||||
// Optional Features
|
||||
ENABLE_MAIL: booleanString,
|
||||
ENABLE_S3: booleanString,
|
||||
|
||||
@@ -9,5 +9,12 @@
|
||||
"serverError": "An unexpected error occurred",
|
||||
"unauthorized": "You are not authorized to perform this action",
|
||||
"forbidden": "Access denied",
|
||||
"badRequest": "Invalid request"
|
||||
"badRequest": "Bad request",
|
||||
"SUCCESS_USER_ROLE_UPDATED": "User role updated",
|
||||
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "User subscription updated",
|
||||
"SUCCESS_USER_DELETED": "User deleted",
|
||||
"SUCCESS_USER_STATUS_UPDATED": "User status updated",
|
||||
"SUCCESS_SETTING_UPDATED": "Setting updated",
|
||||
"SUCCESS_ALL_LIMITS_RESET": "All usage limits reset",
|
||||
"SUCCESS_USER_LIMITS_RESET": "User usage limits reset"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,13 @@
|
||||
"TENANT_NOT_FOUND": "Tenant not found",
|
||||
"VALIDATION_FAILED": "Validation failed",
|
||||
"INTERNAL_ERROR": "An internal error occurred, please try again later",
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token"
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token",
|
||||
"USAGE_LIMIT_EXCEEDED": "You have exceeded your daily usage limit. Please upgrade your plan.",
|
||||
"ANALYSIS_LIMIT_EXCEEDED": "You have exceeded your daily analysis limit. Please upgrade your plan.",
|
||||
"COUPON_LIMIT_EXCEEDED": "You have exceeded your daily coupon limit. Please upgrade your plan.",
|
||||
"INVALID_PLAN_TYPE": "Invalid plan type. Must be free, plus, or premium.",
|
||||
"MATCH_NOT_FOUND": "Match not found",
|
||||
"PREDICTION_GENERATION_FAILED": "Failed to generate prediction",
|
||||
"SMART_COUPON_GENERATION_FAILED": "Failed to generate Smart Coupon",
|
||||
"ANALYSIS_FAILED": "None of the provided matches could be analyzed successfully"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,12 @@
|
||||
"serverError": "Beklenmeyen bir hata oluştu",
|
||||
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
|
||||
"forbidden": "Erişim reddedildi",
|
||||
"badRequest": "Geçersiz istek"
|
||||
"badRequest": "Geçersiz istek",
|
||||
"SUCCESS_USER_ROLE_UPDATED": "Kullanıcı rolü güncellendi",
|
||||
"SUCCESS_USER_SUBSCRIPTION_UPDATED": "Kullanıcı aboneliği güncellendi",
|
||||
"SUCCESS_USER_DELETED": "Kullanıcı başarıyla silindi",
|
||||
"SUCCESS_USER_STATUS_UPDATED": "Kullanıcı durumu güncellendi",
|
||||
"SUCCESS_SETTING_UPDATED": "Ayar güncellendi",
|
||||
"SUCCESS_ALL_LIMITS_RESET": "Tüm kullanıcı limitleri sıfırlandı",
|
||||
"SUCCESS_USER_LIMITS_RESET": "Kullanıcı limitleri sıfırlandı"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,13 @@
|
||||
"TENANT_NOT_FOUND": "Kiracı bulunamadı",
|
||||
"VALIDATION_FAILED": "Doğrulama başarısız",
|
||||
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın"
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın",
|
||||
"USAGE_LIMIT_EXCEEDED": "Günlük kullanım limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"ANALYSIS_LIMIT_EXCEEDED": "Günlük analiz limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"COUPON_LIMIT_EXCEEDED": "Günlük kupon limitinizi doldurdunuz. Lütfen paketinizi yükseltin.",
|
||||
"INVALID_PLAN_TYPE": "Geçersiz paket tipi. (free, plus, premium olmalıdır)",
|
||||
"MATCH_NOT_FOUND": "Maç bulunamadı",
|
||||
"PREDICTION_GENERATION_FAILED": "Tahmin oluşturulamadı",
|
||||
"SMART_COUPON_GENERATION_FAILED": "Akıllı kupon oluşturulamadı",
|
||||
"ANALYSIS_FAILED": "Sağlanan maçların hiçbiri başarıyla analiz edilemedi"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseInterceptors,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
CacheInterceptor,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { UserResponseDto } from "../users/dto/user.dto";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||
import { PlanType } from "../subscriptions/dto/subscription.dto";
|
||||
|
||||
@ApiTags("Admin")
|
||||
@ApiBearerAuth()
|
||||
@@ -45,6 +48,7 @@ export class AdminController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
) {}
|
||||
|
||||
// ================== Users Management ==================
|
||||
@@ -122,7 +126,7 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, updated),
|
||||
"User status updated",
|
||||
"common.SUCCESS_USER_STATUS_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,31 +144,7 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, user),
|
||||
"User role updated",
|
||||
);
|
||||
}
|
||||
|
||||
@Put("users/:id/subscription")
|
||||
@ApiOperation({ summary: "Update user subscription" })
|
||||
@SwaggerResponse({ status: 200, type: UserResponseDto })
|
||||
async updateUserSubscription(
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
data: { subscriptionStatus: string; subscriptionExpiresAt?: string },
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
subscriptionStatus: data.subscriptionStatus as any,
|
||||
subscriptionExpiresAt: data.subscriptionExpiresAt
|
||||
? new Date(data.subscriptionExpiresAt)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, user),
|
||||
"User subscription updated",
|
||||
"common.SUCCESS_USER_ROLE_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +156,7 @@ export class AdminController {
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
return createSuccessResponse(null, "User deleted");
|
||||
return createSuccessResponse(null, "common.SUCCESS_USER_DELETED");
|
||||
}
|
||||
|
||||
// ================== App Settings ==================
|
||||
@@ -220,7 +200,7 @@ export class AdminController {
|
||||
await this.cacheManager.del("app_settings");
|
||||
return createSuccessResponse(
|
||||
{ key: setting.key, value: setting.value || "" },
|
||||
"Setting updated",
|
||||
"common.SUCCESS_SETTING_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,7 +254,57 @@ export class AdminController {
|
||||
|
||||
return createSuccessResponse(
|
||||
{ count: result.count },
|
||||
"All usage limits reset",
|
||||
"common.SUCCESS_ALL_LIMITS_RESET",
|
||||
);
|
||||
}
|
||||
|
||||
@Post("usage-limits/reset/:userId")
|
||||
@ApiOperation({ summary: "Reset usage limits for a single user" })
|
||||
@SwaggerResponse({ status: 200 })
|
||||
async resetUserUsageLimits(
|
||||
@Param("userId") userId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("USER_NOT_FOUND");
|
||||
|
||||
await this.prisma.usageLimit.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return createSuccessResponse(null, "common.SUCCESS_USER_LIMITS_RESET");
|
||||
}
|
||||
|
||||
@Put("users/:userId/subscription")
|
||||
@ApiOperation({ summary: "Update a user's subscription tier" })
|
||||
@SwaggerResponse({ status: 200 })
|
||||
async updateUserSubscription(
|
||||
@Param("userId") userId: string,
|
||||
@Body() data: { plan: string },
|
||||
): Promise<ApiResponse<null>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("USER_NOT_FOUND");
|
||||
|
||||
const validPlans = [PlanType.FREE, PlanType.PLUS, PlanType.PREMIUM];
|
||||
const newPlan = data.plan as PlanType;
|
||||
if (!validPlans.includes(newPlan)) {
|
||||
throw new BadRequestException("INVALID_PLAN_TYPE");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { subscriptionStatus: newPlan },
|
||||
});
|
||||
|
||||
await this.subscriptionsService.syncLimitsWithPlan(userId, newPlan);
|
||||
|
||||
return createSuccessResponse(
|
||||
null,
|
||||
"common.SUCCESS_USER_SUBSCRIPTION_UPDATED",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,7 +324,9 @@ export class AdminController {
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.user.count({ where: { isActive: true } }),
|
||||
this.prisma.user.count({ where: { subscriptionStatus: "active" } }),
|
||||
this.prisma.user.count({
|
||||
where: { subscriptionStatus: { in: ["plus", "premium"] } },
|
||||
}),
|
||||
this.prisma.match.count(),
|
||||
this.prisma.prediction.count(),
|
||||
this.prisma.userCoupon.count(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
|
||||
|
||||
@Module({
|
||||
imports: [SubscriptionsModule],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class AnalysisController {
|
||||
);
|
||||
|
||||
if (!canProceed) {
|
||||
throw new ForbiddenException("You have exceeded your daily usage limit");
|
||||
throw new ForbiddenException("USAGE_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
@@ -68,7 +68,7 @@ export class AnalysisController {
|
||||
if (!result) {
|
||||
return {
|
||||
success: false,
|
||||
message: "None of the provided matches could be analyzed successfully",
|
||||
message: "ANALYSIS_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export class AnalysisService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check user usage limit
|
||||
* Check user usage limit (plan-aware via UsageLimit table)
|
||||
*/
|
||||
async checkUsageLimit(
|
||||
userId: string,
|
||||
@@ -96,24 +96,23 @@ export class AnalysisService {
|
||||
});
|
||||
|
||||
if (!usageLimit) {
|
||||
// Create default limit
|
||||
// Create default limit with free-tier maxes
|
||||
await this.prisma.usageLimit.create({
|
||||
data: {
|
||||
userId,
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
maxAnalyses: 3,
|
||||
maxCoupons: 1,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check limits (default: 10 analyses, 3 coupons per day)
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const isPremium = user?.subscriptionStatus === "active";
|
||||
|
||||
const maxAnalyses = isPremium ? 50 : 10;
|
||||
const maxCoupons = isPremium ? 10 : 3;
|
||||
// Use plan-aware limits from DB (set by SubscriptionsService.syncLimitsWithPlan)
|
||||
const maxAnalyses = usageLimit.maxAnalyses ?? 3;
|
||||
const maxCoupons = usageLimit.maxCoupons ?? 1;
|
||||
|
||||
if (isCoupon) {
|
||||
return usageLimit.couponCount < maxCoupons;
|
||||
|
||||
@@ -188,10 +188,7 @@ export class LeaguesService {
|
||||
{ homeTeamId: teamId1, awayTeamId: teamId2 },
|
||||
{ homeTeamId: teamId2, awayTeamId: teamId1 },
|
||||
],
|
||||
AND: [
|
||||
{ scoreHome: { not: null } },
|
||||
{ scoreAway: { not: null } },
|
||||
],
|
||||
AND: [{ scoreHome: { not: null } }, { scoreAway: { not: null } }],
|
||||
},
|
||||
include: {
|
||||
homeTeam: true,
|
||||
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
GeneratePredictionDto,
|
||||
SmartCouponRequestDto,
|
||||
} from "./dto/predictions-request.dto";
|
||||
import { Public } from "src/common/decorators";
|
||||
import { CurrentUser } from "src/common/decorators";
|
||||
import { AnalysisService } from "../analysis/analysis.service";
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
|
||||
@ApiTags("Predictions")
|
||||
@Controller("predictions")
|
||||
export class PredictionsController {
|
||||
constructor(private readonly predictionsService: PredictionsService) {}
|
||||
constructor(
|
||||
private readonly predictionsService: PredictionsService,
|
||||
private readonly analysisService: AnalysisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /predictions/health
|
||||
@@ -93,7 +98,6 @@ export class PredictionsController {
|
||||
* Get prediction for a specific match
|
||||
*/
|
||||
@Get(":matchId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Get prediction for a specific match" })
|
||||
@ApiParam({ name: "matchId", description: "Match ID" })
|
||||
@ApiResponse({
|
||||
@@ -103,11 +107,23 @@ export class PredictionsController {
|
||||
type: MatchPredictionDto,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Match not found" })
|
||||
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
|
||||
async getPrediction(
|
||||
@Param("matchId") matchId: string,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<MatchPredictionDto> {
|
||||
const canProceed = await this.analysisService.checkUsageLimit(
|
||||
user.id,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
if (!canProceed) {
|
||||
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
||||
if (cached) {
|
||||
await this.analysisService.recordUsage(user.id, false);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -115,9 +131,10 @@ export class PredictionsController {
|
||||
const prediction = await this.predictionsService.getPredictionById(matchId);
|
||||
|
||||
if (!prediction) {
|
||||
throw new NotFoundException(`Match not found: ${matchId}`);
|
||||
throw new NotFoundException("MATCH_NOT_FOUND");
|
||||
}
|
||||
|
||||
await this.analysisService.recordUsage(user.id, false);
|
||||
return prediction;
|
||||
}
|
||||
|
||||
@@ -129,17 +146,29 @@ export class PredictionsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Generate prediction with provided match data" })
|
||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
|
||||
async generatePrediction(
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: GeneratePredictionDto,
|
||||
): Promise<MatchPredictionDto> {
|
||||
const canProceed = await this.analysisService.checkUsageLimit(
|
||||
user.id,
|
||||
false,
|
||||
1,
|
||||
);
|
||||
if (!canProceed) {
|
||||
throw new ForbiddenException("ANALYSIS_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
const prediction = await this.predictionsService.getPredictionWithData({
|
||||
matchId: dto.matchId,
|
||||
});
|
||||
|
||||
if (!prediction) {
|
||||
throw new NotFoundException("Failed to generate prediction");
|
||||
throw new NotFoundException("PREDICTION_GENERATION_FAILED");
|
||||
}
|
||||
|
||||
await this.analysisService.recordUsage(user.id, false);
|
||||
return prediction;
|
||||
}
|
||||
|
||||
@@ -157,7 +186,20 @@ export class PredictionsController {
|
||||
description: "Smart coupon generated successfully",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
||||
@ApiResponse({ status: 403, description: "Daily limit exceeded" })
|
||||
async generateSmartCoupon(
|
||||
@CurrentUser() user: any,
|
||||
@Body() dto: SmartCouponRequestDto,
|
||||
): Promise<any> {
|
||||
const canProceed = await this.analysisService.checkUsageLimit(
|
||||
user.id,
|
||||
true,
|
||||
dto.matchIds?.length || 1,
|
||||
);
|
||||
if (!canProceed) {
|
||||
throw new ForbiddenException("COUPON_LIMIT_EXCEEDED");
|
||||
}
|
||||
|
||||
const coupon = await this.predictionsService.getSmartCoupon(
|
||||
dto.matchIds,
|
||||
dto.strategy || "BALANCED",
|
||||
@@ -168,9 +210,10 @@ export class PredictionsController {
|
||||
);
|
||||
|
||||
if (!coupon) {
|
||||
throw new NotFoundException("Failed to generate Smart Coupon");
|
||||
throw new NotFoundException("SMART_COUPON_GENERATION_FAILED");
|
||||
}
|
||||
|
||||
await this.analysisService.recordUsage(user.id, true);
|
||||
return coupon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PredictionsQueue } from "./queues/predictions.queue";
|
||||
import { PredictionsProcessor } from "./queues/predictions.processor";
|
||||
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
|
||||
import { FeederModule } from "../feeder/feeder.module";
|
||||
import { AnalysisModule } from "../analysis/analysis.module";
|
||||
|
||||
const redisEnabled = process.env.REDIS_ENABLED === "true";
|
||||
|
||||
@@ -25,6 +26,7 @@ const redisEnabled = process.env.REDIS_ENABLED === "true";
|
||||
: []),
|
||||
MatchesModule,
|
||||
FeederModule,
|
||||
AnalysisModule,
|
||||
],
|
||||
controllers: [PredictionsController],
|
||||
providers: [
|
||||
|
||||
@@ -1354,8 +1354,14 @@ export class PredictionsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private extractCooldownMs(detail: unknown): number {
|
||||
if (detail && typeof detail === "object" && "cooldownRemainingMs" in detail) {
|
||||
return Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0;
|
||||
if (
|
||||
detail &&
|
||||
typeof detail === "object" &&
|
||||
"cooldownRemainingMs" in detail
|
||||
) {
|
||||
return (
|
||||
Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof detail === "string") {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Exclude, Expose, Type } from "class-transformer";
|
||||
|
||||
export enum PlanType {
|
||||
FREE = "free",
|
||||
PLUS = "plus",
|
||||
PREMIUM = "premium",
|
||||
}
|
||||
|
||||
export enum BillingIntervalType {
|
||||
MONTHLY = "monthly",
|
||||
YEARLY = "yearly",
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan feature limits configuration
|
||||
*/
|
||||
export const PLAN_LIMITS: Record<
|
||||
PlanType,
|
||||
{ maxAnalyses: number; maxCoupons: number }
|
||||
> = {
|
||||
[PlanType.FREE]: { maxAnalyses: 3, maxCoupons: 1 },
|
||||
[PlanType.PLUS]: { maxAnalyses: 25, maxCoupons: 5 },
|
||||
[PlanType.PREMIUM]: { maxAnalyses: 999, maxCoupons: 999 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan display information
|
||||
*/
|
||||
export interface PlanInfo {
|
||||
id: PlanType;
|
||||
name: string;
|
||||
description: string;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
currency: string;
|
||||
features: string[];
|
||||
limits: { maxAnalyses: number; maxCoupons: number };
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
export const PLANS: readonly PlanInfo[] = [
|
||||
{
|
||||
id: PlanType.FREE,
|
||||
name: "Free",
|
||||
description: "Temel analiz özellikleri",
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
currency: "TRY",
|
||||
features: ["Günlük 3 analiz", "Günlük 1 kupon", "Temel maç istatistikleri"],
|
||||
limits: PLAN_LIMITS[PlanType.FREE],
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
id: PlanType.PLUS,
|
||||
name: "Plus",
|
||||
description: "Detaylı analiz ve daha fazla kupon",
|
||||
monthlyPrice: 99,
|
||||
yearlyPrice: 999,
|
||||
currency: "TRY",
|
||||
features: [
|
||||
"Günlük 25 analiz",
|
||||
"Günlük 5 kupon",
|
||||
"AI detaylı analiz",
|
||||
"H2H karşılaştırma",
|
||||
"Reklamsız deneyim",
|
||||
],
|
||||
limits: PLAN_LIMITS[PlanType.PLUS],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: PlanType.PREMIUM,
|
||||
name: "Premium",
|
||||
description: "Sınırsız erişim ve özel özellikler",
|
||||
monthlyPrice: 249,
|
||||
yearlyPrice: 2499,
|
||||
currency: "TRY",
|
||||
features: [
|
||||
"Sınırsız analiz",
|
||||
"Sınırsız kupon",
|
||||
"AI detaylı analiz",
|
||||
"H2H karşılaştırma",
|
||||
"Kupon Builder",
|
||||
"Spor Toto analiz",
|
||||
"Reklamsız deneyim",
|
||||
"Öncelikli destek",
|
||||
],
|
||||
limits: PLAN_LIMITS[PlanType.PREMIUM],
|
||||
highlighted: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ── Response DTOs ──
|
||||
|
||||
@Exclude()
|
||||
export class UsageLimitResponseDto {
|
||||
@Expose()
|
||||
analysisCount: number;
|
||||
|
||||
@Expose()
|
||||
couponCount: number;
|
||||
|
||||
@Expose()
|
||||
maxAnalyses: number;
|
||||
|
||||
@Expose()
|
||||
maxCoupons: number;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class SubscriptionResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
plan: string;
|
||||
|
||||
@Expose()
|
||||
billingInterval: string | null;
|
||||
|
||||
@Expose()
|
||||
currentPeriodStart: Date | null;
|
||||
|
||||
@Expose()
|
||||
currentPeriodEnd: Date | null;
|
||||
|
||||
@Expose()
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@Expose()
|
||||
cancelEffectiveDate: Date | null;
|
||||
|
||||
@Expose()
|
||||
paddlePriceId: string | null;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ── Request DTOs ──
|
||||
|
||||
export class CreateCheckoutDto {
|
||||
@ApiProperty({
|
||||
enum: PlanType,
|
||||
example: PlanType.PLUS,
|
||||
description: "Target plan",
|
||||
})
|
||||
@IsEnum(PlanType)
|
||||
plan: PlanType;
|
||||
|
||||
@ApiProperty({
|
||||
enum: BillingIntervalType,
|
||||
example: BillingIntervalType.MONTHLY,
|
||||
description: "Billing interval",
|
||||
})
|
||||
@IsEnum(BillingIntervalType)
|
||||
billingInterval: BillingIntervalType;
|
||||
}
|
||||
|
||||
export class CancelSubscriptionDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Reason for cancellation",
|
||||
example: "Too expensive",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
export interface PaddleWebhookEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
occurred_at: string;
|
||||
notification_id: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PaddleTransactionResponse {
|
||||
data: {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaddleService {
|
||||
private readonly logger = new Logger(PaddleService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly webhookSecret: string;
|
||||
private readonly environment: "sandbox" | "production";
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.apiKey = this.config.get<string>("PADDLE_API_KEY", "");
|
||||
this.webhookSecret = this.config.get<string>("PADDLE_WEBHOOK_SECRET", "");
|
||||
this.environment = this.config.get<"sandbox" | "production">(
|
||||
"PADDLE_ENVIRONMENT",
|
||||
"sandbox",
|
||||
);
|
||||
this.baseUrl =
|
||||
this.environment === "production"
|
||||
? "https://api.paddle.com"
|
||||
: "https://sandbox-api.paddle.com";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Paddle webhook signature (Paddle Billing v2)
|
||||
*/
|
||||
verifyWebhookSignature(rawBody: string, signatureHeader: string): boolean {
|
||||
if (!this.webhookSecret) {
|
||||
this.logger.warn(
|
||||
"PADDLE_WEBHOOK_SECRET not configured, skipping verification",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Paddle signature format: ts=TIMESTAMP;h1=HASH
|
||||
const parts = signatureHeader.split(";");
|
||||
const tsValue = parts
|
||||
.find((p) => p.startsWith("ts="))
|
||||
?.replace("ts=", "");
|
||||
const h1Value = parts
|
||||
.find((p) => p.startsWith("h1="))
|
||||
?.replace("h1=", "");
|
||||
|
||||
if (!tsValue || !h1Value) {
|
||||
this.logger.warn("Invalid Paddle signature format");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute expected signature: HMAC-SHA256(ts + ':' + rawBody)
|
||||
const signedPayload = `${tsValue}:${rawBody}`;
|
||||
const expectedSignature = crypto
|
||||
.createHmac("sha256", this.webhookSecret)
|
||||
.update(signedPayload)
|
||||
.digest("hex");
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(h1Value),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Webhook signature verification failed: ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a Paddle subscription
|
||||
*/
|
||||
async cancelSubscription(
|
||||
paddleSubscriptionId: string,
|
||||
effectiveFrom:
|
||||
| "immediately"
|
||||
| "next_billing_period" = "next_billing_period",
|
||||
): Promise<void> {
|
||||
const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}/cancel`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ effective_from: effectiveFrom }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Paddle cancel failed: ${response.status} ${body}`);
|
||||
throw new Error(
|
||||
`Failed to cancel Paddle subscription: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Paddle subscription ${paddleSubscriptionId} cancelled (effective: ${effectiveFrom})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription details from Paddle
|
||||
*/
|
||||
async getSubscription(
|
||||
paddleSubscriptionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = `${this.baseUrl}/subscriptions/${paddleSubscriptionId}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Paddle subscription: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data: Record<string, unknown> };
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Paddle price ID to our internal plan
|
||||
*/
|
||||
mapPriceIdToPlan(priceId: string): {
|
||||
plan: "plus" | "premium";
|
||||
interval: "monthly" | "yearly";
|
||||
} | null {
|
||||
const mapping: Record<
|
||||
string,
|
||||
{ plan: "plus" | "premium"; interval: "monthly" | "yearly" }
|
||||
> = {
|
||||
[this.config.get<string>("PADDLE_PLUS_MONTHLY_PRICE_ID", "")]: {
|
||||
plan: "plus",
|
||||
interval: "monthly",
|
||||
},
|
||||
[this.config.get<string>("PADDLE_PLUS_YEARLY_PRICE_ID", "")]: {
|
||||
plan: "plus",
|
||||
interval: "yearly",
|
||||
},
|
||||
[this.config.get<string>("PADDLE_PREMIUM_MONTHLY_PRICE_ID", "")]: {
|
||||
plan: "premium",
|
||||
interval: "monthly",
|
||||
},
|
||||
[this.config.get<string>("PADDLE_PREMIUM_YEARLY_PRICE_ID", "")]: {
|
||||
plan: "premium",
|
||||
interval: "yearly",
|
||||
},
|
||||
};
|
||||
|
||||
// Remove empty key (from missing env vars)
|
||||
delete mapping[""];
|
||||
|
||||
return mapping[priceId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Paddle price ID for a given plan and interval
|
||||
*/
|
||||
getPriceId(plan: "plus" | "premium", interval: "monthly" | "yearly"): string {
|
||||
const key = `PADDLE_${plan.toUpperCase()}_${interval.toUpperCase()}_PRICE_ID`;
|
||||
const priceId = this.config.get<string>(key, "");
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(
|
||||
`Price ID not configured for ${plan} ${interval} (env: ${key})`,
|
||||
);
|
||||
}
|
||||
|
||||
return priceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client-side token for Paddle.js
|
||||
*/
|
||||
getClientToken(): string {
|
||||
return this.config.get<string>("PADDLE_CLIENT_TOKEN", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Paddle environment
|
||||
*/
|
||||
getEnvironment(): "sandbox" | "production" {
|
||||
return this.environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import type { RawBodyRequest } from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiOkResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Request, Response } from "express";
|
||||
import { CurrentUser, Public } from "../../common/decorators";
|
||||
import type { ApiResponse } from "../../common/types/api-response.type";
|
||||
import {
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
} from "../../common/types/api-response.type";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { PaddleService, PaddleWebhookEvent } from "./paddle.service";
|
||||
import {
|
||||
CreateCheckoutDto,
|
||||
CancelSubscriptionDto,
|
||||
SubscriptionResponseDto,
|
||||
PlanInfo,
|
||||
PlanType,
|
||||
} from "./dto/subscription.dto";
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@ApiTags("Subscriptions")
|
||||
@Controller("subscriptions")
|
||||
export class SubscriptionsController {
|
||||
private readonly logger = new Logger(SubscriptionsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly paddleService: PaddleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /subscriptions/plans — Get all available plans (public)
|
||||
*/
|
||||
@Public()
|
||||
@Get("plans")
|
||||
@ApiOperation({ summary: "Get all available subscription plans" })
|
||||
@ApiOkResponse({ description: "List of available plans" })
|
||||
getPlans(): ApiResponse<readonly PlanInfo[]> {
|
||||
const plans = this.subscriptionsService.getPlans();
|
||||
return createSuccessResponse(plans, "Plans retrieved");
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /subscriptions/me — Get current user subscription
|
||||
*/
|
||||
@ApiBearerAuth()
|
||||
@Get("me")
|
||||
@ApiOperation({ summary: "Get current user subscription status" })
|
||||
async getMySubscription(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<ApiResponse<SubscriptionResponseDto | null>> {
|
||||
const subscription = await this.subscriptionsService.getCurrentSubscription(
|
||||
user.id,
|
||||
);
|
||||
return createSuccessResponse(subscription, "Subscription retrieved");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /subscriptions/checkout — Get checkout config for Paddle.js
|
||||
*/
|
||||
@ApiBearerAuth()
|
||||
@Post("checkout")
|
||||
@ApiOperation({
|
||||
summary: "Get Paddle checkout configuration for a plan",
|
||||
})
|
||||
async getCheckoutConfig(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: CreateCheckoutDto,
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
priceId: string;
|
||||
clientToken: string;
|
||||
environment: string;
|
||||
userId: string;
|
||||
}>
|
||||
> {
|
||||
if (dto.plan === PlanType.FREE) {
|
||||
throw new ForbiddenException("Cannot checkout for free plan");
|
||||
}
|
||||
|
||||
const config = this.subscriptionsService.getCheckoutConfig(
|
||||
dto.plan,
|
||||
dto.billingInterval,
|
||||
);
|
||||
|
||||
return createSuccessResponse(
|
||||
{
|
||||
...config,
|
||||
userId: user.id,
|
||||
},
|
||||
"Checkout config ready",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /subscriptions/cancel — Cancel current subscription
|
||||
*/
|
||||
@ApiBearerAuth()
|
||||
@Post("cancel")
|
||||
@ApiOperation({ summary: "Cancel the current subscription" })
|
||||
async cancelSubscription(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() _dto: CancelSubscriptionDto,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.subscriptionsService.cancelSubscription(user.id);
|
||||
return createSuccessResponse(null, "Subscription cancellation requested");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /subscriptions/webhook/paddle — Paddle webhook receiver
|
||||
*
|
||||
* This endpoint is PUBLIC (no JWT required) — Paddle calls it directly.
|
||||
* Authentication is done via HMAC signature verification.
|
||||
*/
|
||||
@Public()
|
||||
@Post("webhook/paddle")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Paddle webhook receiver (internal)" })
|
||||
async handlePaddleWebhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const signature = req.headers["paddle-signature"] as string | undefined;
|
||||
|
||||
if (!signature) {
|
||||
this.logger.warn("Paddle webhook received without signature");
|
||||
res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = req.rawBody?.toString("utf8");
|
||||
if (!rawBody) {
|
||||
this.logger.warn("Paddle webhook received without raw body");
|
||||
res.status(HttpStatus.BAD_REQUEST).json({ error: "Missing body" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const isValid = this.paddleService.verifyWebhookSignature(
|
||||
rawBody,
|
||||
signature,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
this.logger.warn("Paddle webhook signature verification failed");
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and process
|
||||
try {
|
||||
const event = JSON.parse(rawBody) as PaddleWebhookEvent;
|
||||
await this.subscriptionsService.handleWebhookEvent(event);
|
||||
res.status(HttpStatus.OK).json({ received: true });
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Webhook processing failed: ${err.message}`);
|
||||
res
|
||||
.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.json({ error: "Processing failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsController } from "./subscriptions.controller";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { PaddleService } from "./paddle.service";
|
||||
import { DatabaseModule } from "../../database/database.module";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [SubscriptionsController],
|
||||
providers: [SubscriptionsService, PaddleService],
|
||||
exports: [SubscriptionsService, PaddleService],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,25 @@ export class ChangePasswordDto {
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
import { Exclude, Expose } from "class-transformer";
|
||||
import { Exclude, Expose, Type } from "class-transformer";
|
||||
|
||||
@Exclude()
|
||||
export class UsageLimitDto {
|
||||
@Expose()
|
||||
analysisCount: number;
|
||||
|
||||
@Expose()
|
||||
couponCount: number;
|
||||
|
||||
@Expose()
|
||||
maxAnalyses: number;
|
||||
|
||||
@Expose()
|
||||
maxCoupons: number;
|
||||
|
||||
@Expose()
|
||||
lastResetDate: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class UserResponseDto {
|
||||
@@ -95,9 +113,16 @@ export class UserResponseDto {
|
||||
@Expose()
|
||||
isActive: boolean;
|
||||
|
||||
@Expose()
|
||||
subscriptionStatus: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => UsageLimitDto)
|
||||
usageLimit?: UsageLimitDto;
|
||||
}
|
||||
|
||||
@@ -340,7 +340,9 @@ export class DataFetcherTask {
|
||||
for (const row of rows) {
|
||||
const result = this.resolvePredictionRunSettlement(row);
|
||||
if (!result) continue;
|
||||
const closingOddsSnapshot = await this.getClosingOddsSnapshot(row.matchId);
|
||||
const closingOddsSnapshot = await this.getClosingOddsSnapshot(
|
||||
row.matchId,
|
||||
);
|
||||
const settlementSummary = {
|
||||
settled_at: new Date().toISOString(),
|
||||
model_version: row.engineVersion,
|
||||
@@ -453,7 +455,13 @@ export class DataFetcherTask {
|
||||
const playable = mainPick.playable === true;
|
||||
const odds = Number(mainPick.odds || 0);
|
||||
|
||||
if (!market || !pick || !playable || !Number.isFinite(odds) || odds <= 1.01) {
|
||||
if (
|
||||
!market ||
|
||||
!pick ||
|
||||
!playable ||
|
||||
!Number.isFinite(odds) ||
|
||||
odds <= 1.01
|
||||
) {
|
||||
return { outcome: "NO_BET", unitProfit: 0 };
|
||||
}
|
||||
|
||||
@@ -516,10 +524,9 @@ export class DataFetcherTask {
|
||||
|
||||
const goalLine = this.goalLineForMarket(market);
|
||||
if (goalLine !== null) {
|
||||
const total =
|
||||
market.startsWith("HT_")
|
||||
? this.nullableSum(input.htScoreHome, input.htScoreAway)
|
||||
: scoreHome + scoreAway;
|
||||
const total = market.startsWith("HT_")
|
||||
? this.nullableSum(input.htScoreHome, input.htScoreAway)
|
||||
: scoreHome + scoreAway;
|
||||
if (total === null) return null;
|
||||
if (this.isOverPick(pick)) return total > goalLine;
|
||||
return total < goalLine;
|
||||
@@ -537,7 +544,8 @@ export class DataFetcherTask {
|
||||
if (market === "HTFT") {
|
||||
const htHome = input.htScoreHome;
|
||||
const htAway = input.htScoreAway;
|
||||
if (htHome === null || htAway === null || !pick.includes("/")) return null;
|
||||
if (htHome === null || htAway === null || !pick.includes("/"))
|
||||
return null;
|
||||
const [htPick, ftPick] = pick.split("/");
|
||||
return (
|
||||
this.isResultPickWon(htPick, htHome, htAway) === true &&
|
||||
|
||||
@@ -141,7 +141,7 @@ export class LimitResetterTask {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset subscription status for expired users
|
||||
* Downgrade cancelled subscriptions that have passed their cancel effective date
|
||||
*/
|
||||
@Cron("0 0 * * *", { timeZone: "Europe/Istanbul" })
|
||||
async checkSubscriptions() {
|
||||
@@ -155,21 +155,55 @@ export class LimitResetterTask {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const result = await this.prisma.user.updateMany({
|
||||
// Find subscriptions with passed cancel effective date
|
||||
const expiredSubs = await this.prisma.subscription.findMany({
|
||||
where: {
|
||||
subscriptionStatus: "active",
|
||||
subscriptionExpiresAt: { lt: now },
|
||||
},
|
||||
data: {
|
||||
subscriptionStatus: "expired",
|
||||
plan: "cancelled",
|
||||
cancelEffectiveDate: { lt: now },
|
||||
},
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
this.logger.log(`${result.count} subscriptions marked as expired`);
|
||||
for (const sub of expiredSubs) {
|
||||
// Downgrade to free
|
||||
await this.prisma.user.update({
|
||||
where: { id: sub.userId },
|
||||
data: { subscriptionStatus: "free" },
|
||||
});
|
||||
|
||||
// Sync limits to free tier
|
||||
await this.prisma.usageLimit.upsert({
|
||||
where: { userId: sub.userId },
|
||||
update: { maxAnalyses: 3, maxCoupons: 1 },
|
||||
create: {
|
||||
userId: sub.userId,
|
||||
analysisCount: 0,
|
||||
couponCount: 0,
|
||||
maxAnalyses: 3,
|
||||
maxCoupons: 1,
|
||||
lastResetDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Reset subscription to free
|
||||
await this.prisma.subscription.update({
|
||||
where: { id: sub.id },
|
||||
data: {
|
||||
plan: "free",
|
||||
cancelledAt: null,
|
||||
cancelEffectiveDate: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Subscription check failed: ${error.message}`);
|
||||
|
||||
if (expiredSubs.length > 0) {
|
||||
this.logger.log(
|
||||
`${expiredSubs.length} cancelled subscriptions downgraded to free`,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Subscription check failed: ${err.message}`);
|
||||
}
|
||||
},
|
||||
this.logger,
|
||||
|
||||
Reference in New Issue
Block a user