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

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