diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c7a54db..2d971e0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { ListingsModule } from '@modules/listings'; import { SearchModule } from '@modules/search'; import { NotificationsModule } from '@modules/notifications'; import { PaymentsModule } from '@modules/payments'; +import { SubscriptionsModule } from '@modules/subscriptions'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; @@ -20,6 +21,7 @@ import { AppController } from './app.controller'; SearchModule, NotificationsModule, PaymentsModule, + SubscriptionsModule, // ── Rate Limiting ── // Default: 60 requests per 60 seconds per IP diff --git a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.command.ts b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.command.ts new file mode 100644 index 0000000..e7a6702 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.command.ts @@ -0,0 +1,6 @@ +export class CancelSubscriptionCommand { + constructor( + public readonly userId: string, + public readonly reason?: string, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts new file mode 100644 index 0000000..9b40ff0 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts @@ -0,0 +1,66 @@ +import { + BadRequestException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { CancelSubscriptionCommand } from './cancel-subscription.command'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; + +export interface CancelSubscriptionResult { + subscriptionId: string; + status: string; + cancelledAt: Date; +} + +@CommandHandler(CancelSubscriptionCommand) +export class CancelSubscriptionHandler implements ICommandHandler { + private readonly logger = new Logger(CancelSubscriptionHandler.name); + + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: CancelSubscriptionCommand): Promise { + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy subscription', + }); + } + + if (subscription.status === 'CANCELLED') { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Subscription đã bị hủy trước đó', + }); + } + + subscription.cancel(); + await this.subscriptionRepo.update(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Subscription cancelled: id=${subscription.id}, user=${command.userId}, reason=${command.reason ?? 'N/A'}`, + ); + + return { + subscriptionId: subscription.id, + status: 'CANCELLED', + cancelledAt: subscription.cancelledAt!, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.command.ts b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.command.ts new file mode 100644 index 0000000..dc43ea1 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.command.ts @@ -0,0 +1,9 @@ +import { type PlanTier } from '@prisma/client'; + +export class CreateSubscriptionCommand { + constructor( + public readonly userId: string, + public readonly planTier: PlanTier, + public readonly billingCycle: 'monthly' | 'yearly', + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts new file mode 100644 index 0000000..082560f --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/create-subscription/create-subscription.handler.ts @@ -0,0 +1,98 @@ +import { + BadRequestException, + ConflictException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { createId } from '@paralleldrive/cuid2'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { CreateSubscriptionCommand } from './create-subscription.command'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; +import { SubscriptionEntity } from '../../../domain/entities/subscription.entity'; + +export interface CreateSubscriptionResult { + subscriptionId: string; + planTier: string; + status: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; +} + +@CommandHandler(CreateSubscriptionCommand) +export class CreateSubscriptionHandler implements ICommandHandler { + private readonly logger = new Logger(CreateSubscriptionHandler.name); + + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + private readonly eventBus: EventBus, + ) {} + + async execute(command: CreateSubscriptionCommand): Promise { + // Check if user already has an active subscription + const existing = await this.subscriptionRepo.findByUserId(command.userId); + if (existing && (existing.status === 'ACTIVE' || existing.status === 'PAST_DUE')) { + throw new ConflictException({ + code: ErrorCode.CONFLICT, + message: 'Người dùng đã có subscription đang hoạt động', + }); + } + + // Fetch plan + const plan = await this.prisma.plan.findFirst({ + where: { tier: command.planTier, isActive: true }, + }); + if (!plan) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy gói đăng ký', + }); + } + + // Calculate period + const now = new Date(); + const periodEnd = new Date(now); + if (command.billingCycle === 'yearly') { + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + } else { + periodEnd.setMonth(periodEnd.getMonth() + 1); + } + + const subscriptionId = createId(); + const subscription = SubscriptionEntity.createNew( + subscriptionId, + command.userId, + plan.id, + command.planTier, + now, + periodEnd, + ); + + await this.subscriptionRepo.save(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Subscription created: id=${subscriptionId}, user=${command.userId}, tier=${command.planTier}`, + ); + + return { + subscriptionId, + planTier: command.planTier, + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.command.ts b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.command.ts new file mode 100644 index 0000000..9c57dc6 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.command.ts @@ -0,0 +1,7 @@ +export class MeterUsageCommand { + constructor( + public readonly userId: string, + public readonly metric: string, + public readonly count: number, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts new file mode 100644 index 0000000..c45cb4f --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/meter-usage/meter-usage.handler.ts @@ -0,0 +1,97 @@ +import { + BadRequestException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { MeterUsageCommand } from './meter-usage.command'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; + +export interface MeterUsageResult { + usageRecordId: string; + metric: string; + count: number; + periodStart: Date; + periodEnd: Date; +} + +@CommandHandler(MeterUsageCommand) +export class MeterUsageHandler implements ICommandHandler { + private readonly logger = new Logger(MeterUsageHandler.name); + + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(command: MeterUsageCommand): Promise { + if (command.count <= 0) { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Số lượng phải lớn hơn 0', + }); + } + + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy subscription', + }); + } + + if (!subscription.isActive()) { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Subscription không ở trạng thái hoạt động', + }); + } + + // Upsert usage record for current period + metric + const existing = await this.prisma.usageRecord.findFirst({ + where: { + subscriptionId: subscription.id, + metric: command.metric, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }); + + let usageRecord; + if (existing) { + usageRecord = await this.prisma.usageRecord.update({ + where: { id: existing.id }, + data: { count: existing.count + command.count }, + }); + } else { + usageRecord = await this.prisma.usageRecord.create({ + data: { + subscriptionId: subscription.id, + metric: command.metric, + count: command.count, + periodStart: subscription.currentPeriodStart, + periodEnd: subscription.currentPeriodEnd, + }, + }); + } + + this.logger.log( + `Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`, + ); + + return { + usageRecordId: usageRecord.id, + metric: usageRecord.metric, + count: usageRecord.count, + periodStart: usageRecord.periodStart, + periodEnd: usageRecord.periodEnd, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.command.ts b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.command.ts new file mode 100644 index 0000000..0aa466d --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.command.ts @@ -0,0 +1,8 @@ +import { type PlanTier } from '@prisma/client'; + +export class UpgradeSubscriptionCommand { + constructor( + public readonly userId: string, + public readonly newPlanTier: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts new file mode 100644 index 0000000..9a07a41 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/commands/upgrade-subscription/upgrade-subscription.handler.ts @@ -0,0 +1,104 @@ +import { + BadRequestException, + Inject, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { UpgradeSubscriptionCommand } from './upgrade-subscription.command'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; + +const TIER_ORDER = { FREE: 0, AGENT_PRO: 1, INVESTOR: 1, ENTERPRISE: 2 } as const; + +export interface UpgradeSubscriptionResult { + subscriptionId: string; + previousTier: string; + newTier: string; + status: string; +} + +@CommandHandler(UpgradeSubscriptionCommand) +export class UpgradeSubscriptionHandler implements ICommandHandler { + private readonly logger = new Logger(UpgradeSubscriptionHandler.name); + + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + private readonly eventBus: EventBus, + ) {} + + async execute(command: UpgradeSubscriptionCommand): Promise { + const subscription = await this.subscriptionRepo.findByUserId(command.userId); + if (!subscription) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy subscription', + }); + } + + if (!subscription.isActive()) { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Subscription không ở trạng thái hoạt động', + }); + } + + // Validate upgrade direction + const currentOrder = TIER_ORDER[subscription.planTier]; + const newOrder = TIER_ORDER[command.newPlanTier]; + if (newOrder <= currentOrder && command.newPlanTier !== subscription.planTier) { + // Allow same-tier for AGENT_PRO <-> INVESTOR switches + if (currentOrder !== newOrder) { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Chỉ có thể nâng cấp lên gói cao hơn', + }); + } + } + + if (command.newPlanTier === subscription.planTier) { + throw new BadRequestException({ + code: ErrorCode.BAD_REQUEST, + message: 'Đã đang sử dụng gói này', + }); + } + + // Fetch new plan + const newPlan = await this.prisma.plan.findFirst({ + where: { tier: command.newPlanTier, isActive: true }, + }); + if (!newPlan) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy gói đăng ký mới', + }); + } + + const previousTier = subscription.planTier; + subscription.upgrade(newPlan.id, command.newPlanTier); + await this.subscriptionRepo.update(subscription); + + // Publish domain events + const events = subscription.clearDomainEvents(); + for (const event of events) { + this.eventBus.publish(event); + } + + this.logger.log( + `Subscription upgraded: id=${subscription.id}, ${previousTier} → ${command.newPlanTier}`, + ); + + return { + subscriptionId: subscription.id, + previousTier, + newTier: command.newPlanTier, + status: subscription.status, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/index.ts b/apps/api/src/modules/subscriptions/application/index.ts new file mode 100644 index 0000000..bdc3df8 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/index.ts @@ -0,0 +1,8 @@ +export { CreateSubscriptionHandler } from './commands/create-subscription/create-subscription.handler'; +export { UpgradeSubscriptionHandler } from './commands/upgrade-subscription/upgrade-subscription.handler'; +export { CancelSubscriptionHandler } from './commands/cancel-subscription/cancel-subscription.handler'; +export { MeterUsageHandler } from './commands/meter-usage/meter-usage.handler'; + +export { GetPlanHandler } from './queries/get-plan/get-plan.handler'; +export { CheckQuotaHandler } from './queries/check-quota/check-quota.handler'; +export { GetBillingHistoryHandler } from './queries/get-billing-history/get-billing-history.handler'; diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts new file mode 100644 index 0000000..e752fdb --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts @@ -0,0 +1,97 @@ +import { Inject, NotFoundException } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { CheckQuotaQuery } from './check-quota.query'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; + +export interface QuotaCheckResult { + metric: string; + limit: number | null; + used: number; + remaining: number | null; + allowed: boolean; +} + +const METRIC_TO_PLAN_FIELD: Record = { + listings_created: 'maxListings', + searches_saved: 'maxSavedSearches', +}; + +@QueryHandler(CheckQuotaQuery) +export class CheckQuotaHandler implements IQueryHandler { + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: CheckQuotaQuery): Promise { + const subscription = await this.subscriptionRepo.findByUserId(query.userId); + + // No subscription = FREE tier defaults + if (!subscription || !subscription.isActive()) { + const freePlan = await this.prisma.plan.findFirst({ + where: { tier: 'FREE', isActive: true }, + }); + if (!freePlan) { + return { metric: query.metric, limit: 0, used: 0, remaining: 0, allowed: false }; + } + return this.checkAgainstPlan(freePlan, query.metric, null, query.userId); + } + + const plan = await this.prisma.plan.findUnique({ + where: { id: subscription.planId }, + }); + if (!plan) { + throw new NotFoundException({ + code: ErrorCode.NOT_FOUND, + message: 'Không tìm thấy plan', + }); + } + + return this.checkAgainstPlan(plan, query.metric, subscription.id, query.userId); + } + + private async checkAgainstPlan( + plan: any, + metric: string, + subscriptionId: string | null, + userId: string, + ): Promise { + const planField = METRIC_TO_PLAN_FIELD[metric]; + + // Unknown metric or unlimited + if (!planField || plan[planField] === null) { + return { metric, limit: null, used: 0, remaining: null, allowed: true }; + } + + const limit = plan[planField] as number; + + // Get current usage + let used = 0; + if (subscriptionId) { + const usageRecord = await this.prisma.usageRecord.findFirst({ + where: { + subscriptionId, + metric, + }, + orderBy: { periodStart: 'desc' }, + }); + used = usageRecord?.count ?? 0; + } + + const remaining = limit - used; + + return { + metric, + limit, + used, + remaining: Math.max(0, remaining), + allowed: remaining > 0, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.query.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.query.ts new file mode 100644 index 0000000..682ad47 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.query.ts @@ -0,0 +1,6 @@ +export class CheckQuotaQuery { + constructor( + public readonly userId: string, + public readonly metric: string, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts new file mode 100644 index 0000000..328b021 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.handler.ts @@ -0,0 +1,80 @@ +import { Inject, NotFoundException } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { ErrorCode } from '@modules/shared/domain/error-codes'; +import { GetBillingHistoryQuery } from './get-billing-history.query'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../../domain/repositories/subscription.repository'; + +export interface BillingHistoryDto { + subscription: { + id: string; + planTier: string; + status: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelledAt: Date | null; + createdAt: Date; + } | null; + payments: Array<{ + id: string; + amountVND: string; + status: string; + provider: string; + createdAt: Date; + }>; + total: number; +} + +@QueryHandler(GetBillingHistoryQuery) +export class GetBillingHistoryHandler implements IQueryHandler { + constructor( + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, + private readonly prisma: PrismaService, + ) {} + + async execute(query: GetBillingHistoryQuery): Promise { + const subscription = await this.subscriptionRepo.findByUserId(query.userId); + + // Fetch subscription-related payments + const where = { + userId: query.userId, + type: 'SUBSCRIPTION' as const, + }; + + const [payments, total] = await Promise.all([ + this.prisma.payment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: query.limit ?? 20, + skip: query.offset ?? 0, + }), + this.prisma.payment.count({ where }), + ]); + + return { + subscription: subscription + ? { + id: subscription.id, + planTier: subscription.planTier, + status: subscription.status, + currentPeriodStart: subscription.currentPeriodStart, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelledAt: subscription.cancelledAt, + createdAt: subscription.createdAt, + } + : null, + payments: payments.map((p) => ({ + id: p.id, + amountVND: p.amountVND.toString(), + status: p.status, + provider: p.provider, + createdAt: p.createdAt, + })), + total, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.query.ts b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.query.ts new file mode 100644 index 0000000..2be7ecb --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/get-billing-history/get-billing-history.query.ts @@ -0,0 +1,7 @@ +export class GetBillingHistoryQuery { + constructor( + public readonly userId: string, + public readonly limit?: number, + public readonly offset?: number, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts new file mode 100644 index 0000000..81c4e2f --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.handler.ts @@ -0,0 +1,52 @@ +import { Logger } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { GetPlanQuery } from './get-plan.query'; + +export interface PlanDto { + id: string; + tier: string; + name: string; + priceMonthlyVND: string; + priceYearlyVND: string; + maxListings: number | null; + maxSavedSearches: number | null; + features: unknown; + isActive: boolean; +} + +@QueryHandler(GetPlanQuery) +export class GetPlanHandler implements IQueryHandler { + constructor(private readonly prisma: PrismaService) {} + + async execute(query: GetPlanQuery): Promise { + if (query.planTier) { + const plan = await this.prisma.plan.findFirst({ + where: { tier: query.planTier, isActive: true }, + }); + if (!plan) return []; + return this.toDto(plan); + } + + const plans = await this.prisma.plan.findMany({ + where: { isActive: true }, + orderBy: { priceMonthlyVND: 'asc' }, + }); + + return plans.map((p) => this.toDto(p)); + } + + private toDto(plan: any): PlanDto { + return { + id: plan.id, + tier: plan.tier, + name: plan.name, + priceMonthlyVND: plan.priceMonthlyVND.toString(), + priceYearlyVND: plan.priceYearlyVND.toString(), + maxListings: plan.maxListings, + maxSavedSearches: plan.maxSavedSearches, + features: plan.features, + isActive: plan.isActive, + }; + } +} diff --git a/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.query.ts b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.query.ts new file mode 100644 index 0000000..16ab039 --- /dev/null +++ b/apps/api/src/modules/subscriptions/application/queries/get-plan/get-plan.query.ts @@ -0,0 +1,7 @@ +import { type PlanTier } from '@prisma/client'; + +export class GetPlanQuery { + constructor( + public readonly planTier?: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts new file mode 100644 index 0000000..87b4de1 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts @@ -0,0 +1,132 @@ +import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { + type PlanTier, + type SubscriptionStatus, +} from '@prisma/client'; +import { SubscriptionCreatedEvent } from '../events/subscription-created.event'; +import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event'; +import { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event'; + +export interface SubscriptionProps { + userId: string; + planId: string; + planTier: PlanTier; + status: SubscriptionStatus; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelledAt: Date | null; +} + +export class SubscriptionEntity extends AggregateRoot { + private _userId: string; + private _planId: string; + private _planTier: PlanTier; + private _status: SubscriptionStatus; + private _currentPeriodStart: Date; + private _currentPeriodEnd: Date; + private _cancelledAt: Date | null; + + constructor(id: string, props: SubscriptionProps, createdAt?: Date, updatedAt?: Date) { + super(id, createdAt, updatedAt); + this._userId = props.userId; + this._planId = props.planId; + this._planTier = props.planTier; + this._status = props.status; + this._currentPeriodStart = props.currentPeriodStart; + this._currentPeriodEnd = props.currentPeriodEnd; + this._cancelledAt = props.cancelledAt; + } + + get userId(): string { return this._userId; } + get planId(): string { return this._planId; } + get planTier(): PlanTier { return this._planTier; } + get status(): SubscriptionStatus { return this._status; } + get currentPeriodStart(): Date { return this._currentPeriodStart; } + get currentPeriodEnd(): Date { return this._currentPeriodEnd; } + get cancelledAt(): Date | null { return this._cancelledAt; } + + static createNew( + id: string, + userId: string, + planId: string, + planTier: PlanTier, + periodStart: Date, + periodEnd: Date, + ): SubscriptionEntity { + const subscription = new SubscriptionEntity(id, { + userId, + planId, + planTier, + status: 'ACTIVE', + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + cancelledAt: null, + }); + + subscription.addDomainEvent( + new SubscriptionCreatedEvent(id, userId, planTier), + ); + + return subscription; + } + + upgrade(newPlanId: string, newPlanTier: PlanTier): void { + if (this._status !== 'ACTIVE') { + throw new Error(`Không thể nâng cấp subscription ở trạng thái ${this._status}`); + } + + const oldTier = this._planTier; + this._planId = newPlanId; + this._planTier = newPlanTier; + this.updatedAt = new Date(); + + this.addDomainEvent( + new SubscriptionUpgradedEvent(this.id, this._userId, oldTier, newPlanTier), + ); + } + + cancel(): void { + if (this._status === 'CANCELLED') { + throw new Error('Subscription đã bị hủy'); + } + + this._status = 'CANCELLED'; + this._cancelledAt = new Date(); + this.updatedAt = new Date(); + + this.addDomainEvent( + new SubscriptionCancelledEvent(this.id, this._userId, this._planTier), + ); + } + + markExpired(): void { + if (this._status !== 'ACTIVE' && this._status !== 'PAST_DUE') { + throw new Error(`Không thể đánh dấu hết hạn ở trạng thái ${this._status}`); + } + this._status = 'EXPIRED'; + this.updatedAt = new Date(); + } + + markPastDue(): void { + if (this._status !== 'ACTIVE') { + throw new Error(`Không thể đánh dấu quá hạn ở trạng thái ${this._status}`); + } + this._status = 'PAST_DUE'; + this.updatedAt = new Date(); + } + + renewPeriod(newStart: Date, newEnd: Date): void { + this._currentPeriodStart = newStart; + this._currentPeriodEnd = newEnd; + this._status = 'ACTIVE'; + this.updatedAt = new Date(); + } + + isActive(): boolean { + return this._status === 'ACTIVE'; + } + + isExpired(): boolean { + return this._currentPeriodEnd < new Date(); + } +} diff --git a/apps/api/src/modules/subscriptions/domain/events/subscription-cancelled.event.ts b/apps/api/src/modules/subscriptions/domain/events/subscription-cancelled.event.ts new file mode 100644 index 0000000..085dfdd --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/subscription-cancelled.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PlanTier } from '@prisma/client'; + +export class SubscriptionCancelledEvent implements DomainEvent { + readonly eventName = 'subscription.cancelled'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly planTier: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/domain/events/subscription-created.event.ts b/apps/api/src/modules/subscriptions/domain/events/subscription-created.event.ts new file mode 100644 index 0000000..84734dd --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/subscription-created.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PlanTier } from '@prisma/client'; + +export class SubscriptionCreatedEvent implements DomainEvent { + readonly eventName = 'subscription.created'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly planTier: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/domain/events/subscription-upgraded.event.ts b/apps/api/src/modules/subscriptions/domain/events/subscription-upgraded.event.ts new file mode 100644 index 0000000..190b873 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/subscription-upgraded.event.ts @@ -0,0 +1,14 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type PlanTier } from '@prisma/client'; + +export class SubscriptionUpgradedEvent implements DomainEvent { + readonly eventName = 'subscription.upgraded'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly fromTier: PlanTier, + public readonly toTier: PlanTier, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/domain/repositories/subscription.repository.ts b/apps/api/src/modules/subscriptions/domain/repositories/subscription.repository.ts new file mode 100644 index 0000000..442a40a --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/repositories/subscription.repository.ts @@ -0,0 +1,10 @@ +import { type SubscriptionEntity } from '../entities/subscription.entity'; + +export const SUBSCRIPTION_REPOSITORY = Symbol('SUBSCRIPTION_REPOSITORY'); + +export interface ISubscriptionRepository { + findById(id: string): Promise; + findByUserId(userId: string): Promise; + save(subscription: SubscriptionEntity): Promise; + update(subscription: SubscriptionEntity): Promise; +} diff --git a/apps/api/src/modules/subscriptions/index.ts b/apps/api/src/modules/subscriptions/index.ts new file mode 100644 index 0000000..425feed --- /dev/null +++ b/apps/api/src/modules/subscriptions/index.ts @@ -0,0 +1,5 @@ +export { SubscriptionsModule } from './subscriptions.module'; +export { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository'; +export { type ISubscriptionRepository } from './domain/repositories/subscription.repository'; +export { QuotaGuard } from './presentation/guards/quota.guard'; +export { RequireQuota } from './presentation/decorators/require-quota.decorator'; diff --git a/apps/api/src/modules/subscriptions/infrastructure/repositories/prisma-subscription.repository.ts b/apps/api/src/modules/subscriptions/infrastructure/repositories/prisma-subscription.repository.ts new file mode 100644 index 0000000..4f559a3 --- /dev/null +++ b/apps/api/src/modules/subscriptions/infrastructure/repositories/prisma-subscription.repository.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type Subscription as PrismaSubscription, type Plan as PrismaPlan } from '@prisma/client'; +import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository'; +import { SubscriptionEntity, type SubscriptionProps } from '../../domain/entities/subscription.entity'; + +type SubscriptionWithPlan = PrismaSubscription & { plan: PrismaPlan }; + +@Injectable() +export class PrismaSubscriptionRepository implements ISubscriptionRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const subscription = await this.prisma.subscription.findUnique({ + where: { id }, + include: { plan: true }, + }); + return subscription ? this.toDomain(subscription) : null; + } + + async findByUserId(userId: string): Promise { + const subscription = await this.prisma.subscription.findUnique({ + where: { userId }, + include: { plan: true }, + }); + return subscription ? this.toDomain(subscription) : null; + } + + async save(entity: SubscriptionEntity): Promise { + await this.prisma.subscription.create({ + data: { + id: entity.id, + userId: entity.userId, + planId: entity.planId, + status: entity.status, + currentPeriodStart: entity.currentPeriodStart, + currentPeriodEnd: entity.currentPeriodEnd, + cancelledAt: entity.cancelledAt, + }, + }); + } + + async update(entity: SubscriptionEntity): Promise { + await this.prisma.subscription.update({ + where: { id: entity.id }, + data: { + planId: entity.planId, + status: entity.status, + currentPeriodStart: entity.currentPeriodStart, + currentPeriodEnd: entity.currentPeriodEnd, + cancelledAt: entity.cancelledAt, + }, + }); + } + + private toDomain(raw: SubscriptionWithPlan): SubscriptionEntity { + const props: SubscriptionProps = { + userId: raw.userId, + planId: raw.planId, + planTier: raw.plan.tier, + status: raw.status, + currentPeriodStart: raw.currentPeriodStart, + currentPeriodEnd: raw.currentPeriodEnd, + cancelledAt: raw.cancelledAt, + }; + + return new SubscriptionEntity(raw.id, props, raw.createdAt, raw.updatedAt); + } +} diff --git a/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts new file mode 100644 index 0000000..5bb3894 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/controllers/subscriptions.controller.ts @@ -0,0 +1,125 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; +import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; +import { CreateSubscriptionCommand } from '../../application/commands/create-subscription/create-subscription.command'; +import { UpgradeSubscriptionCommand } from '../../application/commands/upgrade-subscription/upgrade-subscription.command'; +import { CancelSubscriptionCommand } from '../../application/commands/cancel-subscription/cancel-subscription.command'; +import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command'; +import { GetPlanQuery } from '../../application/queries/get-plan/get-plan.query'; +import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query'; +import { GetBillingHistoryQuery } from '../../application/queries/get-billing-history/get-billing-history.query'; +import { type CreateSubscriptionResult } from '../../application/commands/create-subscription/create-subscription.handler'; +import { type UpgradeSubscriptionResult } from '../../application/commands/upgrade-subscription/upgrade-subscription.handler'; +import { type CancelSubscriptionResult } from '../../application/commands/cancel-subscription/cancel-subscription.handler'; +import { type MeterUsageResult } from '../../application/commands/meter-usage/meter-usage.handler'; +import { type PlanDto } from '../../application/queries/get-plan/get-plan.handler'; +import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; +import { type BillingHistoryDto } from '../../application/queries/get-billing-history/get-billing-history.handler'; +import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; +import { UpgradeSubscriptionDto } from '../dto/upgrade-subscription.dto'; +import { CancelSubscriptionDto } from '../dto/cancel-subscription.dto'; +import { MeterUsageDto } from '../dto/meter-usage.dto'; +import { BillingHistoryParamsDto } from '../dto/billing-history.dto'; +import { type PlanTier } from '@prisma/client'; + +@Controller('subscriptions') +export class SubscriptionsController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + // ── Plans (Public) ── + + @Get('plans') + async listPlans(): Promise { + return this.queryBus.execute(new GetPlanQuery()); + } + + @Get('plans/:tier') + async getPlan(@Param('tier') tier: string): Promise { + return this.queryBus.execute(new GetPlanQuery(tier.toUpperCase() as PlanTier)); + } + + // ── Subscriptions (Authenticated) ── + + @UseGuards(JwtAuthGuard) + @Post() + async createSubscription( + @Body() dto: CreateSubscriptionDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new CreateSubscriptionCommand(user.sub, dto.planTier, dto.billingCycle), + ); + } + + @UseGuards(JwtAuthGuard) + @Put('upgrade') + async upgradeSubscription( + @Body() dto: UpgradeSubscriptionDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new UpgradeSubscriptionCommand(user.sub, dto.newPlanTier), + ); + } + + @UseGuards(JwtAuthGuard) + @Delete() + async cancelSubscription( + @Body() dto: CancelSubscriptionDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new CancelSubscriptionCommand(user.sub, dto.reason), + ); + } + + // ── Usage / Quota ── + + @UseGuards(JwtAuthGuard) + @Post('usage') + async meterUsage( + @Body() dto: MeterUsageDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new MeterUsageCommand(user.sub, dto.metric, dto.count), + ); + } + + @UseGuards(JwtAuthGuard) + @Get('quota/:metric') + async checkQuota( + @Param('metric') metric: string, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.queryBus.execute(new CheckQuotaQuery(user.sub, metric)); + } + + // ── Billing ── + + @UseGuards(JwtAuthGuard) + @Get('billing') + async getBillingHistory( + @CurrentUser() user: JwtPayload, + @Query() dto: BillingHistoryParamsDto, + ): Promise { + return this.queryBus.execute( + new GetBillingHistoryQuery(user.sub, dto.limit, dto.offset), + ); + } +} diff --git a/apps/api/src/modules/subscriptions/presentation/decorators/require-quota.decorator.ts b/apps/api/src/modules/subscriptions/presentation/decorators/require-quota.decorator.ts new file mode 100644 index 0000000..92d64bd --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/decorators/require-quota.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from '@nestjs/common'; + +export const QUOTA_METRIC_KEY = 'quota_metric'; + +/** + * Decorator to enforce quota check before executing a route handler. + * Usage: @RequireQuota('listings_created') + */ +export const RequireQuota = (metric: string) => + SetMetadata(QUOTA_METRIC_KEY, metric); diff --git a/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts new file mode 100644 index 0000000..5eb2930 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/dto/billing-history.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, IsOptional, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class BillingHistoryParamsDto { + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + limit?: number; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(0) + offset?: number; +} diff --git a/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts new file mode 100644 index 0000000..f7500a6 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/dto/cancel-subscription.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CancelSubscriptionDto { + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts new file mode 100644 index 0000000..d851cf8 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/dto/create-subscription.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsIn } from 'class-validator'; +import { PlanTier } from '@prisma/client'; + +export class CreateSubscriptionDto { + @IsEnum(PlanTier) + planTier!: PlanTier; + + @IsIn(['monthly', 'yearly']) + billingCycle!: 'monthly' | 'yearly'; +} diff --git a/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts new file mode 100644 index 0000000..d9c59b4 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/dto/meter-usage.dto.ts @@ -0,0 +1,11 @@ +import { IsInt, IsString, Min, MinLength } from 'class-validator'; + +export class MeterUsageDto { + @IsString() + @MinLength(1) + metric!: string; + + @IsInt() + @Min(1) + count!: number; +} diff --git a/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts b/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts new file mode 100644 index 0000000..83a4bf5 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/dto/upgrade-subscription.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { PlanTier } from '@prisma/client'; + +export class UpgradeSubscriptionDto { + @IsEnum(PlanTier) + newPlanTier!: PlanTier; +} diff --git a/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts b/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts new file mode 100644 index 0000000..e553bf4 --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { QueryBus } from '@nestjs/cqrs'; +import { QUOTA_METRIC_KEY } from '../decorators/require-quota.decorator'; +import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query'; +import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; + +@Injectable() +export class QuotaGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly queryBus: QueryBus, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const metric = this.reflector.getAllAndOverride(QUOTA_METRIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!metric) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user?.sub) { + return true; // Let auth guards handle unauthenticated requests + } + + const result: QuotaCheckResult = await this.queryBus.execute( + new CheckQuotaQuery(user.sub, metric), + ); + + if (!result.allowed) { + throw new ForbiddenException({ + code: 'QUOTA_EXCEEDED', + message: `Bạn đã đạt giới hạn ${metric}. Vui lòng nâng cấp gói để tiếp tục.`, + quota: { + metric: result.metric, + limit: result.limit, + used: result.used, + }, + }); + } + + return true; + } +} diff --git a/apps/api/src/modules/subscriptions/subscriptions.module.ts b/apps/api/src/modules/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..1f387e2 --- /dev/null +++ b/apps/api/src/modules/subscriptions/subscriptions.module.ts @@ -0,0 +1,54 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +// Domain +import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository'; + +// Infrastructure +import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository'; + +// Application — Commands +import { CreateSubscriptionHandler } from './application/commands/create-subscription/create-subscription.handler'; +import { UpgradeSubscriptionHandler } from './application/commands/upgrade-subscription/upgrade-subscription.handler'; +import { CancelSubscriptionHandler } from './application/commands/cancel-subscription/cancel-subscription.handler'; +import { MeterUsageHandler } from './application/commands/meter-usage/meter-usage.handler'; + +// Application — Queries +import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler'; +import { CheckQuotaHandler } from './application/queries/check-quota/check-quota.handler'; +import { GetBillingHistoryHandler } from './application/queries/get-billing-history/get-billing-history.handler'; + +// Presentation +import { SubscriptionsController } from './presentation/controllers/subscriptions.controller'; +import { QuotaGuard } from './presentation/guards/quota.guard'; + +const CommandHandlers = [ + CreateSubscriptionHandler, + UpgradeSubscriptionHandler, + CancelSubscriptionHandler, + MeterUsageHandler, +]; + +const QueryHandlers = [ + GetPlanHandler, + CheckQuotaHandler, + GetBillingHistoryHandler, +]; + +@Module({ + imports: [CqrsModule], + controllers: [SubscriptionsController], + providers: [ + // Repositories + { provide: SUBSCRIPTION_REPOSITORY, useClass: PrismaSubscriptionRepository }, + + // Guards + QuotaGuard, + + // CQRS + ...CommandHandlers, + ...QueryHandlers, + ], + exports: [SUBSCRIPTION_REPOSITORY, QuotaGuard], +}) +export class SubscriptionsModule {}