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:
Ho Ngoc Hai
2026-04-08 02:04:20 +07:00
parent f3081d92fc
commit 9b581b7e5f
32 changed files with 1205 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1,6 @@
export class CancelSubscriptionCommand {
constructor(
public readonly userId: string,
public readonly reason?: string,
) {}
}

View File

@@ -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!,
};
}
}

View File

@@ -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',
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,7 @@
export class MeterUsageCommand {
constructor(
public readonly userId: string,
public readonly metric: string,
public readonly count: number,
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,8 @@
import { type PlanTier } from '@prisma/client';
export class UpgradeSubscriptionCommand {
constructor(
public readonly userId: string,
public readonly newPlanTier: PlanTier,
) {}
}

View File

@@ -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,
};
}
}

View 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';

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,6 @@
export class CheckQuotaQuery {
constructor(
public readonly userId: string,
public readonly metric: string,
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,7 @@
export class GetBillingHistoryQuery {
constructor(
public readonly userId: string,
public readonly limit?: number,
public readonly offset?: number,
) {}
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,7 @@
import { type PlanTier } from '@prisma/client';
export class GetPlanQuery {
constructor(
public readonly planTier?: PlanTier,
) {}
}

View File

@@ -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();
}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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>;
}

View 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';

View File

@@ -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);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class CancelSubscriptionDto {
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { PlanTier } from '@prisma/client';
export class UpgradeSubscriptionDto {
@IsEnum(PlanTier)
newPlanTier!: PlanTier;
}

View File

@@ -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;
}
}

View 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 {}