initial: standalone repo from monorepo split
This commit is contained in:
47
src/account/account.controller.ts
Normal file
47
src/account/account.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import { TransferFundsDto } from './dto/transfer-funds.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('accounts')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
|
||||
return this.accountService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||
return this.accountService.update(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.accountService.remove(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('transfer')
|
||||
async transfer(@CurrentUser() user: CurrentUserPayload, @Body() dto: TransferFundsDto) {
|
||||
return this.accountService.transfer(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Get('transfers')
|
||||
async getTransfers(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountService.getTransfers(user.tenantId);
|
||||
}
|
||||
}
|
||||
12
src/account/account.module.ts
Normal file
12
src/account/account.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountingModule],
|
||||
controllers: [AccountController],
|
||||
providers: [AccountService],
|
||||
exports: [AccountService],
|
||||
})
|
||||
export class AccountModule {}
|
||||
137
src/account/account.service.ts
Normal file
137
src/account/account.service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import { TransferFundsDto } from './dto/transfer-funds.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly journal: JournalService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
return this.prisma.companyAccount.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAccountDto) {
|
||||
// Auto-generate a CoA code for this account
|
||||
const existingCoa = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, type: 'asset', code: { startsWith: '10' } },
|
||||
orderBy: { code: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
const lastCode = existingCoa[0]?.code || '1040';
|
||||
const nextCode = String(parseInt(lastCode) + 10);
|
||||
|
||||
// Create CoA entry first
|
||||
const coa = await this.prisma.chartOfAccount.create({
|
||||
data: { tenantId, code: nextCode, name: dto.name, type: 'asset', isSystem: false },
|
||||
});
|
||||
|
||||
// Create company account linked to CoA
|
||||
return this.prisma.companyAccount.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
accountNo: dto.accountNo,
|
||||
balance: dto.initialBalance || 0,
|
||||
chartOfAccountId: coa.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAccountDto) {
|
||||
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem && dto.name && dto.name !== account.name) {
|
||||
throw new ForbiddenException('Cannot rename system account');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.companyAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.accountNo !== undefined && { accountNo: dto.accountNo }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
|
||||
// Sync CoA name if changed
|
||||
if (dto.name && account.chartOfAccountId) {
|
||||
await this.prisma.chartOfAccount.update({
|
||||
where: { id: account.chartOfAccountId },
|
||||
data: { name: dto.name },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const account = await this.prisma.companyAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem) throw new ForbiddenException('Cannot delete system account (Cash on Hand)');
|
||||
if (Number(account.balance) > 0) throw new BadRequestException('Transfer funds out before deleting');
|
||||
|
||||
// Delete linked CoA if exists
|
||||
if (account.chartOfAccountId) {
|
||||
await this.prisma.chartOfAccount.delete({ where: { id: account.chartOfAccountId } }).catch(() => {});
|
||||
}
|
||||
|
||||
await this.prisma.companyAccount.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async transfer(tenantId: string, transferredBy: string, dto: TransferFundsDto) {
|
||||
const from = await this.prisma.companyAccount.findFirst({ where: { id: dto.fromAccountId, tenantId } });
|
||||
const to = await this.prisma.companyAccount.findFirst({ where: { id: dto.toAccountId, tenantId } });
|
||||
if (!from || !to) throw new NotFoundException('Account not found');
|
||||
if (from.id === to.id) throw new BadRequestException('Cannot transfer to same account');
|
||||
if (Number(from.balance) < dto.amount) throw new BadRequestException('Insufficient balance');
|
||||
|
||||
const [transfer] = await this.prisma.$transaction([
|
||||
this.prisma.fundTransfer.create({
|
||||
data: { tenantId, fromAccountId: dto.fromAccountId, toAccountId: dto.toAccountId, amount: dto.amount, description: dto.description, transferredBy },
|
||||
}),
|
||||
this.prisma.companyAccount.update({ where: { id: from.id }, data: { balance: { decrement: dto.amount } } }),
|
||||
this.prisma.companyAccount.update({ where: { id: to.id }, data: { balance: { increment: dto.amount } } }),
|
||||
]);
|
||||
|
||||
// Journal entry for fund transfer: DR destination CoA, CR source CoA
|
||||
if (from.chartOfAccountId && to.chartOfAccountId) {
|
||||
const fromCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: from.chartOfAccountId } });
|
||||
const toCoa = await this.prisma.chartOfAccount.findUnique({ where: { id: to.chartOfAccountId } });
|
||||
if (fromCoa && toCoa) {
|
||||
this.journal.createEntry(
|
||||
tenantId,
|
||||
`Fund transfer: ${from.name} → ${to.name}${dto.description ? ` — ${dto.description}` : ''}`,
|
||||
[
|
||||
{ accountCode: toCoa.code, debit: dto.amount },
|
||||
{ accountCode: fromCoa.code, credit: dto.amount },
|
||||
],
|
||||
{ reference: `TRF-${transfer.id.slice(0, 8)}`, sourceType: 'transfer', sourceId: transfer.id },
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return transfer;
|
||||
}
|
||||
|
||||
async getTransfers(tenantId: string) {
|
||||
return this.prisma.fundTransfer.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fromAccount: { select: { name: true, type: true } },
|
||||
toAccount: { select: { name: true, type: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/account/dto/create-account.dto.ts
Normal file
8
src/account/dto/create-account.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['bank', 'e_wallet', 'cash']) type: string;
|
||||
@IsOptional() @IsString() accountNo?: string;
|
||||
@IsOptional() @IsNumber() initialBalance?: number;
|
||||
}
|
||||
8
src/account/dto/transfer-funds.dto.ts
Normal file
8
src/account/dto/transfer-funds.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class TransferFundsDto {
|
||||
@IsUUID() fromAccountId: string;
|
||||
@IsUUID() toAccountId: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
}
|
||||
7
src/account/dto/update-account.dto.ts
Normal file
7
src/account/dto/update-account.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsOptional, IsBoolean, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsOptional() @IsString() @MinLength(2) name?: string;
|
||||
@IsOptional() @IsString() accountNo?: string;
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
45
src/accounting/accounting.controller.ts
Normal file
45
src/accounting/accounting.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { CreateAccountDto } from './dto/create-coa.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('accounting')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class AccountingController {
|
||||
constructor(private readonly accountingService: AccountingService) {}
|
||||
|
||||
@Get('chart-of-accounts')
|
||||
async getCoA(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getChartOfAccounts(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('chart-of-accounts')
|
||||
async createAccount(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAccountDto) {
|
||||
return this.accountingService.createAccount(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Delete('chart-of-accounts/:id')
|
||||
async deleteAccount(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.accountingService.deleteAccount(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('general-ledger')
|
||||
async getLedger(@CurrentUser() user: CurrentUserPayload, @Query('accountId') accountId?: string) {
|
||||
return this.accountingService.getGeneralLedger(user.tenantId, accountId);
|
||||
}
|
||||
|
||||
@Get('trial-balance')
|
||||
async getTrialBalance(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getTrialBalance(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.accountingService.getAccountingOverview(user.tenantId);
|
||||
}
|
||||
}
|
||||
11
src/accounting/accounting.module.ts
Normal file
11
src/accounting/accounting.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { JournalService } from './journal.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountingController],
|
||||
providers: [AccountingService, JournalService],
|
||||
exports: [AccountingService, JournalService],
|
||||
})
|
||||
export class AccountingModule {}
|
||||
209
src/accounting/accounting.service.ts
Normal file
209
src/accounting/accounting.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAccountDto } from './dto/create-coa.dto';
|
||||
|
||||
// Default Chart of Accounts for new tenants
|
||||
const DEFAULT_COA = [
|
||||
{ code: '1000', name: 'Assets', type: 'asset', isSystem: true },
|
||||
{ code: '1010', name: 'Cash on Hand', type: 'asset', isSystem: true },
|
||||
{ code: '1020', name: 'GCash Business', type: 'asset', isSystem: true },
|
||||
{ code: '1030', name: 'Maya Business', type: 'asset', isSystem: true },
|
||||
{ code: '1040', name: 'Bank Account', type: 'asset', isSystem: true },
|
||||
{ code: '1100', name: 'Accounts Receivable', type: 'asset', isSystem: true },
|
||||
{ code: '1200', name: 'Equipment', type: 'asset', isSystem: true },
|
||||
{ code: '2000', name: 'Liabilities', type: 'liability', isSystem: true },
|
||||
{ code: '2010', name: 'Accounts Payable', type: 'liability', isSystem: true },
|
||||
{ code: '3000', name: 'Equity', type: 'equity', isSystem: true },
|
||||
{ code: '3010', name: 'Owner\'s Equity', type: 'equity', isSystem: true },
|
||||
{ code: '3020', name: 'Retained Earnings', type: 'equity', isSystem: true },
|
||||
{ code: '4000', name: 'Revenue', type: 'revenue', isSystem: true },
|
||||
{ code: '4010', name: 'Internet Service Revenue', type: 'revenue', isSystem: true },
|
||||
{ code: '4020', name: 'Installation Fees', type: 'revenue', isSystem: true },
|
||||
{ code: '5000', name: 'Expenses', type: 'expense', isSystem: true },
|
||||
{ code: '5010', name: 'Utilities Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5020', name: 'Salaries Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5030', name: 'Maintenance Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5040', name: 'Transport Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5050', name: 'Supplies Expense', type: 'expense', isSystem: true },
|
||||
{ code: '5060', name: 'Equipment Expense', type: 'expense', isSystem: true },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AccountingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getChartOfAccounts(tenantId: string) {
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
// Auto-seed if empty
|
||||
if (accounts.length === 0) {
|
||||
await this.seedDefaultAccounts(tenantId);
|
||||
return this.prisma.chartOfAccount.findMany({ where: { tenantId }, orderBy: { code: 'asc' } });
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async seedDefaultAccounts(tenantId: string) {
|
||||
for (const acc of DEFAULT_COA) {
|
||||
await this.prisma.chartOfAccount.upsert({
|
||||
where: { tenantId_code: { tenantId, code: acc.code } },
|
||||
create: { tenantId, ...acc },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(tenantId: string, dto: CreateAccountDto) {
|
||||
const existing = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: dto.code },
|
||||
});
|
||||
if (existing) throw new ConflictException('Account code already exists');
|
||||
|
||||
return this.prisma.chartOfAccount.create({
|
||||
data: { tenantId, code: dto.code, name: dto.name, type: dto.type, parentId: dto.parentId },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAccount(tenantId: string, id: string) {
|
||||
const account = await this.prisma.chartOfAccount.findFirst({ where: { id, tenantId } });
|
||||
if (!account) throw new NotFoundException('Account not found');
|
||||
if (account.isSystem) throw new BadRequestException('Cannot delete system account');
|
||||
|
||||
const hasEntries = await this.prisma.journalLine.count({ where: { accountId: id } });
|
||||
if (hasEntries > 0) throw new BadRequestException('Cannot delete account with journal entries');
|
||||
|
||||
await this.prisma.chartOfAccount.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async getGeneralLedger(tenantId: string, accountId?: string) {
|
||||
const where: any = {};
|
||||
if (accountId) {
|
||||
where.accountId = accountId;
|
||||
} else {
|
||||
where.journalEntry = { tenantId };
|
||||
}
|
||||
|
||||
return this.prisma.journalLine.findMany({
|
||||
where,
|
||||
include: {
|
||||
account: { select: { code: true, name: true, type: true } },
|
||||
journalEntry: { select: { entryDate: true, description: true, reference: true } },
|
||||
},
|
||||
orderBy: { journalEntry: { entryDate: 'desc' } },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async getTrialBalance(tenantId: string) {
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
journalLines: { select: { debit: true, credit: true } },
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
return accounts.map((a) => {
|
||||
const totalDebit = a.journalLines.reduce((s, l) => s + Number(l.debit), 0);
|
||||
const totalCredit = a.journalLines.reduce((s, l) => s + Number(l.credit), 0);
|
||||
return {
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
debit: totalDebit,
|
||||
credit: totalCredit,
|
||||
balance: totalDebit - totalCredit,
|
||||
};
|
||||
}).filter((a) => a.debit > 0 || a.credit > 0);
|
||||
}
|
||||
|
||||
async getAccountingOverview(tenantId: string) {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// All accounts with their full journal lines
|
||||
const accounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
journalLines: {
|
||||
select: { debit: true, credit: true, journalEntry: { select: { entryDate: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
|
||||
// Compute balances by type (from all journal entries)
|
||||
const byType: Record<string, { debit: number; credit: number; balance: number }> = {};
|
||||
const cashAccounts: { code: string; name: string; balance: number }[] = [];
|
||||
|
||||
for (const acc of accounts) {
|
||||
const totalDebit = acc.journalLines.reduce((s, l) => s + Number(l.debit), 0);
|
||||
const totalCredit = acc.journalLines.reduce((s, l) => s + Number(l.credit), 0);
|
||||
const balance = totalDebit - totalCredit;
|
||||
|
||||
if (!byType[acc.type]) byType[acc.type] = { debit: 0, credit: 0, balance: 0 };
|
||||
byType[acc.type].debit += totalDebit;
|
||||
byType[acc.type].credit += totalCredit;
|
||||
byType[acc.type].balance += balance;
|
||||
|
||||
// Track individual cash accounts (codes 1010-1040)
|
||||
if (['1010', '1020', '1030', '1040'].includes(acc.code)) {
|
||||
cashAccounts.push({ code: acc.code, name: acc.name, balance });
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly income (revenue credits this month)
|
||||
const monthlyRevenue = accounts
|
||||
.filter((a) => a.type === 'revenue')
|
||||
.reduce((sum, acc) => {
|
||||
const monthCredits = acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.credit), 0);
|
||||
return sum + monthCredits;
|
||||
}, 0);
|
||||
|
||||
// Monthly expenses (expense debits this month)
|
||||
const monthlyExpenses = accounts
|
||||
.filter((a) => a.type === 'expense')
|
||||
.reduce((sum, acc) => {
|
||||
const monthDebits = acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.debit), 0);
|
||||
return sum + monthDebits;
|
||||
}, 0);
|
||||
|
||||
// Expense breakdown by category this month
|
||||
const expenseBreakdown = accounts
|
||||
.filter((a) => a.type === 'expense')
|
||||
.map((acc) => ({
|
||||
code: acc.code,
|
||||
name: acc.name,
|
||||
amount: acc.journalLines
|
||||
.filter((l) => new Date(l.journalEntry.entryDate) >= monthStart)
|
||||
.reduce((s, l) => s + Number(l.debit), 0),
|
||||
}))
|
||||
.filter((e) => e.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return {
|
||||
totalAssets: byType['asset']?.balance || 0,
|
||||
totalLiabilities: byType['liability']?.balance || 0,
|
||||
totalEquity: byType['equity']?.balance || 0,
|
||||
totalRevenue: byType['revenue']?.credit || 0,
|
||||
totalExpenses: byType['expense']?.debit || 0,
|
||||
accountsReceivable: accounts.find((a) => a.code === '1100')?.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0) || 0,
|
||||
cashAccounts,
|
||||
cashOnHand: cashAccounts.reduce((s, a) => s + a.balance, 0),
|
||||
monthlyRevenue,
|
||||
monthlyExpenses,
|
||||
netIncome: monthlyRevenue - monthlyExpenses,
|
||||
expenseBreakdown,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
src/accounting/dto/create-coa.dto.ts
Normal file
8
src/accounting/dto/create-coa.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, MinLength, IsOptional, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString() @MinLength(3) code: string;
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['asset', 'liability', 'equity', 'revenue', 'expense']) type: string;
|
||||
@IsOptional() @IsUUID() parentId?: string;
|
||||
}
|
||||
259
src/accounting/journal.service.ts
Normal file
259
src/accounting/journal.service.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface JournalLineInput {
|
||||
accountCode: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
}
|
||||
|
||||
// Maps payment method to CoA code suffix
|
||||
const METHOD_SUFFIX: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
gcash: 'GCash',
|
||||
maya: 'Maya',
|
||||
bank_transfer: 'Bank',
|
||||
};
|
||||
|
||||
const METHOD_LABEL: Record<string, string> = {
|
||||
cash: 'Cash',
|
||||
gcash: 'GCash',
|
||||
maya: 'Maya',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
};
|
||||
|
||||
// Company-level CoA codes per method
|
||||
const COMPANY_COA: Record<string, string> = {
|
||||
cash: '1010',
|
||||
gcash: '1020',
|
||||
maya: '1030',
|
||||
bank_transfer: '1040',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JournalService {
|
||||
private readonly logger = new Logger(JournalService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Creates a journal entry with debit/credit lines.
|
||||
*/
|
||||
async createEntry(
|
||||
tenantId: string,
|
||||
description: string,
|
||||
lines: JournalLineInput[],
|
||||
options?: { reference?: string; sourceType?: string; sourceId?: string; createdById?: string },
|
||||
) {
|
||||
const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0);
|
||||
const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0);
|
||||
|
||||
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||
this.logger.warn(`Unbalanced entry: DR ${totalDebit} != CR ${totalCredit} — ${description}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedLines = [];
|
||||
for (const line of lines) {
|
||||
const account = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: line.accountCode },
|
||||
});
|
||||
if (!account) {
|
||||
this.logger.warn(`CoA ${line.accountCode} not found for tenant ${tenantId}`);
|
||||
return null;
|
||||
}
|
||||
resolvedLines.push({ accountId: account.id, debit: line.debit || 0, credit: line.credit || 0 });
|
||||
}
|
||||
|
||||
return this.prisma.journalEntry.create({
|
||||
data: {
|
||||
tenantId,
|
||||
description,
|
||||
reference: options?.reference,
|
||||
sourceType: options?.sourceType,
|
||||
sourceId: options?.sourceId,
|
||||
createdById: options?.createdById,
|
||||
lines: { create: resolvedLines },
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custodial CoA accounts for a user.
|
||||
* Pattern: "UserName - Cash" with code 15XX where XX = sequential.
|
||||
*/
|
||||
async createCustodialAccounts(tenantId: string, userId: string, userName: string) {
|
||||
// Find next available code block in 15XX range
|
||||
const existing = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, code: { startsWith: '15' } },
|
||||
orderBy: { code: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
const baseCode = existing[0] ? parseInt(existing[0].code) + 10 : 1500;
|
||||
|
||||
const methods = ['Cash', 'GCash', 'Maya', 'Bank'];
|
||||
const created = [];
|
||||
|
||||
for (let i = 0; i < methods.length; i++) {
|
||||
const code = String(baseCode + i);
|
||||
const name = `${userName} - ${methods[i]}`;
|
||||
const account = await this.prisma.chartOfAccount.upsert({
|
||||
where: { tenantId_code: { tenantId, code } },
|
||||
create: { tenantId, code, name, type: 'asset', isSystem: false },
|
||||
update: {},
|
||||
});
|
||||
created.push(account);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the custodial CoA code for a user + method.
|
||||
* Looks up "UserName - Cash/GCash/Maya/Bank" in the CoA.
|
||||
*/
|
||||
async getCustodialCode(tenantId: string, collectorId: string, method: string): Promise<string | null> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: collectorId } });
|
||||
if (!user) return null;
|
||||
|
||||
const suffix = METHOD_SUFFIX[method] || 'Cash';
|
||||
const searchName = `${user.firstName} ${user.lastName} - ${suffix}`;
|
||||
|
||||
const account = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, name: searchName },
|
||||
});
|
||||
|
||||
return account?.code || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment collected → DR collector's custodial CoA, CR Accounts Receivable.
|
||||
* Money sits in collector's custody until remittance is approved.
|
||||
*/
|
||||
async journalForPayment(
|
||||
tenantId: string,
|
||||
paymentId: string,
|
||||
amount: number,
|
||||
method: string,
|
||||
context: { invoiceNumber?: string; clientName?: string; collectorId?: string; collectorName?: string },
|
||||
) {
|
||||
let debitCode: string;
|
||||
|
||||
if (context.collectorId) {
|
||||
let custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
|
||||
if (!custodialCode && context.collectorName) {
|
||||
await this.createCustodialAccounts(tenantId, context.collectorId, context.collectorName);
|
||||
custodialCode = await this.getCustodialCode(tenantId, context.collectorId, method);
|
||||
}
|
||||
debitCode = custodialCode || COMPANY_COA[method] || '1010';
|
||||
} else {
|
||||
debitCode = COMPANY_COA[method] || '1010';
|
||||
}
|
||||
|
||||
const description = [
|
||||
`Payment collected via ${METHOD_LABEL[method] || method}`,
|
||||
context.collectorName ? `by ${context.collectorName}` : '',
|
||||
context.clientName ? `from ${context.clientName}` : '',
|
||||
context.invoiceNumber ? `for ${context.invoiceNumber}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return this.createEntry(tenantId, description, [
|
||||
{ accountCode: debitCode, debit: amount },
|
||||
{ accountCode: '1100', credit: amount },
|
||||
], { reference: context.invoiceNumber, sourceType: 'payment', sourceId: paymentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remittance approved → DR company CoA, CR collector's custodial CoA.
|
||||
* Clears money from collector custody into company books.
|
||||
*/
|
||||
async journalForRemittance(
|
||||
tenantId: string,
|
||||
remittanceId: string,
|
||||
collectorId: string,
|
||||
collectorName: string,
|
||||
paymentsByMethod: { method: string; total: number }[],
|
||||
) {
|
||||
const lines: JournalLineInput[] = [];
|
||||
|
||||
for (const pm of paymentsByMethod) {
|
||||
let custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
|
||||
|
||||
// Auto-create custodial accounts if they don't exist yet
|
||||
if (!custodialCode) {
|
||||
this.logger.warn(`Custodial account missing for ${collectorName} (${pm.method}) — auto-creating`);
|
||||
await this.createCustodialAccounts(tenantId, collectorId, collectorName);
|
||||
custodialCode = await this.getCustodialCode(tenantId, collectorId, pm.method);
|
||||
}
|
||||
|
||||
const companyCode = COMPANY_COA[pm.method] || '1010';
|
||||
|
||||
if (custodialCode) {
|
||||
lines.push({ accountCode: companyCode, debit: pm.total });
|
||||
lines.push({ accountCode: custodialCode, credit: pm.total });
|
||||
} else {
|
||||
this.logger.error(`Still no custodial account for ${collectorName} (${pm.method}) after auto-create`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
this.logger.warn(`No journal lines for remittance ${remittanceId} — skipping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalAmount = paymentsByMethod.reduce((s, p) => s + p.total, 0);
|
||||
return this.createEntry(
|
||||
tenantId,
|
||||
`Remittance approved — ${collectorName} cleared PHP ${totalAmount.toLocaleString()}`,
|
||||
lines,
|
||||
{ reference: `REM-${remittanceId.slice(0, 8)}`, sourceType: 'remittance', sourceId: remittanceId },
|
||||
);
|
||||
}
|
||||
|
||||
/** Invoice issued → DR Accounts Receivable (1100), CR Service Revenue (4010) */
|
||||
async journalForInvoice(tenantId: string, invoiceId: string, invoiceNumber: string, amount: number) {
|
||||
return this.createEntry(tenantId, `Invoice ${invoiceNumber} issued`, [
|
||||
{ accountCode: '1100', debit: amount },
|
||||
{ accountCode: '4010', credit: amount },
|
||||
], { reference: invoiceNumber, sourceType: 'invoice', sourceId: invoiceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CompanyAccount.balance when a journal entry affects cash accounts.
|
||||
* Called after remittance confirmation (increment) and expense approval (decrement).
|
||||
*/
|
||||
async updateCompanyAccountBalance(
|
||||
tenantId: string,
|
||||
coaCode: string,
|
||||
amount: number,
|
||||
direction: 'increment' | 'decrement',
|
||||
) {
|
||||
const coa = await this.prisma.chartOfAccount.findFirst({
|
||||
where: { tenantId, code: coaCode },
|
||||
});
|
||||
if (!coa) return;
|
||||
|
||||
const account = await this.prisma.companyAccount.findFirst({
|
||||
where: { tenantId, chartOfAccountId: coa.id, isActive: true },
|
||||
});
|
||||
if (!account) return;
|
||||
|
||||
await this.prisma.companyAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { balance: { [direction]: amount } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Expense approved → DR Expense Category, CR Cash (1010) */
|
||||
async journalForExpense(tenantId: string, expenseId: string, amount: number, category: string) {
|
||||
const expenseAccountCode: Record<string, string> = {
|
||||
utilities: '5010', salary: '5020', maintenance: '5030',
|
||||
transport: '5040', supplies: '5050', equipment: '5060', other: '5000',
|
||||
};
|
||||
return this.createEntry(tenantId, `Expense: ${category}`, [
|
||||
{ accountCode: expenseAccountCode[category] || '5000', debit: amount },
|
||||
{ accountCode: '1010', credit: amount },
|
||||
], { sourceType: 'expense', sourceId: expenseId });
|
||||
}
|
||||
}
|
||||
81
src/app.module.ts
Normal file
81
src/app.module.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { AreaModule } from './area/area.module';
|
||||
import { PlanModule } from './plan/plan.module';
|
||||
import { ClientModule } from './client/client.module';
|
||||
import { TicketModule } from './ticket/ticket.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { InvoiceModule } from './invoice/invoice.module';
|
||||
import { PaymentModule } from './payment/payment.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ReportModule } from './report/report.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { EmployeeModule } from './employee/employee.module';
|
||||
import { ExpenseModule } from './expense/expense.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AssetModule } from './asset/asset.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||
import { AccountingModule } from './accounting/accounting.module';
|
||||
import { PayrollModule } from './payroll/payroll.module';
|
||||
import { RoleModule } from './role/role.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||
import { AccessGuard } from './common/guards/access.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '../../.env',
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{ name: 'short', ttl: 1000, limit: 50 }, // 50 req/sec
|
||||
{ name: 'medium', ttl: 60000, limit: 500 }, // 500 req/min
|
||||
]),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
TenantModule,
|
||||
AreaModule,
|
||||
PlanModule,
|
||||
ClientModule,
|
||||
TicketModule,
|
||||
SubscriptionModule,
|
||||
InvoiceModule,
|
||||
PaymentModule,
|
||||
DashboardModule,
|
||||
ReportModule,
|
||||
NotificationModule,
|
||||
AuditModule,
|
||||
EmployeeModule,
|
||||
ExpenseModule,
|
||||
AccountModule,
|
||||
AssetModule,
|
||||
PortalModule,
|
||||
BillingModule,
|
||||
SchedulerModule,
|
||||
AccountingModule,
|
||||
PayrollModule,
|
||||
RoleModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_GUARD, useClass: AccessGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
63
src/area/area.controller.ts
Normal file
63
src/area/area.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AreaService } from './area.service';
|
||||
import { CreateAreaDto } from './dto/create-area.dto';
|
||||
import { UpdateAreaDto } from './dto/update-area.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('areas')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class AreaController {
|
||||
constructor(private readonly areaService: AreaService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.areaService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.areaService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreateAreaDto,
|
||||
) {
|
||||
return this.areaService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAreaDto,
|
||||
) {
|
||||
return this.areaService.update(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.areaService.remove(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
10
src/area/area.module.ts
Normal file
10
src/area/area.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AreaController } from './area.controller';
|
||||
import { AreaService } from './area.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AreaController],
|
||||
providers: [AreaService],
|
||||
exports: [AreaService],
|
||||
})
|
||||
export class AreaModule {}
|
||||
105
src/area/area.service.ts
Normal file
105
src/area/area.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAreaDto } from './dto/create-area.dto';
|
||||
import { UpdateAreaDto } from './dto/update-area.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AreaService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
return db.area.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: { select: { clients: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const area = await db.area.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { clients: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
return area;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAreaDto) {
|
||||
const existing = await this.prisma.area.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Area name already exists');
|
||||
}
|
||||
|
||||
return this.prisma.area.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAreaDto) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.area.findFirst({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const duplicate = await this.prisma.area.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException('Area name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.area.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.area.findFirst({
|
||||
where: { id },
|
||||
include: { _count: { select: { clients: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Area not found');
|
||||
}
|
||||
|
||||
if (existing._count.clients > 0) {
|
||||
throw new ConflictException(
|
||||
'Cannot delete area with assigned clients. Deactivate it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.area.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
11
src/area/dto/create-area.dto.ts
Normal file
11
src/area/dto/create-area.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAreaDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
16
src/area/dto/update-area.dto.ts
Normal file
16
src/area/dto/update-area.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateAreaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
31
src/asset/asset.controller.ts
Normal file
31
src/asset/asset.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetService } from './asset.service';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.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('assets')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class AssetController {
|
||||
constructor(private readonly assetService: AssetService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('category') category?: string, @Query('status') status?: string) {
|
||||
return this.assetService.findAll(user.tenantId, { category, status });
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateAssetDto) {
|
||||
return this.assetService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateAssetDto) {
|
||||
return this.assetService.update(user.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
10
src/asset/asset.module.ts
Normal file
10
src/asset/asset.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetController } from './asset.controller';
|
||||
import { AssetService } from './asset.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService],
|
||||
exports: [AssetService],
|
||||
})
|
||||
export class AssetModule {}
|
||||
53
src/asset/asset.service.ts
Normal file
53
src/asset/asset.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AssetService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { category?: string; status?: string }) {
|
||||
return this.prisma.asset.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(filters?.category && { category: filters.category }),
|
||||
...(filters?.status && { status: filters.status }),
|
||||
},
|
||||
include: { assignedTo: { select: { id: true, firstName: true, lastName: true, employeeNo: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateAssetDto) {
|
||||
return this.prisma.asset.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
category: dto.category,
|
||||
serialNumber: dto.serialNumber,
|
||||
purchaseDate: dto.purchaseDate ? new Date(dto.purchaseDate) : null,
|
||||
purchasePrice: dto.purchasePrice || null,
|
||||
location: dto.location,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateAssetDto) {
|
||||
const existing = await this.prisma.asset.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException('Asset not found');
|
||||
|
||||
return this.prisma.asset.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
...(dto.assignedToId !== undefined && { assignedToId: dto.assignedToId || null }),
|
||||
...(dto.location !== undefined && { location: dto.location }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
},
|
||||
include: { assignedTo: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/asset/dto/create-asset.dto.ts
Normal file
11
src/asset/dto/create-asset.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsString() @MinLength(2) name: string;
|
||||
@IsString() @IsIn(['router', 'olt', 'cable', 'tool', 'vehicle', 'computer', 'other']) category: string;
|
||||
@IsOptional() @IsString() serialNumber?: string;
|
||||
@IsOptional() @IsString() purchaseDate?: string;
|
||||
@IsOptional() @IsNumber() purchasePrice?: number;
|
||||
@IsOptional() @IsString() location?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
src/asset/dto/update-asset.dto.ts
Normal file
9
src/asset/dto/update-asset.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString, IsOptional, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() @IsIn(['available', 'in_use', 'maintenance', 'retired']) status?: string;
|
||||
@IsOptional() @IsUUID() assignedToId?: string;
|
||||
@IsOptional() @IsString() location?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
9
src/audit/audit.module.ts
Normal file
9
src/audit/audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
39
src/audit/audit.service.ts
Normal file
39
src/audit/audit.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface AuditEntry {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
details?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async log(entry: AuditEntry) {
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`INSERT INTO audit_logs (id, "tenantId", "userId", action, entity, "entityId", details, "ipAddress", "createdAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6::jsonb, $7, NOW())`,
|
||||
entry.tenantId,
|
||||
entry.userId,
|
||||
entry.action,
|
||||
entry.entity,
|
||||
entry.entityId,
|
||||
JSON.stringify(entry.details || {}),
|
||||
entry.ipAddress || null,
|
||||
);
|
||||
}
|
||||
|
||||
async findByTenant(tenantId: string, limit = 50) {
|
||||
return this.prisma.$queryRawUnsafe(
|
||||
`SELECT * FROM audit_logs WHERE "tenantId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
|
||||
tenantId,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
62
src/auth/auth.controller.ts
Normal file
62
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle, SkipThrottle } from '@nestjs/throttler';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
|
||||
async register(@Body() dto: RegisterTenantDto) {
|
||||
return this.authService.registerTenant(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshTokens(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async logout(@Req() req: any) {
|
||||
await this.authService.logout(req.user.sub);
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
async profile(@Req() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
|
||||
}
|
||||
}
|
||||
26
src/auth/auth.module.ts
Normal file
26
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
267
src/auth/auth.service.ts
Normal file
267
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterTenantDto } from './dto/register-tenant.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<TokenResponse> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email: dto.email, isActive: true },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Super admin may not have a tenant; regular users must have active tenant
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(dto.password, user.password);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
async registerTenant(dto: RegisterTenantDto) {
|
||||
const existing = await this.prisma.tenant.findUnique({
|
||||
where: { slug: dto.slug },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Tenant slug already taken');
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
|
||||
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: {
|
||||
name: dto.tenantName,
|
||||
slug: dto.slug,
|
||||
settings: {
|
||||
companyName: dto.tenantName,
|
||||
currency: 'PHP',
|
||||
timezone: 'Asia/Manila',
|
||||
},
|
||||
users: {
|
||||
create: {
|
||||
email: dto.adminEmail,
|
||||
password: hashedPassword,
|
||||
firstName: dto.adminFirstName,
|
||||
lastName: dto.adminLastName,
|
||||
roles: {
|
||||
create: { role: 'tenant_admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
users: {
|
||||
include: { roles: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const admin = tenant.users[0]!;
|
||||
const roles = admin.roles.map((r) => r.role);
|
||||
const tokens = await this.generateTokens({
|
||||
sub: admin.id,
|
||||
tenantId: tenant.id,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
},
|
||||
user: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
roles,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
|
||||
if (!stored || stored.expiresAt < new Date()) {
|
||||
if (stored) {
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
}
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
// Delete the used refresh token (rotation)
|
||||
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: stored.userId },
|
||||
include: { roles: true, tenant: true },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
|
||||
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
return this.generateTokens({
|
||||
sub: user.id,
|
||||
tenantId: user.tenantId,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
});
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: true,
|
||||
tenant: { select: { id: true, name: true, slug: true, settings: true } },
|
||||
tenantRoles: {
|
||||
include: {
|
||||
tenantRole: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const roles = user.roles.map((r) => r.role);
|
||||
const isSuperAdmin = roles.includes('super_admin');
|
||||
|
||||
// Resolve permission matrix from tenant roles
|
||||
const accessMap: Record<string, Record<string, boolean>> = {};
|
||||
if (!isSuperAdmin) {
|
||||
for (const utr of user.tenantRoles) {
|
||||
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
|
||||
for (const p of utr.tenantRole.permissions) {
|
||||
if (!accessMap[p.module]) {
|
||||
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
|
||||
}
|
||||
if (p.canView) accessMap[p.module].canView = true;
|
||||
if (p.canCreate) accessMap[p.module].canCreate = true;
|
||||
if (p.canUpdate) accessMap[p.module].canUpdate = true;
|
||||
if (p.canArchive) accessMap[p.module].canArchive = true;
|
||||
if (p.canApprove) accessMap[p.module].canApprove = true;
|
||||
if (p.canExport) accessMap[p.module].canExport = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
roles,
|
||||
permissions: getPermissionsForRoles(roles),
|
||||
accessMap: isSuperAdmin ? 'all' : accessMap,
|
||||
tenantRoles: user.tenantRoles.map((tr) => ({
|
||||
id: tr.tenantRole.id,
|
||||
name: tr.tenantRole.name,
|
||||
slug: tr.tenantRole.slug,
|
||||
})),
|
||||
tenant: user.tenant,
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!valid) throw new UnauthorizedException('Current password is incorrect');
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 12);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashed, mustChangePassword: false },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
|
||||
const jti = crypto.randomUUID();
|
||||
const accessToken = this.jwt.sign({ ...payload, jti });
|
||||
|
||||
const refreshJti = crypto.randomUUID();
|
||||
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
|
||||
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
|
||||
});
|
||||
|
||||
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
const expiresAt = new Date();
|
||||
const days = parseInt(expiresIn) || 7;
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshToken,
|
||||
userId: payload.sub,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
}
|
||||
10
src/auth/dto/change-password.dto.ts
Normal file
10
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
10
src/auth/dto/login.dto.ts
Normal file
10
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
}
|
||||
6
src/auth/dto/refresh-token.dto.ts
Normal file
6
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
30
src/auth/dto/register-tenant.dto.ts
Normal file
30
src/auth/dto/register-tenant.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||
|
||||
export class RegisterTenantDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
tenantName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(50)
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@IsEmail()
|
||||
adminEmail: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
adminPassword: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminFirstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
adminLastName: string;
|
||||
}
|
||||
25
src/auth/strategies/jwt.strategy.ts
Normal file
25
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '@fiberops/shared';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload) {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId: payload.tenantId || null,
|
||||
roles: payload.roles || [],
|
||||
permissions: payload.permissions || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/billing/billing.controller.ts
Normal file
25
src/billing/billing.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { BillingService } from './billing.service';
|
||||
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.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('billing-settings')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get()
|
||||
async getSettings(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.billingService.getSettings(user.tenantId);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
async updateSettings(@CurrentUser() user: CurrentUserPayload, @Body() dto: UpdateBillingSettingsDto) {
|
||||
return this.billingService.updateSettings(user.tenantId, dto);
|
||||
}
|
||||
}
|
||||
10
src/billing/billing.module.ts
Normal file
10
src/billing/billing.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
25
src/billing/billing.service.ts
Normal file
25
src/billing/billing.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateBillingSettingsDto } from './dto/update-billing-settings.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getSettings(tenantId: string) {
|
||||
let settings = await this.prisma.billingSetting.findUnique({ where: { tenantId } });
|
||||
if (!settings) {
|
||||
settings = await this.prisma.billingSetting.create({ data: { tenantId } });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
async updateSettings(tenantId: string, dto: UpdateBillingSettingsDto) {
|
||||
await this.prisma.billingSetting.upsert({
|
||||
where: { tenantId },
|
||||
create: { tenantId, ...dto },
|
||||
update: dto,
|
||||
});
|
||||
return this.getSettings(tenantId);
|
||||
}
|
||||
}
|
||||
9
src/billing/dto/update-billing-settings.dto.ts
Normal file
9
src/billing/dto/update-billing-settings.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateBillingSettingsDto {
|
||||
@IsOptional() @IsBoolean() autoGenerate?: boolean;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(90) gracePeriodDays?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(60) dueDateOffsetDays?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(100) lateFeePercent?: number;
|
||||
@IsOptional() @IsString() invoicePrefix?: string;
|
||||
}
|
||||
65
src/client/client.controller.ts
Normal file
65
src/client/client.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ClientService } from './client.service';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.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('clients')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('technician')
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('areaId') areaId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.clientService.findAll(user.tenantId, { areaId, status, search, page, limit });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('technician')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.clientService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('manager')
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreateClientDto,
|
||||
) {
|
||||
return this.clientService.create(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('manager')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClientDto,
|
||||
) {
|
||||
return this.clientService.update(user.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
12
src/client/client.module.ts
Normal file
12
src/client/client.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientController } from './client.controller';
|
||||
import { ClientService } from './client.service';
|
||||
import { TicketModule } from '../ticket/ticket.module';
|
||||
|
||||
@Module({
|
||||
imports: [TicketModule],
|
||||
controllers: [ClientController],
|
||||
providers: [ClientService],
|
||||
exports: [ClientService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
164
src/client/client.service.ts
Normal file
164
src/client/client.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TicketService } from '../ticket/ticket.service';
|
||||
import { CreateClientDto } from './dto/create-client.dto';
|
||||
import { UpdateClientDto } from './dto/update-client.dto';
|
||||
import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { areaId?: string; status?: string; search?: string; page?: number; limit?: number }) {
|
||||
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const where = {
|
||||
...(filters?.areaId && { areaId: filters.areaId }),
|
||||
...(filters?.status && { status: filters.status }),
|
||||
...(filters?.search && {
|
||||
OR: [
|
||||
{ firstName: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ lastName: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ accountNumber: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
{ email: { contains: filters.search, mode: 'insensitive' as any } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
db.client.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
_count: { select: { subscriptions: true, tickets: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.client.count({ where }),
|
||||
]);
|
||||
return paginatedResult(items, total, page, limit);
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const client = await db.client.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
subscriptions: {
|
||||
include: { plan: { select: { id: true, name: true, price: true, speedDown: true, speedUp: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
tickets: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
assignee: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async create(tenantId: string, createdById: string, dto: CreateClientDto) {
|
||||
// Generate account number
|
||||
const count = await this.prisma.client.count({ where: { tenantId } });
|
||||
const accountNumber = `C-${String(count + 1).padStart(6, '0')}`;
|
||||
|
||||
// Check unique account number
|
||||
const existingAccount = await this.prisma.client.findFirst({
|
||||
where: { tenantId, accountNumber },
|
||||
});
|
||||
if (existingAccount) {
|
||||
throw new ConflictException('Account number conflict, please retry');
|
||||
}
|
||||
|
||||
const client = await this.prisma.client.create({
|
||||
data: {
|
||||
tenantId,
|
||||
accountNumber,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
address: dto.address,
|
||||
areaId: dto.areaId,
|
||||
},
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Validate 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');
|
||||
}
|
||||
|
||||
// Auto-create subscription (onboarding = client + plan + type)
|
||||
await this.prisma.subscription.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId: client.id,
|
||||
planId: dto.planId,
|
||||
type: dto.subscriptionType,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-create INSTALLATION ticket → kicks off the workflow
|
||||
// Postpaid: install → activation ticket → activate + invoice
|
||||
// Prepaid: install → invoice → payment → activation ticket → activate + next invoice
|
||||
await this.ticketService.createSystemTicket(tenantId, createdById, {
|
||||
clientId: client.id,
|
||||
type: 'installation',
|
||||
title: `Installation for ${client.firstName} ${client.lastName}`,
|
||||
description: `New client installation at ${client.address}. Plan: ${plan.name} (${dto.subscriptionType})`,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateClientDto) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.client.findFirst({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
return this.prisma.client.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.firstName && { firstName: dto.firstName }),
|
||||
...(dto.lastName && { lastName: dto.lastName }),
|
||||
...(dto.email !== undefined && { email: dto.email }),
|
||||
...(dto.phone !== undefined && { phone: dto.phone }),
|
||||
...(dto.address && { address: dto.address }),
|
||||
...(dto.areaId !== undefined && { areaId: dto.areaId }),
|
||||
...(dto.latitude !== undefined && { latitude: dto.latitude }),
|
||||
...(dto.longitude !== undefined && { longitude: dto.longitude }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
},
|
||||
include: {
|
||||
area: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/client/dto/create-client.dto.ts
Normal file
34
src/client/dto/create-client.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateClientDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
areaId?: string;
|
||||
|
||||
@IsUUID()
|
||||
planId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['prepaid', 'postpaid'])
|
||||
subscriptionType: string;
|
||||
}
|
||||
47
src/client/dto/update-client.dto.ts
Normal file
47
src/client/dto/update-client.dto.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsUUID, IsIn, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateClientDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
areaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['active', 'inactive', 'suspended'])
|
||||
status?: string;
|
||||
}
|
||||
15
src/common/decorators/access.decorator.ts
Normal file
15
src/common/decorators/access.decorator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export interface AccessRequirement {
|
||||
module: string;
|
||||
action: 'canView' | 'canCreate' | 'canUpdate' | 'canArchive' | 'canApprove' | 'canExport';
|
||||
}
|
||||
|
||||
export const ACCESS_KEY = 'access_requirement';
|
||||
|
||||
/**
|
||||
* Require the user's tenant role to grant a specific module+action.
|
||||
* Example: @RequireAccess('clients', 'canCreate')
|
||||
*/
|
||||
export const RequireAccess = (module: string, action: AccessRequirement['action']) =>
|
||||
SetMetadata(ACCESS_KEY, { module, action } as AccessRequirement);
|
||||
20
src/common/decorators/current-user.decorator.ts
Normal file
20
src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as CurrentUserPayload;
|
||||
// For tenantId, prefer request.tenantId which TenantGuard may have set (e.g., super_admin x-tenant-id header)
|
||||
if (data === 'tenantId') {
|
||||
return request.tenantId || user?.tenantId;
|
||||
}
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
5
src/common/decorators/permissions.decorator.ts
Normal file
5
src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from '@fiberops/shared';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
55
src/common/dto/pagination.dto.ts
Normal file
55
src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PaginationDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function paginationArgs(dto: PaginationDto) {
|
||||
const page = dto.page || 1;
|
||||
const limit = dto.limit || 20;
|
||||
return {
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
export function paginatedResult<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResult<T> {
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
39
src/common/filters/http-exception.filter.ts
Normal file
39
src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiResponse } from '@fiberops/shared';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as { message?: string }).message || message;
|
||||
} else {
|
||||
console.error('[GlobalExceptionFilter] Unhandled exception:', exception);
|
||||
}
|
||||
|
||||
const body: ApiResponse = {
|
||||
success: false,
|
||||
data: null,
|
||||
error: Array.isArray(message) ? message.join(', ') : message,
|
||||
};
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
}
|
||||
53
src/common/guards/access.guard.ts
Normal file
53
src/common/guards/access.guard.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ACCESS_KEY, AccessRequirement } from '../decorators/access.decorator';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccessGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requirement = this.reflector.getAllAndOverride<AccessRequirement>(ACCESS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// No @RequireAccess decorator → allow
|
||||
if (!requirement) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
// Super admin bypasses all access checks
|
||||
if (user.roles?.includes('super_admin')) return true;
|
||||
|
||||
// Resolve user's tenant role permissions from DB
|
||||
const assignments = await this.prisma.userTenantRole.findMany({
|
||||
where: { userId: user.sub },
|
||||
include: {
|
||||
tenantRole: {
|
||||
include: { permissions: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if any assigned role grants the required module+action
|
||||
for (const a of assignments) {
|
||||
if (!a.tenantRole.isActive || a.tenantRole.deletedAt) continue;
|
||||
for (const p of a.tenantRole.permissions) {
|
||||
if (p.module === requirement.module && p[requirement.action]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Access denied: requires ${requirement.action} on ${requirement.module}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/common/guards/permissions.guard.ts
Normal file
34
src/common/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!required || required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) return false;
|
||||
|
||||
// Super admin bypasses all permission checks
|
||||
if (user.roles?.includes('super_admin')) return true;
|
||||
|
||||
const userPerms: string[] = user.permissions || [];
|
||||
const missing = required.filter((p) => !userPerms.includes(p));
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
28
src/common/guards/roles.guard.ts
Normal file
28
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role, satisfiesRole } from '@fiberops/shared';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hierarchy-aware check: user with 'manager' satisfies 'technician' requirement
|
||||
return requiredRoles.some((role) => satisfiesRole(user.roles, role));
|
||||
}
|
||||
}
|
||||
36
src/common/guards/tenant.guard.ts
Normal file
36
src/common/guards/tenant.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class TenantGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
|
||||
// Super admin: can switch tenant context via x-tenant-id header
|
||||
const isSuperAdmin = user.roles?.includes('super_admin');
|
||||
|
||||
if (isSuperAdmin) {
|
||||
const headerTenantId = request.headers['x-tenant-id'];
|
||||
// Use header tenant if provided, otherwise fall back to user's own tenant (if any)
|
||||
request.tenantId = headerTenantId || user.tenantId || null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Regular users must have a tenantId from JWT
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException('Tenant context required');
|
||||
}
|
||||
|
||||
request.tenantId = user.tenantId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
24
src/common/interceptors/response.interceptor.ts
Normal file
24
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiResponse } from '@fiberops/shared';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/common/pipes/zod-validation.pipe.ts
Normal file
20
src/common/pipes/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common';
|
||||
import { ZodSchema, ZodError } from 'zod';
|
||||
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private schema: ZodSchema) {}
|
||||
|
||||
transform(value: unknown) {
|
||||
try {
|
||||
return this.schema.parse(value);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const messages = error.errors.map(
|
||||
(e) => `${e.path.join('.')}: ${e.message}`,
|
||||
);
|
||||
throw new BadRequestException(messages.join('; '));
|
||||
}
|
||||
throw new BadRequestException('Validation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/dashboard/dashboard.controller.ts
Normal file
37
src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
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('dashboard')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('kpis')
|
||||
@Roles('manager')
|
||||
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getKpis(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('revenue-chart')
|
||||
@Roles('manager')
|
||||
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getRevenueChart(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@Roles('manager')
|
||||
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getRecentActivity(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('financial-summary')
|
||||
@Roles('manager')
|
||||
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.dashboardService.getFinancialSummary(user.tenantId);
|
||||
}
|
||||
}
|
||||
9
src/dashboard/dashboard.module.ts
Normal file
9
src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
185
src/dashboard/dashboard.service.ts
Normal file
185
src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getKpis(tenantId: string) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
activeSubscribers,
|
||||
totalClients,
|
||||
todayPayments,
|
||||
overdueInvoices,
|
||||
newSignups,
|
||||
pendingTickets,
|
||||
] = await Promise.all([
|
||||
this.prisma.subscription.count({
|
||||
where: { tenantId, status: 'active' },
|
||||
}),
|
||||
this.prisma.client.count({
|
||||
where: { tenantId, status: 'active' },
|
||||
}),
|
||||
this.prisma.payment.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
createdAt: { gte: today, lt: tomorrow },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.invoice.count({
|
||||
where: {
|
||||
tenantId,
|
||||
status: { in: ['sent', 'partial'] },
|
||||
dueDate: { lt: today },
|
||||
},
|
||||
}),
|
||||
this.prisma.client.count({
|
||||
where: {
|
||||
tenantId,
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
}),
|
||||
this.prisma.ticket.count({
|
||||
where: {
|
||||
tenantId,
|
||||
status: { in: ['open', 'in_progress'] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
activeSubscribers,
|
||||
totalClients,
|
||||
todayCollections: {
|
||||
amount: Number(todayPayments._sum.amount || 0),
|
||||
count: todayPayments._count,
|
||||
},
|
||||
overdueAccounts: overdueInvoices,
|
||||
newSignups,
|
||||
pendingTickets,
|
||||
};
|
||||
}
|
||||
|
||||
async getRevenueChart(tenantId: string) {
|
||||
const months: { month: string; revenue: number; count: number }[] = [];
|
||||
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const start = new Date();
|
||||
start.setMonth(start.getMonth() - i, 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setMonth(end.getMonth() + 1);
|
||||
|
||||
const result = await this.prisma.payment.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
createdAt: { gte: start, lt: end },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
months.push({
|
||||
month: start.toLocaleString('en-US', { month: 'short', year: 'numeric' }),
|
||||
revenue: Number(result._sum.amount || 0),
|
||||
count: result._count,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
async getRecentActivity(tenantId: string) {
|
||||
const [recentPayments, recentTickets, recentClients] = await Promise.all([
|
||||
this.prisma.payment.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
client: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.ticket.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
client: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.client.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: { id: true, firstName: true, lastName: true, accountNumber: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { recentPayments, recentTickets, recentClients };
|
||||
}
|
||||
|
||||
async getFinancialSummary(tenantId: string) {
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Source of truth: compute all values from journal entries
|
||||
const assetAccounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, type: 'asset', code: { in: ['1010', '1020', '1030', '1040'] } },
|
||||
include: {
|
||||
journalLines: { select: { debit: true, credit: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const cashOnHand = assetAccounts.reduce(
|
||||
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.debit) - Number(l.credit), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const revenueAccounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, type: 'revenue' },
|
||||
include: {
|
||||
journalLines: {
|
||||
where: { journalEntry: { entryDate: { gte: monthStart } } },
|
||||
select: { credit: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const expenseAccounts = await this.prisma.chartOfAccount.findMany({
|
||||
where: { tenantId, type: 'expense' },
|
||||
include: {
|
||||
journalLines: {
|
||||
where: { journalEntry: { entryDate: { gte: monthStart } } },
|
||||
select: { debit: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const monthlyIncome = revenueAccounts.reduce(
|
||||
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.credit), 0),
|
||||
0,
|
||||
);
|
||||
const monthlyExpenses = expenseAccounts.reduce(
|
||||
(sum, acc) => sum + acc.journalLines.reduce((s, l) => s + Number(l.debit), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
cashOnHand,
|
||||
monthlyIncome,
|
||||
monthlyExpenses,
|
||||
netIncome: monthlyIncome - monthlyExpenses,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
src/employee/dto/create-employee.dto.ts
Normal file
13
src/employee/dto/create-employee.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsNumber, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateEmployeeDto {
|
||||
@IsString() @MinLength(1) firstName: string;
|
||||
@IsString() @MinLength(1) lastName: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsString() @MinLength(1) position: string;
|
||||
@IsOptional() @IsString() department?: string;
|
||||
@IsOptional() @IsNumber() salary?: number;
|
||||
@IsOptional() @IsUUID() userId?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
14
src/employee/dto/update-employee.dto.ts
Normal file
14
src/employee/dto/update-employee.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, MinLength, IsOptional, IsEmail, IsNumber, IsIn, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateEmployeeDto {
|
||||
@IsOptional() @IsString() @MinLength(1) firstName?: string;
|
||||
@IsOptional() @IsString() @MinLength(1) lastName?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() position?: string;
|
||||
@IsOptional() @IsString() department?: string;
|
||||
@IsOptional() @IsNumber() salary?: number;
|
||||
@IsOptional() @IsString() @IsIn(['active', 'on_leave', 'terminated']) status?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
@IsOptional() @IsUUID() userId?: string;
|
||||
}
|
||||
36
src/employee/employee.controller.ts
Normal file
36
src/employee/employee.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { EmployeeService } from './employee.service';
|
||||
import { CreateEmployeeDto } from './dto/create-employee.dto';
|
||||
import { UpdateEmployeeDto } from './dto/update-employee.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('employees')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class EmployeeController {
|
||||
constructor(private readonly employeeService: EmployeeService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.employeeService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.employeeService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateEmployeeDto) {
|
||||
return this.employeeService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdateEmployeeDto) {
|
||||
return this.employeeService.update(user.tenantId, id, dto);
|
||||
}
|
||||
}
|
||||
10
src/employee/employee.module.ts
Normal file
10
src/employee/employee.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeeController } from './employee.controller';
|
||||
import { EmployeeService } from './employee.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EmployeeController],
|
||||
providers: [EmployeeService],
|
||||
exports: [EmployeeService],
|
||||
})
|
||||
export class EmployeeModule {}
|
||||
57
src/employee/employee.service.ts
Normal file
57
src/employee/employee.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateEmployeeDto } from './dto/create-employee.dto';
|
||||
import { UpdateEmployeeDto } from './dto/update-employee.dto';
|
||||
|
||||
@Injectable()
|
||||
export class EmployeeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
return this.prisma.employee.findMany({
|
||||
where: { tenantId },
|
||||
include: { _count: { select: { assets: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const employee = await this.prisma.employee.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { assets: true },
|
||||
});
|
||||
if (!employee) throw new NotFoundException('Employee not found');
|
||||
return employee;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateEmployeeDto) {
|
||||
const count = await this.prisma.employee.count({ where: { tenantId } });
|
||||
const employeeNo = `E-${String(count + 1).padStart(4, '0')}`;
|
||||
|
||||
return this.prisma.employee.create({
|
||||
data: { tenantId, employeeNo, ...dto, salary: dto.salary || null },
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateEmployeeDto) {
|
||||
const existing = await this.prisma.employee.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) throw new NotFoundException('Employee not found');
|
||||
|
||||
return this.prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.firstName && { firstName: dto.firstName }),
|
||||
...(dto.lastName && { lastName: dto.lastName }),
|
||||
...(dto.email !== undefined && { email: dto.email }),
|
||||
...(dto.phone !== undefined && { phone: dto.phone }),
|
||||
...(dto.position && { position: dto.position }),
|
||||
...(dto.department !== undefined && { department: dto.department }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
...(dto.salary !== undefined && { salary: dto.salary }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
...(dto.userId !== undefined && { userId: dto.userId || null }),
|
||||
...(dto.status === 'terminated' && { terminatedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/expense/dto/create-expense.dto.ts
Normal file
9
src/expense/dto/create-expense.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateExpenseDto {
|
||||
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
|
||||
@IsString() @MinLength(3) description: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() expenseDate?: string;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
68
src/expense/expense.controller.ts
Normal file
68
src/expense/expense.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ExpenseService } from './expense.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.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';
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsIn, MinLength } from 'class-validator';
|
||||
|
||||
class CreateRecurringDto {
|
||||
@IsString() @IsIn(['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other']) category: string;
|
||||
@IsString() @MinLength(3) description: string;
|
||||
@IsNumber() @IsPositive() amount: number;
|
||||
@IsOptional() @IsString() @IsIn(['monthly', 'quarterly', 'yearly']) frequency?: string;
|
||||
}
|
||||
|
||||
@Controller('expenses')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class ExpenseController {
|
||||
constructor(private readonly expenseService: ExpenseService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload, @Query('status') status?: string, @Query('category') category?: string) {
|
||||
return this.expenseService.findAll(user.tenantId, { status, category });
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
async getSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.expenseService.getSummary(user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateExpenseDto) {
|
||||
return this.expenseService.create(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
async approve(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.approve(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
async reject(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.reject(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Get('recurring')
|
||||
async getRecurring(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.expenseService.getRecurring(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('recurring')
|
||||
async createRecurring(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreateRecurringDto) {
|
||||
return this.expenseService.createRecurring(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch('recurring/:id/toggle')
|
||||
async toggleRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.toggleRecurring(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Delete('recurring/:id')
|
||||
async deleteRecurring(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.expenseService.deleteRecurring(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
13
src/expense/expense.module.ts
Normal file
13
src/expense/expense.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExpenseController } from './expense.controller';
|
||||
import { ExpenseService } from './expense.service';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountingModule, NotificationModule],
|
||||
controllers: [ExpenseController],
|
||||
providers: [ExpenseService],
|
||||
exports: [ExpenseService],
|
||||
})
|
||||
export class ExpenseModule {}
|
||||
129
src/expense/expense.service.ts
Normal file
129
src/expense/expense.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { CreateExpenseDto } from './dto/create-expense.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ExpenseService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly journal: JournalService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { status?: string; category?: string }) {
|
||||
return this.prisma.expense.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(filters?.status && { status: filters.status }),
|
||||
...(filters?.category && { category: filters.category }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, createdById: string, dto: CreateExpenseDto) {
|
||||
return this.prisma.expense.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdById,
|
||||
category: dto.category,
|
||||
description: dto.description,
|
||||
amount: dto.amount,
|
||||
expenseDate: dto.expenseDate ? new Date(dto.expenseDate) : new Date(),
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async approve(tenantId: string, id: string, approvedById: string) {
|
||||
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
|
||||
if (!expense) throw new NotFoundException('Expense not found');
|
||||
if (expense.status !== 'pending') throw new BadRequestException(`Expense is already ${expense.status}`);
|
||||
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1 && expense.createdById === approvedById) {
|
||||
throw new ForbiddenException('Cannot approve your own expense');
|
||||
}
|
||||
|
||||
const result = await this.prisma.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'approved', approvedById, approvedAt: new Date() },
|
||||
});
|
||||
|
||||
// Journal entry: DR Expense Category, CR Cash on Hand
|
||||
this.journal.journalForExpense(tenantId, id, Number(expense.amount), expense.category).catch(() => {});
|
||||
|
||||
// Decrement Cash on Hand CompanyAccount
|
||||
this.journal.updateCompanyAccountBalance(tenantId, '1010', Number(expense.amount), 'decrement')
|
||||
.catch(() => {});
|
||||
|
||||
// Notify about expense approval
|
||||
this.notificationService.create(tenantId, {
|
||||
type: 'in_app',
|
||||
channel: 'billing_reminder',
|
||||
title: 'Expense Approved',
|
||||
message: `₱${Number(expense.amount).toLocaleString()} expense for "${expense.description}" has been approved`,
|
||||
}).catch(() => {});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async reject(tenantId: string, id: string, rejectedById: string) {
|
||||
const expense = await this.prisma.expense.findFirst({ where: { id, tenantId } });
|
||||
if (!expense) throw new NotFoundException('Expense not found');
|
||||
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1 && expense.createdById === rejectedById) {
|
||||
throw new ForbiddenException('Cannot reject your own expense');
|
||||
}
|
||||
|
||||
return this.prisma.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'rejected', approvedById: rejectedById, approvedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async getSummary(tenantId: string) {
|
||||
const [pending, approved, byCategory] = await Promise.all([
|
||||
this.prisma.expense.aggregate({ where: { tenantId, status: 'pending' }, _sum: { amount: true }, _count: true }),
|
||||
this.prisma.expense.aggregate({ where: { tenantId, status: 'approved' }, _sum: { amount: true }, _count: true }),
|
||||
this.prisma.expense.groupBy({ by: ['category'], where: { tenantId, status: 'approved' }, _sum: { amount: true } }),
|
||||
]);
|
||||
return {
|
||||
pending: { total: Number(pending._sum.amount || 0), count: pending._count },
|
||||
approved: { total: Number(approved._sum.amount || 0), count: approved._count },
|
||||
byCategory: byCategory.map((c) => ({ category: c.category, total: Number(c._sum.amount || 0) })),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Recurring Expenses ──────────────────────────────────
|
||||
|
||||
async getRecurring(tenantId: string) {
|
||||
return this.prisma.recurringExpense.findMany({ where: { tenantId }, orderBy: { nextRunDate: 'asc' } });
|
||||
}
|
||||
|
||||
async createRecurring(tenantId: string, data: { category: string; description: string; amount: number; frequency?: string }) {
|
||||
const nextRunDate = new Date();
|
||||
nextRunDate.setMonth(nextRunDate.getMonth() + 1);
|
||||
nextRunDate.setDate(1);
|
||||
|
||||
return this.prisma.recurringExpense.create({
|
||||
data: { tenantId, category: data.category, description: data.description, amount: data.amount, frequency: data.frequency || 'monthly', nextRunDate },
|
||||
});
|
||||
}
|
||||
|
||||
async toggleRecurring(tenantId: string, id: string) {
|
||||
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
|
||||
if (!rec) throw new NotFoundException('Not found');
|
||||
return this.prisma.recurringExpense.update({ where: { id }, data: { isActive: !rec.isActive } });
|
||||
}
|
||||
|
||||
async deleteRecurring(tenantId: string, id: string) {
|
||||
const rec = await this.prisma.recurringExpense.findFirst({ where: { id, tenantId } });
|
||||
if (!rec) throw new NotFoundException('Not found');
|
||||
await this.prisma.recurringExpense.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
32
src/health/health.controller.ts
Normal file
32
src/health/health.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
@SkipThrottle()
|
||||
export class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async check() {
|
||||
const dbHealthy = await this.checkDatabase();
|
||||
|
||||
return {
|
||||
status: dbHealthy ? 'ok' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
api: 'ok',
|
||||
database: dbHealthy ? 'ok' : 'down',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this.prisma.$queryRawUnsafe('SELECT 1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/health/health.module.ts
Normal file
7
src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
58
src/invoice/invoice.controller.ts
Normal file
58
src/invoice/invoice.controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { InvoiceService } from './invoice.service';
|
||||
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('invoices')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class InvoiceController {
|
||||
constructor(private readonly invoiceService: InvoiceService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('technician')
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('clientId') clientId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.invoiceService.findAll(user.tenantId, { clientId, status });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('technician')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.invoiceService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('generate/:clientId')
|
||||
@Roles('manager')
|
||||
async generate(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('clientId') clientId: string,
|
||||
) {
|
||||
return this.invoiceService.generateForClient(user.tenantId, clientId);
|
||||
}
|
||||
|
||||
@Patch(':id/void')
|
||||
@Roles('tenant_admin')
|
||||
async voidInvoice(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.invoiceService.voidInvoice(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
10
src/invoice/invoice.module.ts
Normal file
10
src/invoice/invoice.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvoiceController } from './invoice.controller';
|
||||
import { InvoiceService } from './invoice.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvoiceController],
|
||||
providers: [InvoiceService],
|
||||
exports: [InvoiceService],
|
||||
})
|
||||
export class InvoiceModule {}
|
||||
136
src/invoice/invoice.service.ts
Normal file
136
src/invoice/invoice.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; page?: number; limit?: number }) {
|
||||
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const where = {
|
||||
...(filters?.clientId && { clientId: filters.clientId }),
|
||||
...(filters?.status && { status: filters.status }),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
db.invoice.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
_count: { select: { payments: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
db.invoice.count({ where }),
|
||||
]);
|
||||
return paginatedResult(items, total, page, limit);
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const invoice = await db.invoice.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
payments: {
|
||||
include: {
|
||||
collectedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async generateForClient(tenantId: string, clientId: string) {
|
||||
const subscription = await this.prisma.subscription.findFirst({
|
||||
where: { clientId, tenantId, status: 'active' },
|
||||
include: { plan: true, client: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new BadRequestException('No active subscription for this client');
|
||||
}
|
||||
|
||||
const invoiceCount = await this.prisma.invoice.count({ where: { tenantId } });
|
||||
const now = new Date();
|
||||
const dueDate = new Date(now);
|
||||
dueDate.setDate(dueDate.getDate() + (subscription.plan.billingCycle || 30));
|
||||
|
||||
return this.prisma.invoice.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId,
|
||||
number: `INV-${String(invoiceCount + 1).padStart(6, '0')}`,
|
||||
amount: subscription.plan.price,
|
||||
balance: subscription.plan.price,
|
||||
status: 'sent',
|
||||
dueDate,
|
||||
periodStart: now,
|
||||
periodEnd: dueDate,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async voidInvoice(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const invoice = await db.invoice.findFirst({ where: { id } });
|
||||
|
||||
if (!invoice) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
if (invoice.status === 'paid') {
|
||||
throw new BadRequestException('Cannot void a paid invoice');
|
||||
}
|
||||
|
||||
return this.prisma.invoice.update({
|
||||
where: { id },
|
||||
data: { status: 'void' },
|
||||
});
|
||||
}
|
||||
|
||||
async applyPayment(tenantId: string, invoiceId: string, amount: number) {
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: invoiceId, tenantId },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
|
||||
const newBalance = Number(invoice.balance) - amount;
|
||||
|
||||
let newStatus = invoice.status;
|
||||
if (newBalance <= 0) {
|
||||
newStatus = 'paid';
|
||||
} else if (newBalance < Number(invoice.amount)) {
|
||||
newStatus = 'partial';
|
||||
}
|
||||
|
||||
return this.prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
balance: Math.max(0, newBalance),
|
||||
status: newStatus,
|
||||
...(newStatus === 'paid' && { paidAt: new Date() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
45
src/main.ts
Normal file
45
src/main.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
// Security headers
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false, // Handled by Next.js
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// CORS
|
||||
const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:3000,http://localhost:3002').split(',');
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Validation with sanitization
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.API_PORT || 3001;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`API running on http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
37
src/notification/notification.controller.ts
Normal file
37
src/notification/notification.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Patch, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('notifications')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard)
|
||||
export class NotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.notificationService.findForUser(user.tenantId, user.sub);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
async unreadCount(@CurrentUser() user: CurrentUserPayload) {
|
||||
const count = await this.notificationService.getUnreadCount(user.tenantId, user.sub);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
async markAsRead(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.notificationService.markAsRead(user.tenantId, user.sub, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
async markAllAsRead(@CurrentUser() user: CurrentUserPayload) {
|
||||
await this.notificationService.markAllAsRead(user.tenantId, user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
src/notification/notification.module.ts
Normal file
10
src/notification/notification.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
57
src/notification/notification.service.ts
Normal file
57
src/notification/notification.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findForUser(tenantId: string, userId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { tenantId, userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
async getUnreadCount(tenantId: string, userId: string) {
|
||||
return this.prisma.notification.count({
|
||||
where: { tenantId, userId, isRead: false },
|
||||
});
|
||||
}
|
||||
|
||||
async markAsRead(tenantId: string, userId: string, notificationId: string) {
|
||||
return this.prisma.notification.updateMany({
|
||||
where: { id: notificationId, tenantId, userId },
|
||||
data: { isRead: true },
|
||||
});
|
||||
}
|
||||
|
||||
async markAllAsRead(tenantId: string, userId: string) {
|
||||
return this.prisma.notification.updateMany({
|
||||
where: { tenantId, userId, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
}
|
||||
|
||||
async create(tenantId: string, data: {
|
||||
userId?: string;
|
||||
clientId?: string;
|
||||
type: string;
|
||||
channel: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}) {
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId: data.userId,
|
||||
clientId: data.clientId,
|
||||
type: data.type,
|
||||
channel: data.channel,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/payment/dto/create-remittance.dto.ts
Normal file
11
src/payment/dto/create-remittance.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateRemittanceDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
paymentIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
25
src/payment/dto/record-payment.dto.ts
Normal file
25
src/payment/dto/record-payment.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID, IsIn } from 'class-validator';
|
||||
|
||||
export class RecordPaymentDto {
|
||||
@IsUUID()
|
||||
clientId: string;
|
||||
|
||||
@IsUUID()
|
||||
invoiceId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['gcash', 'maya', 'cash', 'bank_transfer'])
|
||||
method: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
referenceNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
81
src/payment/payment.controller.ts
Normal file
81
src/payment/payment.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PaymentService } from './payment.service';
|
||||
import { RecordPaymentDto } from './dto/record-payment.dto';
|
||||
import { CreateRemittanceDto } from './dto/create-remittance.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('payments')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('technician')
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('clientId') clientId?: string,
|
||||
) {
|
||||
return this.paymentService.findAll(user.tenantId, { clientId });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('technician')
|
||||
async record(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: RecordPaymentDto,
|
||||
) {
|
||||
return this.paymentService.recordPayment(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Get('unremitted')
|
||||
@Roles('technician')
|
||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) {
|
||||
return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub);
|
||||
}
|
||||
|
||||
@Get('remittances')
|
||||
@Roles('technician')
|
||||
async findRemittances(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.paymentService.findRemittances(user.tenantId);
|
||||
}
|
||||
|
||||
@Post('remittances')
|
||||
@Roles('technician')
|
||||
async submitRemittance(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreateRemittanceDto,
|
||||
) {
|
||||
return this.paymentService.submitRemittance(user.tenantId, user.sub, dto);
|
||||
}
|
||||
|
||||
@Patch('remittances/:id/confirm')
|
||||
@Roles('manager')
|
||||
async confirmRemittance(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.paymentService.confirmRemittance(user.tenantId, id, user.sub);
|
||||
}
|
||||
|
||||
@Patch('remittances/:id/reject')
|
||||
@Roles('manager')
|
||||
async rejectRemittance(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
|
||||
}
|
||||
}
|
||||
15
src/payment/payment.module.ts
Normal file
15
src/payment/payment.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PaymentController } from './payment.controller';
|
||||
import { PaymentService } from './payment.service';
|
||||
import { InvoiceModule } from '../invoice/invoice.module';
|
||||
import { SubscriptionModule } from '../subscription/subscription.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [InvoiceModule, SubscriptionModule, AccountingModule, NotificationModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService],
|
||||
exports: [PaymentService],
|
||||
})
|
||||
export class PaymentModule {}
|
||||
249
src/payment/payment.service.ts
Normal file
249
src/payment/payment.service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { InvoiceService } from '../invoice/invoice.service';
|
||||
import { SubscriptionService } from '../subscription/subscription.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RecordPaymentDto } from './dto/record-payment.dto';
|
||||
import { CreateRemittanceDto } from './dto/create-remittance.dto';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly invoiceService: InvoiceService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly journal: JournalService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { clientId?: string }) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
return db.payment.findMany({
|
||||
where: {
|
||||
...(filters?.clientId && { clientId: filters.clientId }),
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
invoice: { select: { id: true, number: true, amount: true, status: true } },
|
||||
collectedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async recordPayment(tenantId: string, collectedById: string, dto: RecordPaymentDto) {
|
||||
// Verify client exists
|
||||
const client = await this.prisma.client.findFirst({
|
||||
where: { id: dto.clientId, tenantId },
|
||||
});
|
||||
if (!client) {
|
||||
throw new NotFoundException('Client not found');
|
||||
}
|
||||
|
||||
// Verify invoice exists and belongs to client
|
||||
const invoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: dto.invoiceId, tenantId, clientId: dto.clientId },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException('Invoice not found');
|
||||
}
|
||||
if (invoice.status === 'paid' || invoice.status === 'void') {
|
||||
throw new BadRequestException(`Invoice is already ${invoice.status}`);
|
||||
}
|
||||
|
||||
// Record payment
|
||||
const payment = await this.prisma.payment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId: dto.clientId,
|
||||
invoiceId: dto.invoiceId,
|
||||
collectedById,
|
||||
amount: dto.amount,
|
||||
method: dto.method,
|
||||
referenceNo: dto.referenceNo,
|
||||
notes: dto.notes,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true } },
|
||||
invoice: { select: { id: true, number: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log + Journal entry (fire-and-forget)
|
||||
this.audit.log({
|
||||
tenantId, userId: collectedById, action: 'payment.created', entity: 'payment', entityId: payment.id,
|
||||
details: { amount: dto.amount, method: dto.method, invoiceId: dto.invoiceId, clientId: dto.clientId },
|
||||
}).catch(() => {});
|
||||
|
||||
// Notify users about the payment
|
||||
this.notificationService.create(tenantId, {
|
||||
type: 'in_app',
|
||||
channel: 'payment_confirmation',
|
||||
title: 'Payment Received',
|
||||
message: `₱${Number(dto.amount).toLocaleString()} payment from ${payment.client?.firstName} ${payment.client?.lastName} (${dto.method})`,
|
||||
}).catch(() => {});
|
||||
|
||||
// Lookup collector name for journal
|
||||
const collector = await this.prisma.user.findUnique({ where: { id: collectedById } });
|
||||
this.journal.journalForPayment(tenantId, payment.id, dto.amount, dto.method, {
|
||||
invoiceNumber: payment.invoice?.number,
|
||||
clientName: payment.client ? `${payment.client.firstName} ${payment.client.lastName}` : undefined,
|
||||
collectorId: collectedById,
|
||||
collectorName: collector ? `${collector.firstName} ${collector.lastName}` : undefined,
|
||||
}).catch(() => {});
|
||||
|
||||
// Apply payment to invoice balance
|
||||
await this.invoiceService.applyPayment(tenantId, dto.invoiceId, dto.amount);
|
||||
|
||||
// Check if this is a prepaid first payment — trigger activation workflow
|
||||
const updatedInvoice = await this.prisma.invoice.findFirst({
|
||||
where: { id: dto.invoiceId },
|
||||
});
|
||||
if (updatedInvoice && updatedInvoice.status === 'paid') {
|
||||
await this.subscriptionService.handlePrepaidPayment(
|
||||
tenantId,
|
||||
dto.clientId,
|
||||
collectedById,
|
||||
);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
// ─── Remittance (custodial clearing) ─────────────────────────
|
||||
|
||||
async findRemittances(tenantId: string) {
|
||||
return this.prisma.remittance.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
collector: { select: { id: true, firstName: true, lastName: true } },
|
||||
confirmedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||
payments: true,
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getUnremittedPayments(tenantId: string, collectorId: string) {
|
||||
const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
|
||||
.map((r) => r.paymentId);
|
||||
|
||||
return this.prisma.payment.findMany({
|
||||
where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } },
|
||||
include: {
|
||||
client: { select: { firstName: true, lastName: true, accountNumber: true } },
|
||||
invoice: { select: { number: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async submitRemittance(tenantId: string, collectorId: string, dto: CreateRemittanceDto) {
|
||||
const payments = await this.prisma.payment.findMany({
|
||||
where: { id: { in: dto.paymentIds }, tenantId, collectedById: collectorId },
|
||||
});
|
||||
if (payments.length !== dto.paymentIds.length) {
|
||||
throw new BadRequestException('Some payments do not belong to you');
|
||||
}
|
||||
|
||||
const totalAmount = payments.reduce((s, p) => s + Number(p.amount), 0);
|
||||
|
||||
return this.prisma.remittance.create({
|
||||
data: {
|
||||
tenantId, collectorId, totalAmount, notes: dto.notes,
|
||||
payments: { create: dto.paymentIds.map((paymentId) => ({ paymentId })) },
|
||||
},
|
||||
include: { collector: { select: { id: true, firstName: true, lastName: true } }, payments: true },
|
||||
});
|
||||
}
|
||||
|
||||
async confirmRemittance(tenantId: string, remittanceId: string, confirmedById: string) {
|
||||
const remittance = await this.prisma.remittance.findFirst({
|
||||
where: { id: remittanceId, tenantId },
|
||||
include: { payments: true, collector: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
if (!remittance) throw new NotFoundException('Remittance not found');
|
||||
if (remittance.status !== 'pending') throw new BadRequestException(`Remittance is already ${remittance.status}`);
|
||||
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1 && remittance.collectorId === confirmedById) {
|
||||
throw new ForbiddenException('Collector cannot confirm their own remittance');
|
||||
}
|
||||
|
||||
const result = await this.prisma.remittance.update({
|
||||
where: { id: remittanceId },
|
||||
data: { status: 'confirmed', confirmedById, confirmedAt: new Date() },
|
||||
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
|
||||
});
|
||||
|
||||
this.audit.log({
|
||||
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
|
||||
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
|
||||
}).catch(() => {});
|
||||
|
||||
// Journal: clear collector custody → company accounts
|
||||
const linkedPayments = await this.prisma.payment.findMany({
|
||||
where: { id: { in: remittance.payments.map((p) => p.paymentId) } },
|
||||
});
|
||||
const byMethod: Record<string, number> = {};
|
||||
for (const p of linkedPayments) { byMethod[p.method] = (byMethod[p.method] || 0) + Number(p.amount); }
|
||||
const collectorName = `${remittance.collector.firstName} ${remittance.collector.lastName}`;
|
||||
|
||||
this.journal.journalForRemittance(
|
||||
tenantId, remittanceId, remittance.collectorId, collectorName,
|
||||
Object.entries(byMethod).map(([method, total]) => ({ method, total })),
|
||||
).catch((err) => this.logger.error(`Remittance journal failed: ${err.message}`, err.stack));
|
||||
|
||||
// Sync CompanyAccount balances for each payment method
|
||||
const METHOD_COA: Record<string, string> = {
|
||||
cash: '1010', gcash: '1020', maya: '1030', bank_transfer: '1040',
|
||||
};
|
||||
for (const [method, total] of Object.entries(byMethod)) {
|
||||
const coaCode = METHOD_COA[method];
|
||||
if (coaCode) {
|
||||
this.journal.updateCompanyAccountBalance(tenantId, coaCode, total, 'increment')
|
||||
.catch((err) => this.logger.error(`COH sync failed for ${method}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async rejectRemittance(tenantId: string, remittanceId: string, rejectedById: string) {
|
||||
const remittance = await this.prisma.remittance.findFirst({
|
||||
where: { id: remittanceId, tenantId },
|
||||
});
|
||||
|
||||
if (!remittance) {
|
||||
throw new NotFoundException('Remittance not found');
|
||||
}
|
||||
|
||||
if (remittance.collectorId === rejectedById) {
|
||||
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
|
||||
if (activeUserCount > 1) {
|
||||
throw new ForbiddenException('Collector cannot reject their own remittance');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.remittance.update({
|
||||
where: { id: remittanceId },
|
||||
data: {
|
||||
status: 'rejected',
|
||||
confirmedById: rejectedById,
|
||||
confirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
47
src/payroll/payroll.controller.ts
Normal file
47
src/payroll/payroll.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Post, Patch, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PayrollService } from './payroll.service';
|
||||
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';
|
||||
import { IsString, IsOptional, IsNumber, Min } from 'class-validator';
|
||||
|
||||
class CreatePayrollDto { @IsString() period: string; }
|
||||
class UpdatePayslipDto {
|
||||
@IsOptional() @IsNumber() @Min(0) deductions?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) bonuses?: number;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
@Controller('payroll')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('tenant_admin')
|
||||
export class PayrollController {
|
||||
constructor(private readonly payrollService: PayrollService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.payrollService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.payrollService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createRun(@CurrentUser() user: CurrentUserPayload, @Body() dto: CreatePayrollDto) {
|
||||
return this.payrollService.createRun(user.tenantId, dto.period);
|
||||
}
|
||||
|
||||
@Patch('payslips/:id')
|
||||
async updatePayslip(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string, @Body() dto: UpdatePayslipDto) {
|
||||
return this.payrollService.updatePayslip(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/process')
|
||||
async processRun(@CurrentUser() user: CurrentUserPayload, @Param('id') id: string) {
|
||||
return this.payrollService.processRun(user.tenantId, id, user.sub);
|
||||
}
|
||||
}
|
||||
12
src/payroll/payroll.module.ts
Normal file
12
src/payroll/payroll.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PayrollController } from './payroll.controller';
|
||||
import { PayrollService } from './payroll.service';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountingModule],
|
||||
controllers: [PayrollController],
|
||||
providers: [PayrollService],
|
||||
exports: [PayrollService],
|
||||
})
|
||||
export class PayrollModule {}
|
||||
120
src/payroll/payroll.service.ts
Normal file
120
src/payroll/payroll.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JournalService } from '../accounting/journal.service';
|
||||
|
||||
@Injectable()
|
||||
export class PayrollService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly journal: JournalService,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
return this.prisma.payrollRun.findMany({
|
||||
where: { tenantId },
|
||||
include: { _count: { select: { payslips: true } } },
|
||||
orderBy: { period: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const run = await this.prisma.payrollRun.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
payslips: {
|
||||
include: { employee: { select: { id: true, firstName: true, lastName: true, employeeNo: true, position: true } } },
|
||||
orderBy: { employee: { lastName: 'asc' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!run) throw new NotFoundException('Payroll run not found');
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Create a payroll run for a given period. Auto-generates payslips from active employees. */
|
||||
async createRun(tenantId: string, period: string) {
|
||||
const existing = await this.prisma.payrollRun.findFirst({ where: { tenantId, period } });
|
||||
if (existing) throw new BadRequestException(`Payroll for ${period} already exists`);
|
||||
|
||||
const employees = await this.prisma.employee.findMany({
|
||||
where: { tenantId, status: 'active', salary: { not: null } },
|
||||
});
|
||||
|
||||
if (employees.length === 0) throw new BadRequestException('No active employees with salary');
|
||||
|
||||
const totalAmount = employees.reduce((s, e) => s + Number(e.salary || 0), 0);
|
||||
|
||||
return this.prisma.payrollRun.create({
|
||||
data: {
|
||||
tenantId,
|
||||
period,
|
||||
totalAmount,
|
||||
payslips: {
|
||||
create: employees.map((e) => ({
|
||||
employeeId: e.id,
|
||||
baseSalary: e.salary!,
|
||||
netPay: e.salary!,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
payslips: {
|
||||
include: { employee: { select: { firstName: true, lastName: true, employeeNo: true } } },
|
||||
},
|
||||
_count: { select: { payslips: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a payslip (adjust deductions, bonuses) */
|
||||
async updatePayslip(tenantId: string, payslipId: string, data: { deductions?: number; bonuses?: number; notes?: string }) {
|
||||
const payslip = await this.prisma.payslip.findFirst({
|
||||
where: { id: payslipId },
|
||||
include: { payrollRun: true },
|
||||
});
|
||||
if (!payslip || payslip.payrollRun.tenantId !== tenantId) throw new NotFoundException('Payslip not found');
|
||||
if (payslip.payrollRun.status === 'completed') throw new BadRequestException('Cannot modify completed payroll');
|
||||
|
||||
const deductions = data.deductions ?? Number(payslip.deductions);
|
||||
const bonuses = data.bonuses ?? Number(payslip.bonuses);
|
||||
const netPay = Number(payslip.baseSalary) - deductions + bonuses;
|
||||
|
||||
return this.prisma.payslip.update({
|
||||
where: { id: payslipId },
|
||||
data: { deductions, bonuses, netPay, notes: data.notes ?? payslip.notes },
|
||||
});
|
||||
}
|
||||
|
||||
/** Process payroll — marks as completed + creates journal entries */
|
||||
async processRun(tenantId: string, id: string, processedBy: string) {
|
||||
const run = await this.prisma.payrollRun.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { payslips: { include: { employee: true } } },
|
||||
});
|
||||
if (!run) throw new NotFoundException('Payroll run not found');
|
||||
if (run.status === 'completed') throw new BadRequestException('Already completed');
|
||||
|
||||
// Recalculate total
|
||||
const totalAmount = run.payslips.reduce((s, p) => s + Number(p.netPay), 0);
|
||||
|
||||
const result = await this.prisma.payrollRun.update({
|
||||
where: { id },
|
||||
data: { status: 'completed', processedBy, processedAt: new Date(), totalAmount },
|
||||
include: { payslips: { include: { employee: { select: { firstName: true, lastName: true } } } } },
|
||||
});
|
||||
|
||||
// Mark all payslips as paid
|
||||
await this.prisma.payslip.updateMany({
|
||||
where: { payrollRunId: id },
|
||||
data: { status: 'paid' },
|
||||
});
|
||||
|
||||
// Journal entry: DR Salaries Expense (5020), CR Cash on Hand (1010)
|
||||
this.journal.createEntry(tenantId, `Payroll ${run.period} — ${run.payslips.length} employees`, [
|
||||
{ accountCode: '5020', debit: totalAmount },
|
||||
{ accountCode: '1010', credit: totalAmount },
|
||||
], { reference: `PAY-${run.period}`, sourceType: 'payroll', sourceId: id }).catch(() => {});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
28
src/plan/dto/create-plan.dto.ts
Normal file
28
src/plan/dto/create-plan.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsPositive, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class CreatePlanDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
speedDown: number;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
speedUp: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
price: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
billingCycle?: number;
|
||||
}
|
||||
36
src/plan/dto/update-plan.dto.ts
Normal file
36
src/plan/dto/update-plan.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsString, MinLength, IsOptional, IsNumber, IsPositive, IsInt, Min, IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdatePlanDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
speedDown?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
speedUp?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
billingCycle?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
63
src/plan/plan.controller.ts
Normal file
63
src/plan/plan.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PlanService } from './plan.service';
|
||||
import { CreatePlanDto } from './dto/create-plan.dto';
|
||||
import { UpdatePlanDto } from './dto/update-plan.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('plans')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class PlanController {
|
||||
constructor(private readonly planService: PlanService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.planService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.planService.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Body() dto: CreatePlanDto,
|
||||
) {
|
||||
return this.planService.create(user.tenantId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePlanDto,
|
||||
) {
|
||||
return this.planService.update(user.tenantId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.planService.remove(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
10
src/plan/plan.module.ts
Normal file
10
src/plan/plan.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlanController } from './plan.controller';
|
||||
import { PlanService } from './plan.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PlanController],
|
||||
providers: [PlanService],
|
||||
exports: [PlanService],
|
||||
})
|
||||
export class PlanModule {}
|
||||
113
src/plan/plan.service.ts
Normal file
113
src/plan/plan.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePlanDto } from './dto/create-plan.dto';
|
||||
import { UpdatePlanDto } from './dto/update-plan.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PlanService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
return db.plan.findMany({
|
||||
orderBy: { price: 'asc' },
|
||||
include: {
|
||||
_count: { select: { subscriptions: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const plan = await db.plan.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { subscriptions: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreatePlanDto) {
|
||||
const existing = await this.prisma.plan.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Plan name already exists');
|
||||
}
|
||||
|
||||
return this.prisma.plan.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
speedDown: dto.speedDown,
|
||||
speedUp: dto.speedUp,
|
||||
price: dto.price,
|
||||
billingCycle: dto.billingCycle ?? 30,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdatePlanDto) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.plan.findFirst({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
if (dto.name && dto.name !== existing.name) {
|
||||
const duplicate = await this.prisma.plan.findFirst({
|
||||
where: { tenantId, name: dto.name },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException('Plan name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.plan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.speedDown !== undefined && { speedDown: dto.speedDown }),
|
||||
...(dto.speedUp !== undefined && { speedUp: dto.speedUp }),
|
||||
...(dto.price !== undefined && { price: dto.price }),
|
||||
...(dto.billingCycle !== undefined && { billingCycle: dto.billingCycle }),
|
||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const existing = await db.plan.findFirst({
|
||||
where: { id },
|
||||
include: { _count: { select: { subscriptions: true } } },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Plan not found');
|
||||
}
|
||||
|
||||
if (existing._count.subscriptions > 0) {
|
||||
throw new ConflictException(
|
||||
'Cannot delete plan with active subscriptions. Deactivate it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.plan.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
11
src/portal/dto/create-portal-ticket.dto.ts
Normal file
11
src/portal/dto/create-portal-ticket.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreatePortalTicketDto {
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
11
src/portal/dto/portal-login.dto.ts
Normal file
11
src/portal/dto/portal-login.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class PortalLoginDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
accountNumber: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
phone: string;
|
||||
}
|
||||
22
src/portal/portal-jwt.strategy.ts
Normal file
22
src/portal/portal-jwt.strategy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class PortalJwtStrategy extends PassportStrategy(Strategy, 'portal-jwt') {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: any) {
|
||||
if (payload.type !== 'portal') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
return { sub: payload.sub, tenantId: payload.tenantId, type: 'portal' };
|
||||
}
|
||||
}
|
||||
59
src/portal/portal.controller.ts
Normal file
59
src/portal/portal.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, Post, Body, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { PortalService } from './portal.service';
|
||||
import { PortalLoginDto } from './dto/portal-login.dto';
|
||||
import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto';
|
||||
|
||||
@Controller('portal')
|
||||
export class PortalController {
|
||||
constructor(private readonly portalService: PortalService) {}
|
||||
|
||||
@Post('auth/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() dto: PortalLoginDto) {
|
||||
return this.portalService.login(dto);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getProfile(@Req() req: any) {
|
||||
return this.portalService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getDashboard(@Req() req: any) {
|
||||
return this.portalService.getDashboard(req.user.sub, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('subscription')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getSubscription(@Req() req: any) {
|
||||
return this.portalService.getSubscription(req.user.sub, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('invoices')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getInvoices(@Req() req: any) {
|
||||
return this.portalService.getInvoices(req.user.sub, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('payments')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getPayments(@Req() req: any) {
|
||||
return this.portalService.getPayments(req.user.sub, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Get('tickets')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async getTickets(@Req() req: any) {
|
||||
return this.portalService.getTickets(req.user.sub, req.user.tenantId);
|
||||
}
|
||||
|
||||
@Post('tickets')
|
||||
@UseGuards(AuthGuard('portal-jwt'))
|
||||
async createTicket(@Req() req: any, @Body() dto: CreatePortalTicketDto) {
|
||||
return this.portalService.createTicket(req.user.sub, req.user.tenantId, dto);
|
||||
}
|
||||
}
|
||||
21
src/portal/portal.module.ts
Normal file
21
src/portal/portal.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PortalController } from './portal.controller';
|
||||
import { PortalService } from './portal.service';
|
||||
import { PortalJwtStrategy } from './portal-jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '24h' as any },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [PortalController],
|
||||
providers: [PortalService, PortalJwtStrategy],
|
||||
})
|
||||
export class PortalModule {}
|
||||
132
src/portal/portal.service.ts
Normal file
132
src/portal/portal.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, UnauthorizedException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PortalLoginDto } from './dto/portal-login.dto';
|
||||
import { CreatePortalTicketDto } from './dto/create-portal-ticket.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PortalService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: PortalLoginDto) {
|
||||
const client = await this.prisma.client.findFirst({
|
||||
where: {
|
||||
accountNumber: dto.accountNumber,
|
||||
phone: dto.phone,
|
||||
status: 'active',
|
||||
},
|
||||
include: { area: { select: { name: true } } },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new UnauthorizedException('Invalid account number or phone number');
|
||||
}
|
||||
|
||||
const token = this.jwt.sign({
|
||||
sub: client.id,
|
||||
tenantId: client.tenantId,
|
||||
type: 'portal',
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
client: {
|
||||
id: client.id,
|
||||
accountNumber: client.accountNumber,
|
||||
firstName: client.firstName,
|
||||
lastName: client.lastName,
|
||||
email: client.email,
|
||||
phone: client.phone,
|
||||
address: client.address,
|
||||
area: client.area?.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(clientId: string) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { id: clientId },
|
||||
include: {
|
||||
area: { select: { name: true } },
|
||||
subscriptions: {
|
||||
where: { status: { in: ['active', 'pending'] } },
|
||||
include: { plan: { select: { name: true, price: true, speedDown: true, speedUp: true, billingCycle: true } } },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!client) throw new NotFoundException('Client not found');
|
||||
return client;
|
||||
}
|
||||
|
||||
async getSubscription(clientId: string, tenantId: string) {
|
||||
return this.prisma.subscription.findFirst({
|
||||
where: { clientId, tenantId, status: { in: ['active', 'pending', 'suspended'] } },
|
||||
include: { plan: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getInvoices(clientId: string, tenantId: string) {
|
||||
return this.prisma.invoice.findMany({
|
||||
where: { clientId, tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
|
||||
async getPayments(clientId: string, tenantId: string) {
|
||||
return this.prisma.payment.findMany({
|
||||
where: { clientId, tenantId },
|
||||
include: { invoice: { select: { number: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
|
||||
async getTickets(clientId: string, tenantId: string) {
|
||||
return this.prisma.ticket.findMany({
|
||||
where: { clientId, tenantId },
|
||||
include: { assignee: { select: { firstName: true, lastName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
|
||||
async createTicket(clientId: string, tenantId: string, dto: CreatePortalTicketDto) {
|
||||
// Find an admin user to attribute as creator
|
||||
const admin = await this.prisma.user.findFirst({
|
||||
where: { tenantId },
|
||||
include: { roles: { where: { role: 'tenant_admin' } } },
|
||||
});
|
||||
|
||||
return this.prisma.ticket.create({
|
||||
data: {
|
||||
tenantId,
|
||||
clientId,
|
||||
createdById: admin?.id || '',
|
||||
type: 'support',
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
priority: 'normal',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDashboard(clientId: string, tenantId: string) {
|
||||
const [subscription, unpaidInvoices, recentPayment, openTickets] = await Promise.all([
|
||||
this.prisma.subscription.findFirst({
|
||||
where: { clientId, tenantId, status: 'active' },
|
||||
include: { plan: { select: { name: true, speedDown: true, speedUp: true, price: true } } },
|
||||
}),
|
||||
this.prisma.invoice.count({ where: { clientId, tenantId, status: { in: ['sent', 'partial'] } } }),
|
||||
this.prisma.payment.findFirst({ where: { clientId, tenantId }, orderBy: { createdAt: 'desc' } }),
|
||||
this.prisma.ticket.count({ where: { clientId, tenantId, status: { in: ['open', 'in_progress'] } } }),
|
||||
]);
|
||||
|
||||
return { subscription, unpaidInvoices, recentPayment, openTickets };
|
||||
}
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
161
src/prisma/prisma.service.ts
Normal file
161
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Models that require tenant scoping
|
||||
const TENANT_SCOPED_MODELS = [
|
||||
'User',
|
||||
'Area',
|
||||
'Plan',
|
||||
'Client',
|
||||
'Subscription',
|
||||
'Invoice',
|
||||
'Payment',
|
||||
'Remittance',
|
||||
'Ticket',
|
||||
'Notification',
|
||||
];
|
||||
|
||||
// Models that have a deletedAt column for soft deletes
|
||||
const SOFT_DELETE_MODELS = [
|
||||
'Tenant',
|
||||
'User',
|
||||
'Area',
|
||||
'Plan',
|
||||
'Client',
|
||||
'Subscription',
|
||||
'Invoice',
|
||||
'Payment',
|
||||
'Remittance',
|
||||
'Ticket',
|
||||
'Employee',
|
||||
'PayrollRun',
|
||||
'RecurringExpense',
|
||||
'Expense',
|
||||
'CompanyAccount',
|
||||
'Asset',
|
||||
'ChartOfAccount',
|
||||
'TenantRole',
|
||||
];
|
||||
|
||||
function isSoftDeleteModel(model: string): boolean {
|
||||
return SOFT_DELETE_MODELS.includes(model);
|
||||
}
|
||||
|
||||
function addDeletedAtFilter(args: any): any {
|
||||
if (!args) args = {};
|
||||
if (!args.where) args.where = {};
|
||||
if (args.where.deletedAt === undefined) {
|
||||
args.where.deletedAt = null;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a scoped Prisma client that automatically:
|
||||
* 1. Filters all queries on tenant-scoped models by tenantId
|
||||
* 2. Applies soft-delete filtering (excludes deletedAt != null)
|
||||
* 3. Converts delete → soft delete for soft-delete-enabled models
|
||||
*/
|
||||
forTenant(tenantId: string) {
|
||||
return this.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async findMany({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model)) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
}
|
||||
if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args);
|
||||
return query(args);
|
||||
},
|
||||
async findFirst({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model)) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
}
|
||||
if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args);
|
||||
return query(args);
|
||||
},
|
||||
async findUnique({ model, args, query }) {
|
||||
const result = await query(args);
|
||||
if (
|
||||
TENANT_SCOPED_MODELS.includes(model) &&
|
||||
result &&
|
||||
'tenantId' in result &&
|
||||
(result as any).tenantId !== tenantId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (isSoftDeleteModel(model) && result && 'deletedAt' in result && result.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async create({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model)) {
|
||||
args.data = { ...args.data, tenantId } as any;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
async update({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model) && args.where) {
|
||||
args.where = { ...args.where, tenantId } as any;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
async delete({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model) && args.where) {
|
||||
args.where = { ...args.where, tenantId } as any;
|
||||
}
|
||||
// Soft delete: convert to update
|
||||
if (isSoftDeleteModel(model)) {
|
||||
const modelKey = model[0].toLowerCase() + model.slice(1);
|
||||
return (this as any)[modelKey].update({
|
||||
where: args.where,
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
async deleteMany({ model, args, query }) {
|
||||
// Soft delete: convert to updateMany
|
||||
if (isSoftDeleteModel(model)) {
|
||||
const modelKey = model[0].toLowerCase() + model.slice(1);
|
||||
return (this as any)[modelKey].updateMany({
|
||||
where: args.where,
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
async count({ model, args, query }) {
|
||||
if (TENANT_SCOPED_MODELS.includes(model)) {
|
||||
args.where = { ...args.where, tenantId };
|
||||
}
|
||||
if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args);
|
||||
return query(args);
|
||||
},
|
||||
async aggregate({ model, args, query }) {
|
||||
if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args);
|
||||
return query(args);
|
||||
},
|
||||
async groupBy({ model, args, query }) {
|
||||
if (isSoftDeleteModel(model)) args = addDeletedAtFilter(args);
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
60
src/report/report.controller.ts
Normal file
60
src/report/report.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ReportService } from './report.service';
|
||||
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('reports')
|
||||
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||
@Roles('manager')
|
||||
export class ReportController {
|
||||
constructor(private readonly reportService: ReportService) {}
|
||||
|
||||
@Get()
|
||||
async index() {
|
||||
return {
|
||||
available: [
|
||||
{ key: 'collections', label: 'Collection Report', path: '/reports/collections' },
|
||||
{ key: 'subscribers', label: 'Subscriber List', path: '/reports/subscribers' },
|
||||
{ key: 'aging', label: 'Aging Report', path: '/reports/aging' },
|
||||
{ key: 'plan-distribution', label: 'Plan Distribution', path: '/reports/plan-distribution' },
|
||||
{ key: 'expenses', label: 'Expense Report', path: '/reports/expenses' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Get('collections')
|
||||
async collections(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.reportService.getCollectionReport(user.tenantId, from, to);
|
||||
}
|
||||
|
||||
@Get('subscribers')
|
||||
async subscribers(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.reportService.getSubscriberList(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('aging')
|
||||
async aging(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.reportService.getAgingReport(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('plan-distribution')
|
||||
async planDistribution(@CurrentUser() user: CurrentUserPayload) {
|
||||
return this.reportService.getPlanDistribution(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('expenses')
|
||||
async expenses(
|
||||
@CurrentUser() user: CurrentUserPayload,
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
return this.reportService.getExpenseReport(user.tenantId, from, to);
|
||||
}
|
||||
}
|
||||
9
src/report/report.module.ts
Normal file
9
src/report/report.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportController } from './report.controller';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportController],
|
||||
providers: [ReportService],
|
||||
})
|
||||
export class ReportModule {}
|
||||
131
src/report/report.service.ts
Normal file
131
src/report/report.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCollectionReport(tenantId: string, from?: string, to?: string) {
|
||||
const dateFrom = from ? new Date(from) : new Date(new Date().setDate(1));
|
||||
const dateTo = to ? new Date(to) : new Date();
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
const payments = await this.prisma.payment.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
},
|
||||
include: {
|
||||
client: { select: { firstName: true, lastName: true, accountNumber: true } },
|
||||
invoice: { select: { number: true } },
|
||||
collectedBy: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const total = payments.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||
const byMethod: Record<string, number> = {};
|
||||
for (const p of payments) {
|
||||
byMethod[p.method] = (byMethod[p.method] || 0) + Number(p.amount);
|
||||
}
|
||||
|
||||
return { payments, summary: { total, count: payments.length, byMethod } };
|
||||
}
|
||||
|
||||
async getSubscriberList(tenantId: string) {
|
||||
return this.prisma.client.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
area: { select: { name: true } },
|
||||
subscriptions: {
|
||||
where: { status: { in: ['active', 'pending'] } },
|
||||
include: { plan: { select: { name: true, price: true, speedDown: true, speedUp: true } } },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: { accountNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getAgingReport(tenantId: string) {
|
||||
const now = new Date();
|
||||
const invoices = await this.prisma.invoice.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: { in: ['sent', 'partial'] },
|
||||
dueDate: { lt: now },
|
||||
},
|
||||
include: {
|
||||
client: { select: { firstName: true, lastName: true, accountNumber: true } },
|
||||
},
|
||||
orderBy: { dueDate: 'asc' },
|
||||
});
|
||||
|
||||
const buckets = { current: [] as any[], days30: [] as any[], days60: [] as any[], days90: [] as any[] };
|
||||
for (const inv of invoices) {
|
||||
const daysOverdue = Math.floor((now.getTime() - new Date(inv.dueDate).getTime()) / 86400000);
|
||||
const entry = { ...inv, daysOverdue };
|
||||
if (daysOverdue <= 30) buckets.current.push(entry);
|
||||
else if (daysOverdue <= 60) buckets.days30.push(entry);
|
||||
else if (daysOverdue <= 90) buckets.days60.push(entry);
|
||||
else buckets.days90.push(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
buckets,
|
||||
summary: {
|
||||
current: buckets.current.reduce((s, i) => s + Number(i.balance), 0),
|
||||
days30: buckets.days30.reduce((s, i) => s + Number(i.balance), 0),
|
||||
days60: buckets.days60.reduce((s, i) => s + Number(i.balance), 0),
|
||||
days90Plus: buckets.days90.reduce((s, i) => s + Number(i.balance), 0),
|
||||
total: invoices.reduce((s, i) => s + Number(i.balance), 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getExpenseReport(tenantId: string, from?: string, to?: string) {
|
||||
const dateFrom = from ? new Date(from) : new Date(new Date().setDate(1));
|
||||
const dateTo = to ? new Date(to) : new Date();
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
const expenses = await this.prisma.expense.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
expenseDate: { gte: dateFrom, lte: dateTo },
|
||||
},
|
||||
orderBy: { expenseDate: 'desc' },
|
||||
});
|
||||
|
||||
const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0);
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const e of expenses) {
|
||||
byCategory[e.category] = (byCategory[e.category] || 0) + Number(e.amount);
|
||||
}
|
||||
|
||||
return { expenses, summary: { total, count: expenses.length, byCategory } };
|
||||
}
|
||||
|
||||
async getPlanDistribution(tenantId: string) {
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
_count: { select: { subscriptions: true } },
|
||||
subscriptions: {
|
||||
where: { status: 'active' },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { price: 'asc' },
|
||||
});
|
||||
|
||||
return plans.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: Number(p.price),
|
||||
speedDown: p.speedDown,
|
||||
speedUp: p.speedUp,
|
||||
totalSubscriptions: p._count.subscriptions,
|
||||
activeSubscriptions: p.subscriptions.length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
39
src/role/dto/create-role.dto.ts
Normal file
39
src/role/dto/create-role.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { IsString, IsOptional, IsArray, ValidateNested, IsBoolean } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PermissionRowDto {
|
||||
@IsString()
|
||||
module: string;
|
||||
|
||||
@IsBoolean()
|
||||
canView: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
canCreate: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
canUpdate: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
canArchive: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
canApprove: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PermissionRowDto)
|
||||
permissions: PermissionRowDto[];
|
||||
}
|
||||
23
src/role/dto/update-role.dto.ts
Normal file
23
src/role/dto/update-role.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsOptional, IsArray, ValidateNested, IsBoolean } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PermissionRowDto } from './create-role.dto';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PermissionRowDto)
|
||||
permissions?: PermissionRowDto[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user