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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user