feat(subscriptions): add Subscriptions module with plans, quotas, and billing
- Add Subscription, Plan, UsageRecord domain entities - Implement Create, Upgrade, Cancel subscription commands - Add MeterUsage command for quota tracking - Support 4 plan tiers: Free, Agent Pro, Investor, Enterprise - Register SubscriptionsModule in AppModule Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -4,6 +4,7 @@ import { ListingsModule } from '@modules/listings';
|
|||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
|
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
@@ -20,6 +21,7 @@ import { AppController } from './app.controller';
|
|||||||
SearchModule,
|
SearchModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
SubscriptionsModule,
|
||||||
|
|
||||||
// ── Rate Limiting ──
|
// ── Rate Limiting ──
|
||||||
// Default: 60 requests per 60 seconds per IP
|
// Default: 60 requests per 60 seconds per IP
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class CancelSubscriptionCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly reason?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<CancelSubscriptionCommand> {
|
||||||
|
private readonly logger = new Logger(CancelSubscriptionHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||||
|
private readonly subscriptionRepo: ISubscriptionRepository,
|
||||||
|
private readonly eventBus: EventBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CancelSubscriptionCommand): Promise<CancelSubscriptionResult> {
|
||||||
|
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!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<CreateSubscriptionCommand> {
|
||||||
|
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<CreateSubscriptionResult> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class MeterUsageCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly metric: string,
|
||||||
|
public readonly count: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<MeterUsageCommand> {
|
||||||
|
private readonly logger = new Logger(MeterUsageHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||||
|
private readonly subscriptionRepo: ISubscriptionRepository,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: MeterUsageCommand): Promise<MeterUsageResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { type PlanTier } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpgradeSubscriptionCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly newPlanTier: PlanTier,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<UpgradeSubscriptionCommand> {
|
||||||
|
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<UpgradeSubscriptionResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/modules/subscriptions/application/index.ts
Normal file
8
apps/api/src/modules/subscriptions/application/index.ts
Normal file
@@ -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';
|
||||||
@@ -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<string, string> = {
|
||||||
|
listings_created: 'maxListings',
|
||||||
|
searches_saved: 'maxSavedSearches',
|
||||||
|
};
|
||||||
|
|
||||||
|
@QueryHandler(CheckQuotaQuery)
|
||||||
|
export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||||
|
private readonly subscriptionRepo: ISubscriptionRepository,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: CheckQuotaQuery): Promise<QuotaCheckResult> {
|
||||||
|
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<QuotaCheckResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class CheckQuotaQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly metric: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<GetBillingHistoryQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||||
|
private readonly subscriptionRepo: ISubscriptionRepository,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetBillingHistoryQuery): Promise<BillingHistoryDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class GetBillingHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly limit?: number,
|
||||||
|
public readonly offset?: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<GetPlanQuery> {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async execute(query: GetPlanQuery): Promise<PlanDto | PlanDto[]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { type PlanTier } from '@prisma/client';
|
||||||
|
|
||||||
|
export class GetPlanQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly planTier?: PlanTier,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<SubscriptionEntity | null>;
|
||||||
|
findByUserId(userId: string): Promise<SubscriptionEntity | null>;
|
||||||
|
save(subscription: SubscriptionEntity): Promise<void>;
|
||||||
|
update(subscription: SubscriptionEntity): Promise<void>;
|
||||||
|
}
|
||||||
5
apps/api/src/modules/subscriptions/index.ts
Normal file
5
apps/api/src/modules/subscriptions/index.ts
Normal file
@@ -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';
|
||||||
@@ -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<SubscriptionEntity | null> {
|
||||||
|
const subscription = await this.prisma.subscription.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
return subscription ? this.toDomain(subscription) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string): Promise<SubscriptionEntity | null> {
|
||||||
|
const subscription = await this.prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: { plan: true },
|
||||||
|
});
|
||||||
|
return subscription ? this.toDomain(subscription) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(entity: SubscriptionEntity): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlanDto[]> {
|
||||||
|
return this.queryBus.execute(new GetPlanQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('plans/:tier')
|
||||||
|
async getPlan(@Param('tier') tier: string): Promise<PlanDto> {
|
||||||
|
return this.queryBus.execute(new GetPlanQuery(tier.toUpperCase() as PlanTier));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscriptions (Authenticated) ──
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post()
|
||||||
|
async createSubscription(
|
||||||
|
@Body() dto: CreateSubscriptionDto,
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<CreateSubscriptionResult> {
|
||||||
|
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<UpgradeSubscriptionResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new UpgradeSubscriptionCommand(user.sub, dto.newPlanTier),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Delete()
|
||||||
|
async cancelSubscription(
|
||||||
|
@Body() dto: CancelSubscriptionDto,
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<CancelSubscriptionResult> {
|
||||||
|
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<MeterUsageResult> {
|
||||||
|
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<QuotaCheckResult> {
|
||||||
|
return this.queryBus.execute(new CheckQuotaQuery(user.sub, metric));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Billing ──
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('billing')
|
||||||
|
async getBillingHistory(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query() dto: BillingHistoryParamsDto,
|
||||||
|
): Promise<BillingHistoryDto> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetBillingHistoryQuery(user.sub, dto.limit, dto.offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CancelSubscriptionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { PlanTier } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpgradeSubscriptionDto {
|
||||||
|
@IsEnum(PlanTier)
|
||||||
|
newPlanTier!: PlanTier;
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
const metric = this.reflector.getAllAndOverride<string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/api/src/modules/subscriptions/subscriptions.module.ts
Normal file
54
apps/api/src/modules/subscriptions/subscriptions.module.ts
Normal file
@@ -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 {}
|
||||||
Reference in New Issue
Block a user