initial: standalone repo from monorepo split
This commit is contained in:
13
src/subscription/dto/create-subscription.dto.ts
Normal file
13
src/subscription/dto/create-subscription.dto.ts
Normal 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;
|
||||
}
|
||||
78
src/subscription/subscription.controller.ts
Normal file
78
src/subscription/subscription.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/subscription/subscription.module.ts
Normal file
25
src/subscription/subscription.module.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
316
src/subscription/subscription.service.ts
Normal file
316
src/subscription/subscription.service.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user