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