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