Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
46
backend/src/modules/accounts/accounts.controller.ts
Normal file
46
backend/src/modules/accounts/accounts.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Controller, Get, Post, Put, Body, Param, Query, UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AccountsService } from './accounts.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
|
||||
@ApiTags('accounts')
|
||||
@Controller('accounts')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AccountsController {
|
||||
constructor(private accountsService: AccountsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all accounts' })
|
||||
findAll(@Query('fundType') fundType?: string) {
|
||||
return this.accountsService.findAll(fundType);
|
||||
}
|
||||
|
||||
@Get('trial-balance')
|
||||
@ApiOperation({ summary: 'Get trial balance' })
|
||||
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
||||
return this.accountsService.getTrialBalance(asOfDate);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get account by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.accountsService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new account' })
|
||||
create(@Body() dto: CreateAccountDto) {
|
||||
return this.accountsService.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update an account' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||
return this.accountsService.update(id, dto);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/accounts/accounts.module.ts
Normal file
10
backend/src/modules/accounts/accounts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountsController } from './accounts.controller';
|
||||
import { AccountsService } from './accounts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountsController],
|
||||
providers: [AccountsService],
|
||||
exports: [AccountsService],
|
||||
})
|
||||
export class AccountsModule {}
|
||||
107
backend/src/modules/accounts/accounts.service.ts
Normal file
107
backend/src/modules/accounts/accounts.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { CreateAccountDto } from './dto/create-account.dto';
|
||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll(fundType?: string) {
|
||||
let sql = 'SELECT * FROM accounts WHERE is_active = true';
|
||||
const params: any[] = [];
|
||||
if (fundType) {
|
||||
sql += ' AND fund_type = $1';
|
||||
params.push(fundType);
|
||||
}
|
||||
sql += ' ORDER BY account_number';
|
||||
return this.tenant.query(sql, params);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM accounts WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Account not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: CreateAccountDto) {
|
||||
const existing = await this.tenant.query(
|
||||
'SELECT id FROM accounts WHERE account_number = $1',
|
||||
[dto.accountNumber],
|
||||
);
|
||||
if (existing.length) {
|
||||
throw new BadRequestException(`Account number ${dto.accountNumber} already exists`);
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.accountNumber,
|
||||
dto.name,
|
||||
dto.description || null,
|
||||
dto.accountType,
|
||||
dto.fundType,
|
||||
dto.parentAccountId || null,
|
||||
dto.is1099Reportable || false,
|
||||
],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAccountDto) {
|
||||
const account = await this.findOne(id);
|
||||
if (account.is_system && dto.accountType && dto.accountType !== account.account_type) {
|
||||
throw new BadRequestException('Cannot change type of system account');
|
||||
}
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
|
||||
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
|
||||
if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); }
|
||||
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
|
||||
|
||||
if (!sets.length) return account;
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
params,
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async getTrialBalance(asOfDate?: string) {
|
||||
const dateFilter = asOfDate
|
||||
? `AND je.entry_date <= $1`
|
||||
: '';
|
||||
const params = asOfDate ? [asOfDate] : [];
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
COALESCE(SUM(jel.debit), 0) as total_debits,
|
||||
COALESCE(SUM(jel.credit), 0) as total_credits,
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
${dateFilter}
|
||||
WHERE a.is_active = true
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
ORDER BY a.account_number
|
||||
`;
|
||||
return this.tenant.query(sql, params);
|
||||
}
|
||||
}
|
||||
35
backend/src/modules/accounts/dto/create-account.dto.ts
Normal file
35
backend/src/modules/accounts/dto/create-account.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IsString, IsInt, IsOptional, IsBoolean, IsIn, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@ApiProperty({ example: 6600 })
|
||||
@IsInt()
|
||||
accountNumber: number;
|
||||
|
||||
@ApiProperty({ example: 'Equipment Repairs' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: 'expense', enum: ['asset', 'liability', 'equity', 'income', 'expense'] })
|
||||
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
|
||||
accountType: string;
|
||||
|
||||
@ApiProperty({ example: 'operating', enum: ['operating', 'reserve'] })
|
||||
@IsIn(['operating', 'reserve'])
|
||||
fundType: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
parentAccountId?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is1099Reportable?: boolean;
|
||||
}
|
||||
29
backend/src/modules/accounts/dto/update-account.dto.ts
Normal file
29
backend/src/modules/accounts/dto/update-account.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsIn(['asset', 'liability', 'equity', 'income', 'expense'])
|
||||
@IsOptional()
|
||||
accountType?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is1099Reportable?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
50
backend/src/modules/auth/auth.controller.ts
Normal file
50
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Get,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('switch-org')
|
||||
@ApiOperation({ summary: 'Switch active organization' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
|
||||
}
|
||||
}
|
||||
28
backend/src/modules/auth/auth.module.ts
Normal file
28
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
136
backend/src/modules/auth/auth.service.ts
Normal file
136
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.usersService.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||
const user = await this.usersService.create({
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
});
|
||||
|
||||
return this.generateTokenResponse(user);
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string): Promise<User> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: User) {
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||
return this.generateTokenResponse(fullUser || user);
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
organizations: user.userOrganizations?.map((uo) => ({
|
||||
id: uo.organization.id,
|
||||
name: uo.organization.name,
|
||||
role: uo.role,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
async switchOrganization(userId: string, organizationId: string) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const membership = user.userOrganizations?.find(
|
||||
(uo) => uo.organizationId === organizationId && uo.isActive,
|
||||
);
|
||||
if (!membership) {
|
||||
throw new UnauthorizedException('Not a member of this organization');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
orgId: membership.organizationId,
|
||||
orgSchema: membership.organization.schemaName,
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
organization: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
role: membership.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateTokenResponse(user: User) {
|
||||
const orgs = user.userOrganizations || [];
|
||||
const defaultOrg = orgs[0];
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
if (defaultOrg) {
|
||||
payload.orgId = defaultOrg.organizationId;
|
||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||
payload.role = defaultOrg.role;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
name: uo.organization?.name,
|
||||
schemaName: uo.organization?.schemaName,
|
||||
role: uo.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
backend/src/modules/auth/dto/login.dto.ts
Normal file
12
backend/src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
23
backend/src/modules/auth/dto/register.dto.ts
Normal file
23
backend/src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'Jane', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ example: 'Doe', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
}
|
||||
8
backend/src/modules/auth/dto/switch-org.dto.ts
Normal file
8
backend/src/modules/auth/dto/switch-org.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SwitchOrgDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
@IsUUID()
|
||||
organizationId: string;
|
||||
}
|
||||
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
25
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
25
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
orgId: payload.orgId,
|
||||
orgSchema: payload.orgSchema,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/auth/strategies/local.strategy.ts
Normal file
15
backend/src/modules/auth/strategies/local.strategy.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({ usernameField: 'email' });
|
||||
}
|
||||
|
||||
async validate(email: string, password: string) {
|
||||
return this.authService.validateUser(email, password);
|
||||
}
|
||||
}
|
||||
37
backend/src/modules/budgets/budgets.controller.ts
Normal file
37
backend/src/modules/budgets/budgets.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Put, Body, Param, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BudgetsService } from './budgets.service';
|
||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||
|
||||
@ApiTags('budgets')
|
||||
@Controller('budgets')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BudgetsController {
|
||||
constructor(private budgetsService: BudgetsService) {}
|
||||
|
||||
@Get(':year')
|
||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||
return this.budgetsService.findByYear(year);
|
||||
}
|
||||
|
||||
@Put(':year')
|
||||
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
||||
upsert(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Body() budgets: UpsertBudgetDto[],
|
||||
) {
|
||||
return this.budgetsService.upsert(year, budgets);
|
||||
}
|
||||
|
||||
@Get(':year/vs-actual')
|
||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||
budgetVsActual(
|
||||
@Param('year', ParseIntPipe) year: number,
|
||||
@Query('month') month?: string,
|
||||
) {
|
||||
return this.budgetsService.getBudgetVsActual(year, month ? parseInt(month) : undefined);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/budgets/budgets.module.ts
Normal file
10
backend/src/modules/budgets/budgets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BudgetsController } from './budgets.controller';
|
||||
import { BudgetsService } from './budgets.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BudgetsController],
|
||||
providers: [BudgetsService],
|
||||
exports: [BudgetsService],
|
||||
})
|
||||
export class BudgetsModule {}
|
||||
103
backend/src/modules/budgets/budgets.service.ts
Normal file
103
backend/src/modules/budgets/budgets.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findByYear(year: number) {
|
||||
return this.tenant.query(
|
||||
`SELECT b.*, a.account_number, a.name as account_name, a.account_type
|
||||
FROM budgets b
|
||||
JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1
|
||||
ORDER BY a.account_number`,
|
||||
[year],
|
||||
);
|
||||
}
|
||||
|
||||
async upsert(year: number, budgets: UpsertBudgetDto[]) {
|
||||
const results = [];
|
||||
for (const b of budgets) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
ON CONFLICT (fiscal_year, account_id, fund_type)
|
||||
DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, notes=$16
|
||||
RETURNING *`,
|
||||
[
|
||||
year, b.accountId, b.fundType,
|
||||
b.jan || 0, b.feb || 0, b.mar || 0, b.apr || 0,
|
||||
b.may || 0, b.jun || 0, b.jul || 0, b.aug || 0,
|
||||
b.sep || 0, b.oct || 0, b.nov || 0, b.dec || 0,
|
||||
b.notes || null,
|
||||
],
|
||||
);
|
||||
results.push(rows[0]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async getBudgetVsActual(year: number, month?: number) {
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
|
||||
let budgetMonthCols: string;
|
||||
let actualDateFilter: string;
|
||||
const params: any[] = [year];
|
||||
|
||||
if (month) {
|
||||
budgetMonthCols = `b.${monthNames[month - 1]} as budget_amount`;
|
||||
actualDateFilter = `AND EXTRACT(MONTH FROM je.entry_date) = $2`;
|
||||
params.push(month);
|
||||
} else {
|
||||
budgetMonthCols = `(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as budget_amount`;
|
||||
actualDateFilter = '';
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT
|
||||
a.id as account_id, a.account_number, a.name as account_name,
|
||||
a.account_type, a.fund_type,
|
||||
${budgetMonthCols},
|
||||
COALESCE(SUM(
|
||||
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
||||
ELSE jel.credit - jel.debit END
|
||||
), 0) as actual_amount
|
||||
FROM accounts a
|
||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
${actualDateFilter}
|
||||
WHERE a.is_active = true
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
ORDER BY a.account_number`,
|
||||
params,
|
||||
);
|
||||
|
||||
// Compute variance fields
|
||||
const lines = rows.map((r: any) => {
|
||||
const budget = parseFloat(r.budget_amount || '0');
|
||||
const actual = parseFloat(r.actual_amount || '0');
|
||||
const variance = actual - budget;
|
||||
const variancePct = budget !== 0 ? (variance / budget) * 100 : 0;
|
||||
return { ...r, budget_amount: budget, actual_amount: actual, variance, variance_pct: variancePct };
|
||||
});
|
||||
|
||||
const incomeLines = lines.filter((l: any) => l.account_type === 'income');
|
||||
const expenseLines = lines.filter((l: any) => l.account_type === 'expense');
|
||||
|
||||
return {
|
||||
year,
|
||||
lines,
|
||||
total_income_budget: incomeLines.reduce((s: number, l: any) => s + l.budget_amount, 0),
|
||||
total_income_actual: incomeLines.reduce((s: number, l: any) => s + l.actual_amount, 0),
|
||||
total_expense_budget: expenseLines.reduce((s: number, l: any) => s + l.budget_amount, 0),
|
||||
total_expense_actual: expenseLines.reduce((s: number, l: any) => s + l.actual_amount, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
30
backend/src/modules/budgets/dto/upsert-budget.dto.ts
Normal file
30
backend/src/modules/budgets/dto/upsert-budget.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsUUID, IsNumber, IsOptional, IsString, IsIn } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpsertBudgetDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@ApiProperty({ enum: ['operating', 'reserve'] })
|
||||
@IsIn(['operating', 'reserve'])
|
||||
fundType: string;
|
||||
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jan?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() feb?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() mar?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() apr?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() may?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jun?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() jul?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() aug?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() sep?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() oct?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() nov?: number;
|
||||
@ApiProperty({ required: false }) @IsNumber() @IsOptional() dec?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CapitalProjectsService } from './capital-projects.service';
|
||||
|
||||
@ApiTags('capital-projects')
|
||||
@Controller('capital-projects')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CapitalProjectsController {
|
||||
constructor(private service: CapitalProjectsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.service.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.service.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CapitalProjectsController } from './capital-projects.controller';
|
||||
import { CapitalProjectsService } from './capital-projects.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CapitalProjectsController],
|
||||
providers: [CapitalProjectsService],
|
||||
exports: [CapitalProjectsService],
|
||||
})
|
||||
export class CapitalProjectsModule {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class CapitalProjectsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT cp.*, rc.name as reserve_component_name
|
||||
FROM capital_projects cp
|
||||
LEFT JOIN reserve_components rc ON rc.id = cp.reserve_component_id
|
||||
ORDER BY cp.target_year, cp.target_month NULLS LAST, cp.priority
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM capital_projects WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Capital project not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO capital_projects (name, description, estimated_cost, actual_cost, target_year, target_month,
|
||||
status, reserve_component_id, fund_source, priority, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[dto.name, dto.description, dto.estimated_cost, dto.actual_cost || null, dto.target_year,
|
||||
dto.target_month || null, dto.status || 'planned', dto.reserve_component_id || null,
|
||||
dto.fund_source || 'reserve', dto.priority || 3, dto.notes],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE capital_projects SET name = COALESCE($2, name), description = COALESCE($3, description),
|
||||
estimated_cost = COALESCE($4, estimated_cost), actual_cost = COALESCE($5, actual_cost),
|
||||
target_year = COALESCE($6, target_year), target_month = COALESCE($7, target_month),
|
||||
status = COALESCE($8, status), reserve_component_id = COALESCE($9, reserve_component_id),
|
||||
fund_source = COALESCE($10, fund_source), priority = COALESCE($11, priority),
|
||||
notes = COALESCE($12, notes), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.description, dto.estimated_cost, dto.actual_cost, dto.target_year,
|
||||
dto.target_month, dto.status, dto.reserve_component_id, dto.fund_source, dto.priority, dto.notes],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { FiscalPeriodsService } from './fiscal-periods.service';
|
||||
|
||||
@ApiTags('fiscal-periods')
|
||||
@Controller('fiscal-periods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FiscalPeriodsController {
|
||||
constructor(private fpService: FiscalPeriodsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all fiscal periods' })
|
||||
findAll() {
|
||||
return this.fpService.findAll();
|
||||
}
|
||||
|
||||
@Post(':id/close')
|
||||
@ApiOperation({ summary: 'Close a fiscal period' })
|
||||
close(@Param('id') id: string, @Request() req: any) {
|
||||
return this.fpService.close(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Post(':id/lock')
|
||||
@ApiOperation({ summary: 'Lock a fiscal period (audit lock)' })
|
||||
lock(@Param('id') id: string, @Request() req: any) {
|
||||
return this.fpService.lock(id, req.user.sub);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/fiscal-periods/fiscal-periods.module.ts
Normal file
10
backend/src/modules/fiscal-periods/fiscal-periods.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FiscalPeriodsController } from './fiscal-periods.controller';
|
||||
import { FiscalPeriodsService } from './fiscal-periods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FiscalPeriodsController],
|
||||
providers: [FiscalPeriodsService],
|
||||
exports: [FiscalPeriodsService],
|
||||
})
|
||||
export class FiscalPeriodsModule {}
|
||||
61
backend/src/modules/fiscal-periods/fiscal-periods.service.ts
Normal file
61
backend/src/modules/fiscal-periods/fiscal-periods.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class FiscalPeriodsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query('SELECT * FROM fiscal_periods ORDER BY year DESC, month DESC');
|
||||
}
|
||||
|
||||
async findByDate(date: string) {
|
||||
const d = new Date(date);
|
||||
const rows = await this.tenant.query(
|
||||
'SELECT * FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[d.getFullYear(), d.getMonth() + 1],
|
||||
);
|
||||
if (!rows.length) {
|
||||
throw new NotFoundException(`No fiscal period for ${date}`);
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findOrCreate(year: number, month: number) {
|
||||
let rows = await this.tenant.query(
|
||||
'SELECT * FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (rows.length) return rows[0];
|
||||
|
||||
rows = await this.tenant.query(
|
||||
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING *`,
|
||||
[year, month],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async close(id: string, userId: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM fiscal_periods WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Period not found');
|
||||
if (rows[0].status !== 'open') throw new BadRequestException('Period is not open');
|
||||
|
||||
const result = await this.tenant.query(
|
||||
`UPDATE fiscal_periods SET status = 'closed', closed_by = $1, closed_at = NOW() WHERE id = $2 RETURNING *`,
|
||||
[userId, id],
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async lock(id: string, userId: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM fiscal_periods WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Period not found');
|
||||
if (rows[0].status === 'locked') throw new BadRequestException('Period is already locked');
|
||||
|
||||
const result = await this.tenant.query(
|
||||
`UPDATE fiscal_periods SET status = 'locked', locked_by = $1, locked_at = NOW() WHERE id = $2 RETURNING *`,
|
||||
[userId, id],
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
24
backend/src/modules/investments/investments.controller.ts
Normal file
24
backend/src/modules/investments/investments.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InvestmentsService } from './investments.service';
|
||||
|
||||
@ApiTags('investments')
|
||||
@Controller('investment-accounts')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InvestmentsController {
|
||||
constructor(private service: InvestmentsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.service.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.service.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
}
|
||||
10
backend/src/modules/investments/investments.module.ts
Normal file
10
backend/src/modules/investments/investments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvestmentsController } from './investments.controller';
|
||||
import { InvestmentsService } from './investments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvestmentsController],
|
||||
providers: [InvestmentsService],
|
||||
exports: [InvestmentsService],
|
||||
})
|
||||
export class InvestmentsModule {}
|
||||
46
backend/src/modules/investments/investments.service.ts
Normal file
46
backend/src/modules/investments/investments.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvestmentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query('SELECT * FROM investment_accounts WHERE is_active = true ORDER BY name');
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM investment_accounts WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Investment account not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO investment_accounts (name, institution, account_number_last4, investment_type,
|
||||
fund_type, principal, interest_rate, maturity_date, purchase_date, current_value, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[dto.name, dto.institution, dto.account_number_last4, dto.investment_type || 'cd',
|
||||
dto.fund_type || 'reserve', dto.principal, dto.interest_rate || 0,
|
||||
dto.maturity_date || null, dto.purchase_date || null, dto.current_value || dto.principal, dto.notes],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE investment_accounts SET name = COALESCE($2, name), institution = COALESCE($3, institution),
|
||||
account_number_last4 = COALESCE($4, account_number_last4), investment_type = COALESCE($5, investment_type),
|
||||
fund_type = COALESCE($6, fund_type), principal = COALESCE($7, principal),
|
||||
interest_rate = COALESCE($8, interest_rate), maturity_date = COALESCE($9, maturity_date),
|
||||
purchase_date = COALESCE($10, purchase_date), current_value = COALESCE($11, current_value),
|
||||
notes = COALESCE($12, notes), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.institution, dto.account_number_last4, dto.investment_type,
|
||||
dto.fund_type, dto.principal, dto.interest_rate, dto.maturity_date, dto.purchase_date,
|
||||
dto.current_value, dto.notes],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
28
backend/src/modules/invoices/invoices.controller.ts
Normal file
28
backend/src/modules/invoices/invoices.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@ApiTags('invoices')
|
||||
@Controller('invoices')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InvoicesController {
|
||||
constructor(private invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.invoicesService.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||
|
||||
@Post('generate-bulk')
|
||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Post('apply-late-fees')
|
||||
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
||||
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/invoices/invoices.module.ts
Normal file
10
backend/src/modules/invoices/invoices.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
120
backend/src/modules/invoices/invoices.service.ts
Normal file
120
backend/src/modules/invoices/invoices.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT i.*, u.unit_number,
|
||||
(i.amount - i.amount_paid) as balance_due
|
||||
FROM invoices i
|
||||
JOIN units u ON u.id = i.unit_id
|
||||
ORDER BY i.invoice_date DESC, i.invoice_number DESC
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query(`
|
||||
SELECT i.*, u.unit_number FROM invoices i
|
||||
JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]);
|
||||
if (!rows.length) throw new NotFoundException('Invoice not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async generateBulk(dto: { month: number; year: number }, userId: string) {
|
||||
const units = await this.tenant.query(
|
||||
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 0`,
|
||||
);
|
||||
if (!units.length) throw new BadRequestException('No active units with assessments found');
|
||||
|
||||
// Get or create fiscal period
|
||||
let fp = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [dto.year, dto.month],
|
||||
);
|
||||
if (!fp.length) {
|
||||
fp = await this.tenant.query(
|
||||
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING id`,
|
||||
[dto.year, dto.month],
|
||||
);
|
||||
}
|
||||
const fiscalPeriodId = fp[0].id;
|
||||
|
||||
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
||||
const dueDate = new Date(dto.year, dto.month - 1, 15);
|
||||
let created = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`;
|
||||
|
||||
// Check if already generated
|
||||
const existing = await this.tenant.query(
|
||||
'SELECT id FROM invoices WHERE invoice_number = $1', [invNum],
|
||||
);
|
||||
if (existing.length) continue;
|
||||
|
||||
// Create the invoice
|
||||
const inv = await this.tenant.query(
|
||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
|
||||
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
||||
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
|
||||
unit.monthly_assessment],
|
||||
);
|
||||
|
||||
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
||||
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
|
||||
const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 4000`);
|
||||
|
||||
if (arAccount.length && incomeAccount.length) {
|
||||
const je = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`,
|
||||
[invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
||||
[je[0].id, arAccount[0].id, unit.monthly_assessment, incomeAccount[0].id],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||
);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
|
||||
return { created, month: dto.month, year: dto.year };
|
||||
}
|
||||
|
||||
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - dto.grace_period_days);
|
||||
const cutoffStr = cutoff.toISOString().split('T')[0];
|
||||
|
||||
const overdue = await this.tenant.query(`
|
||||
SELECT i.*, u.unit_number FROM invoices i
|
||||
JOIN units u ON u.id = i.unit_id
|
||||
WHERE i.status IN ('sent', 'partial') AND i.due_date < $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id
|
||||
AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%'
|
||||
)
|
||||
`, [cutoffStr]);
|
||||
|
||||
let applied = 0;
|
||||
for (const inv of overdue) {
|
||||
await this.tenant.query(`UPDATE invoices SET status = 'overdue' WHERE id = $1`, [inv.id]);
|
||||
|
||||
const lfNum = `LF-${inv.invoice_number}`;
|
||||
await this.tenant.query(
|
||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
||||
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'sent')`,
|
||||
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
|
||||
);
|
||||
applied++;
|
||||
}
|
||||
|
||||
return { applied };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsUUID, IsIn, IsDateString,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class JournalEntryLineDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@ApiProperty({ example: 350.00 })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
debit?: number;
|
||||
|
||||
@ApiProperty({ example: 0 })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
credit?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export class CreateJournalEntryDto {
|
||||
@ApiProperty({ example: '2026-02-15' })
|
||||
@IsDateString()
|
||||
entryDate: string;
|
||||
|
||||
@ApiProperty({ example: 'Monthly landscaping payment' })
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
referenceNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'manual', required: false })
|
||||
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance'])
|
||||
@IsOptional()
|
||||
entryType?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sourceType?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
sourceId?: string;
|
||||
|
||||
@ApiProperty({ type: [JournalEntryLineDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => JournalEntryLineDto)
|
||||
lines: JournalEntryLineDto[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VoidJournalEntryDto {
|
||||
@ApiProperty({ example: 'Duplicate entry' })
|
||||
@IsString()
|
||||
reason: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Query, UseGuards, Request,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { JournalEntriesService } from './journal-entries.service';
|
||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
||||
|
||||
@ApiTags('journal-entries')
|
||||
@Controller('journal-entries')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class JournalEntriesController {
|
||||
constructor(private jeService: JournalEntriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List journal entries' })
|
||||
findAll(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@Query('accountId') accountId?: string,
|
||||
@Query('type') type?: string,
|
||||
) {
|
||||
return this.jeService.findAll({ from, to, accountId, type });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get journal entry by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.jeService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a journal entry' })
|
||||
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
||||
return this.jeService.create(dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Post(':id/post')
|
||||
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
||||
post(@Param('id') id: string, @Request() req: any) {
|
||||
return this.jeService.post(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Post(':id/void')
|
||||
@ApiOperation({ summary: 'Void a journal entry' })
|
||||
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
||||
return this.jeService.void(id, req.user.sub, dto.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JournalEntriesController } from './journal-entries.controller';
|
||||
import { JournalEntriesService } from './journal-entries.service';
|
||||
import { FiscalPeriodsModule } from '../fiscal-periods/fiscal-periods.module';
|
||||
|
||||
@Module({
|
||||
imports: [FiscalPeriodsModule],
|
||||
controllers: [JournalEntriesController],
|
||||
providers: [JournalEntriesService],
|
||||
exports: [JournalEntriesService],
|
||||
})
|
||||
export class JournalEntriesModule {}
|
||||
191
backend/src/modules/journal-entries/journal-entries.service.ts
Normal file
191
backend/src/modules/journal-entries/journal-entries.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { FiscalPeriodsService } from '../fiscal-periods/fiscal-periods.service';
|
||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||
|
||||
@Injectable()
|
||||
export class JournalEntriesService {
|
||||
constructor(
|
||||
private tenant: TenantService,
|
||||
private fiscalPeriodsService: FiscalPeriodsService,
|
||||
) {}
|
||||
|
||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||
let sql = `
|
||||
SELECT je.*,
|
||||
json_agg(json_build_object(
|
||||
'id', jel.id, 'account_id', jel.account_id,
|
||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||
'account_name', a.name, 'account_number', a.account_number
|
||||
)) as lines
|
||||
FROM journal_entries je
|
||||
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
LEFT JOIN accounts a ON a.id = jel.account_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filters.from) {
|
||||
sql += ` AND je.entry_date >= $${idx++}`;
|
||||
params.push(filters.from);
|
||||
}
|
||||
if (filters.to) {
|
||||
sql += ` AND je.entry_date <= $${idx++}`;
|
||||
params.push(filters.to);
|
||||
}
|
||||
if (filters.accountId) {
|
||||
sql += ` AND je.id IN (SELECT journal_entry_id FROM journal_entry_lines WHERE account_id = $${idx++})`;
|
||||
params.push(filters.accountId);
|
||||
}
|
||||
if (filters.type) {
|
||||
sql += ` AND je.entry_type = $${idx++}`;
|
||||
params.push(filters.type);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY je.id ORDER BY je.entry_date DESC, je.created_at DESC';
|
||||
return this.tenant.query(sql, params);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT je.*,
|
||||
json_agg(json_build_object(
|
||||
'id', jel.id, 'account_id', jel.account_id,
|
||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||
'account_name', a.name, 'account_number', a.account_number
|
||||
)) as lines
|
||||
FROM journal_entries je
|
||||
LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
LEFT JOIN accounts a ON a.id = jel.account_id
|
||||
WHERE je.id = $1
|
||||
GROUP BY je.id`,
|
||||
[id],
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException('Journal entry not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: CreateJournalEntryDto, userId: string) {
|
||||
// Validate debits = credits
|
||||
const totalDebits = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0);
|
||||
const totalCredits = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0);
|
||||
|
||||
if (Math.abs(totalDebits - totalCredits) > 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Debits ($${totalDebits.toFixed(2)}) must equal credits ($${totalCredits.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.lines.length < 2) {
|
||||
throw new BadRequestException('Journal entry must have at least 2 lines');
|
||||
}
|
||||
|
||||
// Find or create fiscal period
|
||||
const entryDate = new Date(dto.entryDate);
|
||||
const fp = await this.fiscalPeriodsService.findOrCreate(
|
||||
entryDate.getFullYear(),
|
||||
entryDate.getMonth() + 1,
|
||||
);
|
||||
if (fp.status === 'locked') {
|
||||
throw new BadRequestException('Cannot post to a locked fiscal period');
|
||||
}
|
||||
if (fp.status === 'closed') {
|
||||
throw new BadRequestException('Cannot post to a closed fiscal period');
|
||||
}
|
||||
|
||||
// Create journal entry
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, reference_number, entry_type, fiscal_period_id, source_type, source_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[
|
||||
dto.entryDate,
|
||||
dto.description,
|
||||
dto.referenceNumber || null,
|
||||
dto.entryType || 'manual',
|
||||
fp.id,
|
||||
dto.sourceType || null,
|
||||
dto.sourceId || null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
const je = jeRows[0];
|
||||
|
||||
// Create lines
|
||||
for (const line of dto.lines) {
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[je.id, line.accountId, line.debit || 0, line.credit || 0, line.memo || null],
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOne(je.id);
|
||||
}
|
||||
|
||||
async post(id: string, userId: string) {
|
||||
const je = await this.findOne(id);
|
||||
if (je.is_posted) throw new BadRequestException('Already posted');
|
||||
if (je.is_void) throw new BadRequestException('Cannot post a voided entry');
|
||||
|
||||
// Update account balances
|
||||
for (const line of je.lines) {
|
||||
const debit = parseFloat(line.debit) || 0;
|
||||
const credit = parseFloat(line.credit) || 0;
|
||||
const netAmount = debit - credit;
|
||||
|
||||
await this.tenant.query(
|
||||
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
|
||||
[netAmount, line.account_id],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.tenant.query(
|
||||
`UPDATE journal_entries SET is_posted = true, posted_by = $1, posted_at = NOW() WHERE id = $2 RETURNING *`,
|
||||
[userId, id],
|
||||
);
|
||||
return this.findOne(result[0].id);
|
||||
}
|
||||
|
||||
async void(id: string, userId: string, reason: string) {
|
||||
const je = await this.findOne(id);
|
||||
if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry');
|
||||
if (je.is_void) throw new BadRequestException('Already voided');
|
||||
|
||||
// Reverse account balances
|
||||
for (const line of je.lines) {
|
||||
const debit = parseFloat(line.debit) || 0;
|
||||
const credit = parseFloat(line.credit) || 0;
|
||||
const reverseAmount = credit - debit;
|
||||
|
||||
await this.tenant.query(
|
||||
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
|
||||
[reverseAmount, line.account_id],
|
||||
);
|
||||
}
|
||||
|
||||
await this.tenant.query(
|
||||
`UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`,
|
||||
[userId, reason, id],
|
||||
);
|
||||
|
||||
// Create reversing entry
|
||||
const reverseDto: CreateJournalEntryDto = {
|
||||
entryDate: new Date().toISOString().split('T')[0],
|
||||
description: `VOID: ${je.description}`,
|
||||
referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`,
|
||||
entryType: 'adjustment',
|
||||
lines: je.lines.map((l: any) => ({
|
||||
accountId: l.account_id,
|
||||
debit: parseFloat(l.credit) || 0,
|
||||
credit: parseFloat(l.debit) || 0,
|
||||
memo: `Reversal of voided entry`,
|
||||
})),
|
||||
};
|
||||
|
||||
const reversing = await this.create(reverseDto, userId);
|
||||
await this.post(reversing.id, userId);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateOrganizationDto {
|
||||
@ApiProperty({ example: 'Sunrise Valley HOA' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: '123 Main St', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
addressLine1?: string;
|
||||
|
||||
@ApiProperty({ example: 'Springfield', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
city?: string;
|
||||
|
||||
@ApiProperty({ example: 'IL', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
state?: string;
|
||||
|
||||
@ApiProperty({ example: '62701', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
zipCode?: string;
|
||||
|
||||
@ApiProperty({ example: '555-123-4567', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ example: 'board@sunrisevalley.org', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ example: 1, required: false })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
@IsOptional()
|
||||
fiscalYearStartMonth?: number;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserOrganization } from './user-organization.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'organizations' })
|
||||
export class Organization {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'schema_name', unique: true })
|
||||
schemaName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
subdomain: string;
|
||||
|
||||
@Column({ default: 'active' })
|
||||
status: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
settings: Record<string, any>;
|
||||
|
||||
@Column({ name: 'address_line1', nullable: true })
|
||||
addressLine1: string;
|
||||
|
||||
@Column({ name: 'address_line2', nullable: true })
|
||||
addressLine2: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
city: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
state: string;
|
||||
|
||||
@Column({ name: 'zip_code', nullable: true })
|
||||
zipCode: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ name: 'tax_id', nullable: true })
|
||||
taxId: string;
|
||||
|
||||
@Column({ name: 'fiscal_year_start_month', default: 1 })
|
||||
fiscalYearStartMonth: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => UserOrganization, (uo) => uo.organization)
|
||||
userOrganizations: UserOrganization[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'user_organizations' })
|
||||
@Unique(['userId', 'organizationId'])
|
||||
export class UserOrganization {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'organization_id' })
|
||||
organizationId: string;
|
||||
|
||||
@Column()
|
||||
role: string;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
|
||||
joinedAt: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.userOrganizations)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => Organization, (org) => org.userOrganizations)
|
||||
@JoinColumn({ name: 'organization_id' })
|
||||
organization: Organization;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Controller, Post, Get, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('organizations')
|
||||
@Controller('organizations')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OrganizationsController {
|
||||
constructor(private orgService: OrganizationsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new HOA organization' })
|
||||
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
|
||||
return this.orgService.create(dto, req.user.sub);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List organizations for current user' })
|
||||
async findMine(@Request() req: any) {
|
||||
return this.orgService.findByUser(req.user.sub);
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/organizations/organizations.module.ts
Normal file
15
backend/src/modules/organizations/organizations.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { UserOrganization } from './entities/user-organization.entity';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Organization, UserOrganization])],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService, TenantSchemaService],
|
||||
exports: [OrganizationsService, TenantSchemaService],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
80
backend/src/modules/organizations/organizations.service.ts
Normal file
80
backend/src/modules/organizations/organizations.service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { UserOrganization } from './entities/user-organization.entity';
|
||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
constructor(
|
||||
@InjectRepository(Organization)
|
||||
private orgRepository: Repository<Organization>,
|
||||
@InjectRepository(UserOrganization)
|
||||
private userOrgRepository: Repository<UserOrganization>,
|
||||
private tenantSchemaService: TenantSchemaService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateOrganizationDto, userId: string) {
|
||||
const schemaName = this.generateSchemaName(dto.name);
|
||||
|
||||
const existing = await this.orgRepository.findOne({
|
||||
where: { schemaName },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Organization name too similar to existing one');
|
||||
}
|
||||
|
||||
const org = this.orgRepository.create({
|
||||
name: dto.name,
|
||||
schemaName,
|
||||
addressLine1: dto.addressLine1,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
zipCode: dto.zipCode,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
fiscalYearStartMonth: dto.fiscalYearStartMonth || 1,
|
||||
});
|
||||
|
||||
const savedOrg = await this.orgRepository.save(org);
|
||||
|
||||
await this.tenantSchemaService.createTenantSchema(schemaName);
|
||||
|
||||
const membership = this.userOrgRepository.create({
|
||||
userId,
|
||||
organizationId: savedOrg.id,
|
||||
role: 'president',
|
||||
});
|
||||
await this.userOrgRepository.save(membership);
|
||||
|
||||
return savedOrg;
|
||||
}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
const memberships = await this.userOrgRepository.find({
|
||||
where: { userId, isActive: true },
|
||||
relations: ['organization'],
|
||||
});
|
||||
return memberships.map((m) => ({
|
||||
...m.organization,
|
||||
role: m.role,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.orgRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
private generateSchemaName(name: string): string {
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.substring(0, 40);
|
||||
const suffix = Date.now().toString(36).slice(-4);
|
||||
return `tenant_${clean}_${suffix}`;
|
||||
}
|
||||
}
|
||||
21
backend/src/modules/payments/payments.controller.ts
Normal file
21
backend/src/modules/payments/payments.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PaymentsController {
|
||||
constructor(private paymentsService: PaymentsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.paymentsService.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||
}
|
||||
10
backend/src/modules/payments/payments.module.ts
Normal file
10
backend/src/modules/payments/payments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
90
backend/src/modules/payments/payments.service.ts
Normal file
90
backend/src/modules/payments/payments.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT p.*, u.unit_number, i.invoice_number
|
||||
FROM payments p
|
||||
JOIN units u ON u.id = p.unit_id
|
||||
LEFT JOIN invoices i ON i.id = p.invoice_id
|
||||
ORDER BY p.payment_date DESC, p.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query(`
|
||||
SELECT p.*, u.unit_number, i.invoice_number FROM payments p
|
||||
JOIN units u ON u.id = p.unit_id
|
||||
LEFT JOIN invoices i ON i.id = p.invoice_id
|
||||
WHERE p.id = $1`, [id]);
|
||||
if (!rows.length) throw new NotFoundException('Payment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any, userId: string) {
|
||||
// Validate invoice exists and get details
|
||||
let invoice: any = null;
|
||||
if (dto.invoice_id) {
|
||||
const rows = await this.tenant.query('SELECT * FROM invoices WHERE id = $1', [dto.invoice_id]);
|
||||
if (!rows.length) throw new NotFoundException('Invoice not found');
|
||||
invoice = rows[0];
|
||||
if (invoice.status === 'paid') throw new BadRequestException('Invoice is already paid');
|
||||
if (invoice.status === 'void') throw new BadRequestException('Cannot pay void invoice');
|
||||
}
|
||||
|
||||
// Get fiscal period
|
||||
const payDate = new Date(dto.payment_date);
|
||||
let fp = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[payDate.getFullYear(), payDate.getMonth() + 1],
|
||||
);
|
||||
if (!fp.length) {
|
||||
fp = await this.tenant.query(
|
||||
`INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING id`,
|
||||
[payDate.getFullYear(), payDate.getMonth() + 1],
|
||||
);
|
||||
}
|
||||
|
||||
// Create payment record
|
||||
const payment = await this.tenant.query(
|
||||
`INSERT INTO payments (unit_id, invoice_id, payment_date, amount, payment_method, reference_number, notes, received_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[dto.unit_id, dto.invoice_id || null, dto.payment_date, dto.amount, dto.payment_method || 'check',
|
||||
dto.reference_number || null, dto.notes || null, userId],
|
||||
);
|
||||
|
||||
// Create journal entry: DR Cash, CR Accounts Receivable
|
||||
const cashAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1000`);
|
||||
const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = 1200`);
|
||||
|
||||
if (cashAccount.length && arAccount.length) {
|
||||
const je = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'payment', $3, 'payment', $4, true, NOW(), $5) RETURNING id`,
|
||||
[dto.payment_date, `Payment received - ${dto.reference_number || 'N/A'}`, fp[0].id, payment[0].id, userId],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`,
|
||||
[je[0].id, cashAccount[0].id, dto.amount, arAccount[0].id],
|
||||
);
|
||||
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
|
||||
}
|
||||
|
||||
// Update invoice if linked
|
||||
if (invoice) {
|
||||
const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount);
|
||||
const invoiceAmt = parseFloat(invoice.amount);
|
||||
const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial';
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`,
|
||||
[newPaid, newStatus, invoice.id],
|
||||
);
|
||||
}
|
||||
|
||||
return payment[0];
|
||||
}
|
||||
}
|
||||
35
backend/src/modules/reports/reports.controller.ts
Normal file
35
backend/src/modules/reports/reports.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('reports')
|
||||
@Controller('reports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReportsController {
|
||||
constructor(private reportsService: ReportsService) {}
|
||||
|
||||
@Get('balance-sheet')
|
||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
@Get('income-statement')
|
||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||
const now = new Date();
|
||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||
const defaultTo = now.toISOString().split('T')[0];
|
||||
return this.reportsService.getIncomeStatement(from || defaultFrom, to || defaultTo);
|
||||
}
|
||||
|
||||
@Get('cash-flow-sankey')
|
||||
getCashFlowSankey(@Query('year') year?: string) {
|
||||
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
getDashboardKPIs() {
|
||||
return this.reportsService.getDashboardKPIs();
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/reports/reports.module.ts
Normal file
10
backend/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
227
backend/src/modules/reports/reports.service.ts
Normal file
227
backend/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async getBalanceSheet(asOf: string) {
|
||||
const sql = `
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date <= $1
|
||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
HAVING CASE
|
||||
WHEN a.account_type IN ('asset') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END <> 0 OR a.is_system = true
|
||||
ORDER BY a.account_number
|
||||
`;
|
||||
const rows = await this.tenant.query(sql, [asOf]);
|
||||
|
||||
const assets = rows.filter((r: any) => r.account_type === 'asset');
|
||||
const liabilities = rows.filter((r: any) => r.account_type === 'liability');
|
||||
const equity = rows.filter((r: any) => r.account_type === 'equity');
|
||||
|
||||
const totalAssets = assets.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
const totalEquity = equity.reduce((s: number, r: any) => s + parseFloat(r.balance), 0);
|
||||
|
||||
return {
|
||||
as_of: asOf,
|
||||
assets, liabilities, equity,
|
||||
total_assets: totalAssets.toFixed(2),
|
||||
total_liabilities: totalLiabilities.toFixed(2),
|
||||
total_equity: totalEquity.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async getIncomeStatement(from: string, to: string) {
|
||||
const sql = `
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
CASE
|
||||
WHEN a.account_type = 'income'
|
||||
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
END as amount
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND je.entry_date BETWEEN $1 AND $2
|
||||
WHERE a.is_active = true AND a.account_type IN ('income', 'expense')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type
|
||||
HAVING CASE
|
||||
WHEN a.account_type = 'income' THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
END <> 0 OR a.is_system = true
|
||||
ORDER BY a.account_number
|
||||
`;
|
||||
const rows = await this.tenant.query(sql, [from, to]);
|
||||
|
||||
const income = rows.filter((r: any) => r.account_type === 'income');
|
||||
const expenses = rows.filter((r: any) => r.account_type === 'expense');
|
||||
|
||||
const totalIncome = income.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
|
||||
const totalExpenses = expenses.reduce((s: number, r: any) => s + parseFloat(r.amount), 0);
|
||||
|
||||
return {
|
||||
from, to,
|
||||
income, expenses,
|
||||
total_income: totalIncome.toFixed(2),
|
||||
total_expenses: totalExpenses.toFixed(2),
|
||||
net_income: (totalIncome - totalExpenses).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async getCashFlowSankey(year: number) {
|
||||
// Get income accounts with amounts
|
||||
const income = await this.tenant.query(`
|
||||
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'income' AND a.is_active = true
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
|
||||
const expenses = await this.tenant.query(`
|
||||
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
WHERE a.account_type = 'expense' AND a.is_active = true
|
||||
GROUP BY a.id, a.name, a.fund_type
|
||||
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
|
||||
ORDER BY amount DESC
|
||||
`, [year]);
|
||||
|
||||
if (!income.length && !expenses.length) {
|
||||
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };
|
||||
}
|
||||
|
||||
// Build Sankey nodes and links
|
||||
// Structure: Income Sources → HOA Fund → Expense Categories
|
||||
const nodes: { name: string; category: string }[] = [];
|
||||
const links: { source: number; target: number; value: number }[] = [];
|
||||
|
||||
// Income source nodes
|
||||
income.forEach((i: any) => nodes.push({ name: i.name, category: 'income' }));
|
||||
|
||||
// Central HOA Fund node
|
||||
const fundIdx = nodes.length;
|
||||
nodes.push({ name: 'HOA Fund', category: 'operating' });
|
||||
|
||||
// Operating expense nodes
|
||||
const opExpenses = expenses.filter((e: any) => e.fund_type === 'operating');
|
||||
const resExpenses = expenses.filter((e: any) => e.fund_type === 'reserve');
|
||||
|
||||
if (opExpenses.length) {
|
||||
const opIdx = nodes.length;
|
||||
nodes.push({ name: 'Operating Expenses', category: 'expense' });
|
||||
opExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'expense' }));
|
||||
|
||||
// Link fund → operating
|
||||
const opTotal = opExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
links.push({ source: fundIdx, target: opIdx, value: opTotal });
|
||||
|
||||
// Link operating → each expense
|
||||
opExpenses.forEach((e: any, i: number) => {
|
||||
links.push({ source: opIdx, target: opIdx + 1 + i, value: parseFloat(e.amount) });
|
||||
});
|
||||
}
|
||||
|
||||
if (resExpenses.length) {
|
||||
const resIdx = nodes.length;
|
||||
nodes.push({ name: 'Reserve Expenses', category: 'reserve' });
|
||||
resExpenses.forEach((e: any) => nodes.push({ name: e.name, category: 'reserve' }));
|
||||
|
||||
const resTotal = resExpenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
links.push({ source: fundIdx, target: resIdx, value: resTotal });
|
||||
|
||||
resExpenses.forEach((e: any, i: number) => {
|
||||
links.push({ source: resIdx, target: resIdx + 1 + i, value: parseFloat(e.amount) });
|
||||
});
|
||||
}
|
||||
|
||||
// Link income sources → fund
|
||||
income.forEach((i: any, idx: number) => {
|
||||
links.push({ source: idx, target: fundIdx, value: parseFloat(i.amount) });
|
||||
});
|
||||
|
||||
// Net surplus node
|
||||
const totalIncome = income.reduce((s: number, i: any) => s + parseFloat(i.amount), 0);
|
||||
const totalExpenses = expenses.reduce((s: number, e: any) => s + parseFloat(e.amount), 0);
|
||||
const netFlow = totalIncome - totalExpenses;
|
||||
|
||||
if (netFlow > 0) {
|
||||
const surplusIdx = nodes.length;
|
||||
nodes.push({ name: 'Surplus / Savings', category: 'net' });
|
||||
links.push({ source: fundIdx, target: surplusIdx, value: netFlow });
|
||||
}
|
||||
|
||||
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
|
||||
}
|
||||
|
||||
async getDashboardKPIs() {
|
||||
// Total cash (all asset accounts with 'Cash' in name)
|
||||
const cash = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
const totalCash = parseFloat(cash[0]?.total || '0');
|
||||
|
||||
// Receivables
|
||||
const ar = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(amount - amount_paid), 0) as total
|
||||
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
|
||||
`);
|
||||
|
||||
// Reserve fund balance
|
||||
const reserves = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(current_fund_balance), 0) as total FROM reserve_components
|
||||
`);
|
||||
|
||||
// Delinquent count (overdue invoices)
|
||||
const delinquent = await this.tenant.query(`
|
||||
SELECT COUNT(DISTINCT unit_id) as count FROM invoices WHERE status = 'overdue'
|
||||
`);
|
||||
|
||||
// Recent transactions
|
||||
const recentTx = await this.tenant.query(`
|
||||
SELECT je.id, je.entry_date, je.description, je.entry_type,
|
||||
(SELECT COALESCE(SUM(debit), 0) FROM journal_entry_lines WHERE journal_entry_id = je.id) as amount
|
||||
FROM journal_entries je WHERE je.is_posted = true AND je.is_void = false
|
||||
ORDER BY je.entry_date DESC, je.created_at DESC LIMIT 10
|
||||
`);
|
||||
|
||||
return {
|
||||
total_cash: totalCash.toFixed(2),
|
||||
total_receivables: ar[0]?.total || '0.00',
|
||||
reserve_fund_balance: reserves[0]?.total || '0.00',
|
||||
delinquent_units: parseInt(delinquent[0]?.count || '0'),
|
||||
recent_transactions: recentTx,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ReserveComponentsService } from './reserve-components.service';
|
||||
|
||||
@ApiTags('reserve-components')
|
||||
@Controller('reserve-components')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReserveComponentsController {
|
||||
constructor(private service: ReserveComponentsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.service.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.service.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReserveComponentsController } from './reserve-components.controller';
|
||||
import { ReserveComponentsService } from './reserve-components.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReserveComponentsController],
|
||||
providers: [ReserveComponentsService],
|
||||
exports: [ReserveComponentsService],
|
||||
})
|
||||
export class ReserveComponentsModule {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class ReserveComponentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query('SELECT * FROM reserve_components ORDER BY name');
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM reserve_components WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Reserve component not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO reserve_components (name, category, description, useful_life_years, remaining_life_years,
|
||||
replacement_cost, current_fund_balance, annual_contribution, condition_rating,
|
||||
last_replacement_date, next_replacement_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[dto.name, dto.category, dto.description, dto.useful_life_years, dto.remaining_life_years || 0,
|
||||
dto.replacement_cost, dto.current_fund_balance || 0, dto.annual_contribution || 0,
|
||||
dto.condition_rating || 5, dto.last_replacement_date || null, dto.next_replacement_date || null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE reserve_components SET name = COALESCE($2, name), category = COALESCE($3, category),
|
||||
description = COALESCE($4, description), useful_life_years = COALESCE($5, useful_life_years),
|
||||
remaining_life_years = COALESCE($6, remaining_life_years), replacement_cost = COALESCE($7, replacement_cost),
|
||||
current_fund_balance = COALESCE($8, current_fund_balance), annual_contribution = COALESCE($9, annual_contribution),
|
||||
condition_rating = COALESCE($10, condition_rating), last_replacement_date = COALESCE($11, last_replacement_date),
|
||||
next_replacement_date = COALESCE($12, next_replacement_date), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.category, dto.description, dto.useful_life_years, dto.remaining_life_years,
|
||||
dto.replacement_cost, dto.current_fund_balance, dto.annual_contribution, dto.condition_rating,
|
||||
dto.last_replacement_date, dto.next_replacement_date],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
24
backend/src/modules/units/units.controller.ts
Normal file
24
backend/src/modules/units/units.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UnitsService } from './units.service';
|
||||
|
||||
@ApiTags('units')
|
||||
@Controller('units')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UnitsController {
|
||||
constructor(private unitsService: UnitsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.unitsService.findAll(); }
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||
}
|
||||
10
backend/src/modules/units/units.module.ts
Normal file
10
backend/src/modules/units/units.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UnitsController } from './units.controller';
|
||||
import { UnitsService } from './units.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UnitsController],
|
||||
providers: [UnitsService],
|
||||
exports: [UnitsService],
|
||||
})
|
||||
export class UnitsModule {}
|
||||
51
backend/src/modules/units/units.service.ts
Normal file
51
backend/src/modules/units/units.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class UnitsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query(`
|
||||
SELECT u.*,
|
||||
COALESCE((
|
||||
SELECT SUM(i.amount - i.amount_paid)
|
||||
FROM invoices i
|
||||
WHERE i.unit_id = u.id AND i.status NOT IN ('paid', 'void', 'written_off')
|
||||
), 0) as balance_due
|
||||
FROM units u ORDER BY u.unit_number
|
||||
`);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM units WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Unit not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
|
||||
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE units SET unit_number = COALESCE($2, unit_number), address_line1 = COALESCE($3, address_line1),
|
||||
city = COALESCE($4, city), state = COALESCE($5, state), zip_code = COALESCE($6, zip_code),
|
||||
owner_name = COALESCE($7, owner_name), owner_email = COALESCE($8, owner_email),
|
||||
owner_phone = COALESCE($9, owner_phone), monthly_assessment = COALESCE($10, monthly_assessment),
|
||||
status = COALESCE($11, status), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment, dto.status],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
23
backend/src/modules/users/dto/create-user.dto.ts
Normal file
23
backend/src/modules/users/dto/create-user.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ example: 'john@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'John', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ example: 'Smith', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
}
|
||||
57
backend/src/modules/users/entities/user.entity.ts
Normal file
57
backend/src/modules/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserOrganization } from '../../organizations/entities/user-organization.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'users' })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
|
||||
@Column({ name: 'password_hash', nullable: true })
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ name: 'first_name', nullable: true })
|
||||
firstName: string;
|
||||
|
||||
@Column({ name: 'last_name', nullable: true })
|
||||
lastName: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ name: 'is_email_verified', default: false })
|
||||
isEmailVerified: boolean;
|
||||
|
||||
@Column({ name: 'mfa_enabled', default: false })
|
||||
mfaEnabled: boolean;
|
||||
|
||||
@Column({ name: 'mfa_secret', nullable: true })
|
||||
mfaSecret: string;
|
||||
|
||||
@Column({ name: 'oauth_provider', nullable: true })
|
||||
oauthProvider: string;
|
||||
|
||||
@Column({ name: 'oauth_provider_id', nullable: true })
|
||||
oauthProviderId: string;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||
lastLoginAt: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => UserOrganization, (uo) => uo.user)
|
||||
userOrganizations: UserOrganization[];
|
||||
}
|
||||
11
backend/src/modules/users/users.module.ts
Normal file
11
backend/src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
41
backend/src/modules/users/users.service.ts
Normal file
41
backend/src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByIdWithOrgs(id: string): Promise<User | null> {
|
||||
return this.usersRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['userOrganizations', 'userOrganizations.organization'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Partial<User>): Promise<User> {
|
||||
const user = this.usersRepository.create({
|
||||
...data,
|
||||
email: data.email?.toLowerCase(),
|
||||
});
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async updateLastLogin(id: string): Promise<void> {
|
||||
await this.usersRepository.update(id, { lastLoginAt: new Date() });
|
||||
}
|
||||
}
|
||||
29
backend/src/modules/vendors/vendors.controller.ts
vendored
Normal file
29
backend/src/modules/vendors/vendors.controller.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { VendorsService } from './vendors.service';
|
||||
|
||||
@ApiTags('vendors')
|
||||
@Controller('vendors')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class VendorsController {
|
||||
constructor(private vendorsService: VendorsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() { return this.vendorsService.findAll(); }
|
||||
|
||||
@Get('1099-data')
|
||||
get1099Data(@Query('year') year: string) {
|
||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
||||
}
|
||||
10
backend/src/modules/vendors/vendors.module.ts
vendored
Normal file
10
backend/src/modules/vendors/vendors.module.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VendorsController } from './vendors.controller';
|
||||
import { VendorsService } from './vendors.service';
|
||||
|
||||
@Module({
|
||||
controllers: [VendorsController],
|
||||
providers: [VendorsService],
|
||||
exports: [VendorsService],
|
||||
})
|
||||
export class VendorsModule {}
|
||||
61
backend/src/modules/vendors/vendors.service.ts
vendored
Normal file
61
backend/src/modules/vendors/vendors.service.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class VendorsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.tenant.query('SELECT * FROM vendors WHERE is_active = true ORDER BY name');
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const rows = await this.tenant.query('SELECT * FROM vendors WHERE id = $1', [id]);
|
||||
if (!rows.length) throw new NotFoundException('Vendor not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO vendors (name, contact_name, email, phone, address_line1, city, state, zip_code, tax_id, is_1099_eligible, default_account_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state, dto.zip_code,
|
||||
dto.tax_id, dto.is_1099_eligible || false, dto.default_account_id || null],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const rows = await this.tenant.query(
|
||||
`UPDATE vendors SET name = COALESCE($2, name), contact_name = COALESCE($3, contact_name),
|
||||
email = COALESCE($4, email), phone = COALESCE($5, phone), address_line1 = COALESCE($6, address_line1),
|
||||
city = COALESCE($7, city), state = COALESCE($8, state), zip_code = COALESCE($9, zip_code),
|
||||
tax_id = COALESCE($10, tax_id), is_1099_eligible = COALESCE($11, is_1099_eligible),
|
||||
default_account_id = COALESCE($12, default_account_id), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, dto.name, dto.contact_name, dto.email, dto.phone, dto.address_line1, dto.city, dto.state,
|
||||
dto.zip_code, dto.tax_id, dto.is_1099_eligible, dto.default_account_id],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async get1099Data(year: number) {
|
||||
return this.tenant.query(`
|
||||
SELECT v.*, COALESCE(SUM(p_amounts.amount), 0) as total_paid
|
||||
FROM vendors v
|
||||
LEFT JOIN (
|
||||
SELECT jel.account_id, jel.debit as amount, je.entry_date
|
||||
FROM journal_entry_lines jel
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
AND jel.debit > 0
|
||||
) p_amounts ON p_amounts.account_id = v.default_account_id
|
||||
WHERE v.is_1099_eligible = true
|
||||
GROUP BY v.id
|
||||
HAVING COALESCE(SUM(p_amounts.amount), 0) >= 600
|
||||
ORDER BY v.name
|
||||
`, [year]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user