initial: standalone repo from monorepo split

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

View File

@@ -0,0 +1,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);
}
}

View 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 {}

View 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' },
});
}
}

View 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;
}

View 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;
}

View 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;
}