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:
2026-02-17 19:58:04 -05:00
commit 243770cea5
118 changed files with 8569 additions and 0 deletions

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

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

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