initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
import { IsString, IsUUID, IsIn } from 'class-validator';
export class CreateSubscriptionDto {
@IsUUID()
clientId: string;
@IsUUID()
planId: string;
@IsString()
@IsIn(['prepaid', 'postpaid'])
type: string;
}

View File

@@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { SubscriptionService } from './subscription.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('subscriptions')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class SubscriptionController {
constructor(private readonly subscriptionService: SubscriptionService) {}
@Get()
@Roles('manager')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
@Query('status') status?: string,
) {
return this.subscriptionService.findAll(user.tenantId, { clientId, status });
}
@Get(':id')
@Roles('manager')
async findById(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.subscriptionService.findById(user.tenantId, id);
}
@Post()
@Roles('manager')
async create(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateSubscriptionDto,
) {
return this.subscriptionService.create(user.tenantId, dto);
}
@Patch(':id/suspend')
@Roles('manager')
async suspend(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.subscriptionService.suspend(user.tenantId, id);
}
@Patch(':id/reactivate')
@Roles('manager')
async reactivate(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.subscriptionService.reactivate(user.tenantId, id);
}
@Patch(':id/cancel')
@Roles('manager')
async cancel(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.subscriptionService.cancel(user.tenantId, id);
}
}

View File

@@ -0,0 +1,25 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
import { TicketModule } from '../ticket/ticket.module';
import { TicketService } from '../ticket/ticket.service';
@Module({
imports: [TicketModule],
controllers: [SubscriptionController],
providers: [SubscriptionService],
exports: [SubscriptionService],
})
export class SubscriptionModule implements OnModuleInit {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly ticketService: TicketService,
) {}
onModuleInit() {
// Wire up the ticket resolution event to subscription workflow
this.ticketService.onTicketResolved = async (event) => {
await this.subscriptionService.handleTicketResolved(event);
};
}
}

View File

@@ -0,0 +1,316 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TicketService, TicketResolvedEvent } from '../ticket/ticket.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@Injectable()
export class SubscriptionService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
) {}
async findAll(tenantId: string, filters?: { clientId?: string; status?: string }) {
const db = this.prisma.forTenant(tenantId);
return db.subscription.findMany({
where: {
...(filters?.clientId && { clientId: filters.clientId }),
...(filters?.status && { status: filters.status }),
},
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async findById(tenantId: string, id: string) {
const db = this.prisma.forTenant(tenantId);
const sub = await db.subscription.findFirst({
where: { id },
include: {
client: true,
plan: true,
},
});
if (!sub) {
throw new NotFoundException('Subscription not found');
}
return sub;
}
async create(tenantId: string, dto: CreateSubscriptionDto) {
// Verify client exists
const client = await this.prisma.client.findFirst({
where: { id: dto.clientId, tenantId },
});
if (!client) {
throw new NotFoundException('Client not found');
}
// Verify plan exists
const plan = await this.prisma.plan.findFirst({
where: { id: dto.planId, tenantId, isActive: true },
});
if (!plan) {
throw new NotFoundException('Plan not found or inactive');
}
// Check no active subscription for this client
const activeSub = await this.prisma.subscription.findFirst({
where: {
clientId: dto.clientId,
tenantId,
status: { in: ['pending', 'active'] },
},
});
if (activeSub) {
throw new BadRequestException('Client already has an active or pending subscription');
}
return this.prisma.subscription.create({
data: {
tenantId,
clientId: dto.clientId,
planId: dto.planId,
type: dto.type,
status: 'pending',
},
include: {
plan: { select: { id: true, name: true, price: true } },
client: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async suspend(tenantId: string, id: string) {
return this.updateStatus(tenantId, id, 'suspended', ['active']);
}
async reactivate(tenantId: string, id: string) {
return this.updateStatus(tenantId, id, 'active', ['suspended']);
}
async cancel(tenantId: string, id: string) {
return this.updateStatus(tenantId, id, 'cancelled', ['pending', 'active', 'suspended']);
}
/**
* Handle ticket resolution events — drives the postpaid/prepaid workflow.
*
* POSTPAID: install resolved → auto activation ticket
* activation resolved → activate subscription + auto 1st invoice (due 1 month)
*
* PREPAID: install resolved → auto 1st invoice (will need payment before activation)
* activation resolved → activate subscription + auto next invoice
*/
async handleTicketResolved(event: TicketResolvedEvent) {
if (!event.clientId) return;
const subscription = await this.prisma.subscription.findFirst({
where: {
clientId: event.clientId,
tenantId: event.tenantId,
status: 'pending',
},
include: { client: true, plan: true },
});
if (!subscription) return;
// Find a user to attribute system actions to (first admin of tenant)
const adminUser = await this.prisma.user.findFirst({
where: { tenantId: event.tenantId },
include: { roles: { where: { role: 'tenant_admin' } } },
});
const systemUserId = adminUser?.id || '';
if (subscription.type === 'postpaid') {
await this.handlePostpaidTicketResolved(event, subscription, systemUserId);
} else {
await this.handlePrepaidTicketResolved(event, subscription, systemUserId);
}
}
private async handlePostpaidTicketResolved(
event: TicketResolvedEvent,
subscription: any,
systemUserId: string,
) {
if (event.type === 'installation') {
// Installation done → auto-create activation ticket
await this.prisma.subscription.update({
where: { id: subscription.id },
data: { installedAt: new Date() },
});
await this.ticketService.createSystemTicket(event.tenantId, systemUserId, {
clientId: event.clientId!,
type: 'activation',
title: `Activation for ${subscription.client.firstName} ${subscription.client.lastName}`,
description: 'Auto-created after installation completion (postpaid)',
});
} else if (event.type === 'activation') {
// Activation done → activate subscription
const now = new Date();
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'active',
activatedAt: now,
startDate: now,
},
});
// Auto-create 1st invoice due 1 month from installation
const dueDate = new Date(subscription.installedAt || now);
dueDate.setDate(dueDate.getDate() + (subscription.plan.billingCycle || 30));
const invoiceCount = await this.prisma.invoice.count({
where: { tenantId: event.tenantId },
});
await this.prisma.invoice.create({
data: {
tenantId: event.tenantId,
clientId: event.clientId!,
number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`,
amount: subscription.plan.price,
balance: subscription.plan.price,
status: 'sent',
dueDate,
periodStart: now,
periodEnd: dueDate,
},
});
}
}
private async handlePrepaidTicketResolved(
event: TicketResolvedEvent,
subscription: any,
systemUserId: string,
) {
if (event.type === 'installation') {
// Installation done → auto-create 1st invoice (must pay before activation)
await this.prisma.subscription.update({
where: { id: subscription.id },
data: { installedAt: new Date() },
});
const invoiceCount = await this.prisma.invoice.count({
where: { tenantId: event.tenantId },
});
await this.prisma.invoice.create({
data: {
tenantId: event.tenantId,
clientId: event.clientId!,
number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`,
amount: subscription.plan.price,
balance: subscription.plan.price,
status: 'sent',
dueDate: new Date(), // Due immediately for prepaid
periodStart: new Date(),
periodEnd: new Date(Date.now() + (subscription.plan.billingCycle || 30) * 86400000),
},
});
} else if (event.type === 'activation') {
// Activation done → activate subscription + auto next invoice
const now = new Date();
await this.prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'active',
activatedAt: now,
startDate: now,
},
});
// Create next month invoice
const nextDue = new Date(now);
nextDue.setDate(nextDue.getDate() + (subscription.plan.billingCycle || 30));
const invoiceCount = await this.prisma.invoice.count({
where: { tenantId: event.tenantId },
});
await this.prisma.invoice.create({
data: {
tenantId: event.tenantId,
clientId: event.clientId!,
number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`,
amount: subscription.plan.price,
balance: subscription.plan.price,
status: 'sent',
dueDate: nextDue,
periodStart: now,
periodEnd: nextDue,
},
});
}
}
/**
* Called when a prepaid client pays their first invoice.
* Triggers auto-creation of activation ticket.
*/
async handlePrepaidPayment(tenantId: string, clientId: string, systemUserId: string) {
const subscription = await this.prisma.subscription.findFirst({
where: { clientId, tenantId, type: 'prepaid', status: 'pending' },
include: { client: true },
});
if (!subscription) return;
// Check if activation ticket already exists
const existingActivation = await this.prisma.ticket.findFirst({
where: { clientId, tenantId, type: 'activation', status: { in: ['open', 'in_progress'] } },
});
if (existingActivation) return;
await this.ticketService.createSystemTicket(tenantId, systemUserId, {
clientId,
type: 'activation',
title: `Activation for ${subscription.client.firstName} ${subscription.client.lastName}`,
description: 'Auto-created after first payment received (prepaid)',
});
}
private async updateStatus(
tenantId: string,
id: string,
newStatus: string,
validFromStatuses: string[],
) {
const db = this.prisma.forTenant(tenantId);
const sub = await db.subscription.findFirst({ where: { id } });
if (!sub) {
throw new NotFoundException('Subscription not found');
}
if (!validFromStatuses.includes(sub.status)) {
throw new BadRequestException(
`Cannot change status from '${sub.status}' to '${newStatus}'`,
);
}
return this.prisma.subscription.update({
where: { id },
data: { status: newStatus },
include: {
plan: { select: { id: true, name: true, price: true } },
client: { select: { id: true, firstName: true, lastName: true } },
},
});
}
}