8 Commits

Author SHA1 Message Date
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
efa5aca35f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
c429dcc033 Merge pull request 'fix: improve AI health score accuracy and consistency' (#1) from ai-improvements into main
Reviewed-on: #1
2026-03-06 14:44:39 -05:00
9146118df1 feat: async AI calls, 10-min timeout, and failure messaging
- Make all AI endpoints (health scores + investment recommendations)
  fire-and-forget: POST returns immediately, frontend polls for results
- Extend AI API timeout from 2-5 min to 10 min for both services
- Add "last analysis failed — showing cached data" message to the
  Investment Recommendations panel (matches health score widgets)
- Add status/error_message columns to ai_recommendations table
- Remove nginx AI timeout overrides (no longer needed)
- Users can now navigate away during AI processing without interruption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:42:53 -05:00
34 changed files with 1540 additions and 296 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {

View File

@@ -112,6 +112,8 @@ export class TenantSchemaService {
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
due_day INTEGER DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
@@ -155,8 +157,11 @@ export class TenantSchemaService {
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
)),
period_start DATE,
period_end DATE,
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
@@ -325,6 +330,8 @@ export class TenantSchemaService {
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
status VARCHAR(20) DEFAULT 'complete',
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,

View File

@@ -67,7 +67,7 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2')
.setVersion('2026.3.7')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);

View File

@@ -1,6 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
quarterly: [1, 4, 7, 10],
annual: [1],
};
@Injectable()
export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
return rows.length ? rows[0] : null;
}
private validateDueMonths(frequency: string, dueMonths: number[]) {
if (!dueMonths || !dueMonths.length) {
throw new BadRequestException('Due months are required');
}
// Validate all values are 1-12
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
throw new BadRequestException('Due months must be integers between 1 and 12');
}
switch (frequency) {
case 'monthly':
if (dueMonths.length !== 12) {
throw new BadRequestException('Monthly frequency must include all 12 months');
}
break;
case 'quarterly':
if (dueMonths.length !== 4) {
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
}
break;
case 'annual':
if (dueMonths.length !== 1) {
throw new BadRequestException('Annual frequency must have exactly 1 due month');
}
break;
}
}
async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
}
const frequency = dto.frequency || 'monthly';
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
this.validateDueMonths(frequency, dueMonths);
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const existing = await this.findOne(id);
if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
const effectiveFrequency = dto.frequency || existing.frequency;
if (dto.dueMonths !== undefined) {
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
sets.push(`due_months = $${idx++}`);
params.push(dto.dueMonths);
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
// Frequency changed, auto-populate due_months
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
sets.push(`due_months = $${idx++}`);
params.push(newDueMonths);
}
if (dto.dueDay !== undefined) {
sets.push(`due_day = $${idx++}`);
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
}
if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()');

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@@ -9,6 +9,8 @@ import { HealthScoresService } from './health-scores.service';
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
private readonly logger = new Logger(HealthScoresController.name);
constructor(private service: HealthScoresService) {}
@Get('latest')
@@ -19,32 +21,56 @@ export class HealthScoresController {
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
const [operating, reserve] = await Promise.all([
// Fire-and-forget — background processing saves results to DB
Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]);
return { operating, reserve };
]).catch((err) => {
this.logger.error(`Background health score calculation failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Health score calculations started. Results will appear when ready.',
};
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.user?.orgSchema;
const operating = await this.service.calculateScore(schema, 'operating');
return { operating };
// Fire-and-forget
this.service.calculateScore(schema, 'operating').catch((err) => {
this.logger.error(`Background operating score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Operating fund health score calculation started.',
};
}
@Post('calculate/reserve')
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.user?.orgSchema;
const reserve = await this.service.calculateScore(schema, 'reserve');
return { reserve };
// Fire-and-forget
this.service.calculateScore(schema, 'reserve').catch((err) => {
this.logger.error(`Background reserve score failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Reserve fund health score calculation started.',
};
}
}

View File

@@ -1115,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 120000,
timeout: 600000, // 10 minute timeout
};
const req = https.request(options, (res) => {
@@ -1129,7 +1129,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out after 120s'));
reject(new Error('Request timed out after 600s'));
});
req.write(bodyString);

View File

@@ -36,9 +36,9 @@ export class InvestmentPlanningController {
}
@Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer()
getRecommendations(@Req() req: any) {
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
}
}

View File

@@ -65,6 +65,9 @@ export interface SavedRecommendation {
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
@Injectable()
@@ -196,14 +199,33 @@ export class InvestmentPlanningService {
return rates.cd;
}
/**
* Ensure the status/error_message columns exist (for tenants created before this migration).
*/
private async ensureStatusColumn(): Promise<void> {
try {
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
);
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
);
} catch {
// Ignore — column may already exist or table may not exist
}
}
/**
* Get the latest saved AI recommendation for this tenant.
* Returns status and last_failed flag for UI state management.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
response_time_ms, status, error_message, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
@@ -212,6 +234,64 @@ export class InvestmentPlanningService {
if (!rows || rows.length === 0) return null;
const row = rows[0];
const status = row.status || 'complete';
// If still processing, return processing status
if (status === 'processing') {
return {
id: row.id,
recommendations: [],
overall_assessment: '',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'processing',
last_failed: false,
};
}
// If latest attempt failed, return the last successful result with last_failed flag
if (status === 'error') {
const lastGood = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
WHERE status = 'complete'
ORDER BY created_at DESC
LIMIT 1`,
);
if (lastGood?.length) {
const goodRow = lastGood[0];
const recData = goodRow.recommendations_json || {};
return {
id: goodRow.id,
recommendations: recData.recommendations || [],
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
response_time_ms: goodRow.response_time_ms || 0,
created_at: goodRow.created_at,
status: 'complete',
last_failed: true,
error_message: row.error_message,
};
}
// No previous good result — return error state
return {
id: row.id,
recommendations: [],
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'error',
last_failed: true,
error_message: row.error_message,
};
}
// Complete — return the data normally
const recData = row.recommendations_json || {};
return {
id: row.id,
@@ -220,6 +300,8 @@ export class InvestmentPlanningService {
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
status: 'complete',
last_failed: false,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
@@ -228,15 +310,153 @@ export class InvestmentPlanningService {
}
}
/**
* Save a 'processing' placeholder record and return its ID.
*/
private async saveProcessingRecord(userId?: string): Promise<string> {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
VALUES ('{}', '', '[]', $1, 'processing')
RETURNING id`,
[userId || null],
);
return rows[0].id;
}
/**
* Update a processing record with completed results.
*/
private async updateRecommendationComplete(
jobId: string,
aiResponse: AIResponse,
userId: string | undefined,
elapsed: number,
): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET recommendations_json = $1,
overall_assessment = $2,
risk_notes = $3,
response_time_ms = $4,
status = 'complete'
WHERE id = $5`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
elapsed,
jobId,
],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
}
}
/**
* Update a processing record with error status.
*/
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET status = 'error',
error_message = $1
WHERE id = $2`,
[errorMessage, jobId],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
}
}
/**
* Trigger AI recommendations asynchronously.
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
* The TenantService instance remains alive via closure reference for the duration of the background work.
*/
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
const jobId = await this.saveProcessingRecord(userId);
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
});
return {
status: 'processing',
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
};
}
/**
* Run the full AI recommendation pipeline in the background.
*/
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
try {
const startTime = Date.now();
const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(),
this.getMarketRates(),
this.getMonthlyForecast(),
]);
this.debug('background_snapshot_summary', {
job_id: jobId,
operating_cash: snapshot.summary.operating_cash,
reserve_cash: snapshot.summary.reserve_cash,
total_all: snapshot.summary.total_all,
investment_accounts: snapshot.investment_accounts.length,
});
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
this.debug('background_final_response', {
job_id: jobId,
recommendation_count: aiResponse.recommendations.length,
has_assessment: !!aiResponse.overall_assessment,
elapsed_ms: elapsed,
});
// Check if the AI returned a graceful error (empty recommendations with error message)
const isGracefulError = aiResponse.recommendations.length === 0 &&
(aiResponse.overall_assessment?.includes('Unable to generate') ||
aiResponse.overall_assessment?.includes('invalid response'));
if (isGracefulError) {
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
} else {
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
}
// Log AI usage (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
} catch (err: any) {
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
await this.updateRecommendationError(jobId, err.message);
}
}
/**
* Save AI recommendation result to tenant schema.
* @deprecated Use triggerAIRecommendations() for async flow instead
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.ensureStatusColumn();
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
VALUES ($1, $2, $3, $4, $5)`,
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5, 'complete')`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
@@ -873,7 +1093,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 300000, // 5 minute timeout
timeout: 600000, // 10 minute timeout
};
const req = https.request(options, (res) => {
@@ -887,7 +1107,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timed out after 300s`));
reject(new Error(`Request timed out after 600s`));
});
req.write(bodyString);

View File

@@ -16,6 +16,11 @@ export class InvoicesController {
@Get(':id')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);

View File

@@ -1,33 +1,135 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_ABBREV = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
@Injectable()
export class InvoicesService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query(`
SELECT i.*, u.unit_number,
SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency,
(i.amount - i.amount_paid) as balance_due
FROM invoices i
JOIN units u ON u.id = i.unit_id
LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_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
SELECT i.*, u.unit_number, u.owner_name 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`,
/**
* Calculate billing period based on frequency and the billing month.
*/
private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } {
switch (frequency) {
case 'quarterly': {
// Period covers 3 months starting from the billing month
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month + 2, 0); // last day of month+2
const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2;
const quarter = Math.ceil(month / 3);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`,
};
}
case 'annual': {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year, 11, 31);
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Annual Assessment ${year}`,
};
}
default: { // monthly
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0); // last day of month
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`,
};
}
}
}
/**
* Preview which groups/units will be billed for a given month/year.
*/
async generatePreview(dto: { month: number; year: number }) {
const allGroups = await this.tenant.query(
`SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units
FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`,
);
if (!units.length) throw new BadRequestException('No active units with assessments found');
const groups = allGroups.map((g: any) => {
const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12];
const isBillingMonth = dueMonths.includes(dto.month);
const activeUnits = parseInt(g.active_units || '0');
const totalAmount = isBillingMonth
? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits
: 0;
const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year);
return {
id: g.id,
name: g.name,
frequency: g.frequency || 'monthly',
due_months: dueMonths,
active_units: activeUnits,
regular_assessment: g.regular_assessment,
special_assessment: g.special_assessment,
is_billing_month: isBillingMonth,
total_amount: totalAmount,
period_description: period.description,
};
});
const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0);
const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0);
const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0);
return {
month: dto.month,
year: dto.year,
month_name: MONTH_NAMES[dto.month],
groups,
summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount },
};
}
/**
* Generate invoices for all assessment groups where the given month is a billing month.
*/
async generateBulk(dto: { month: number; year: number }, userId: string) {
// Get assessment groups where this month is a billing month
const groups = await this.tenant.query(
`SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`,
[dto.month],
);
if (!groups.length) {
throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`);
}
// Get or create fiscal period
let fp = await this.tenant.query(
@@ -41,50 +143,87 @@ export class InvoicesService {
}
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);
// Look up GL accounts once
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'`);
let created = 0;
const groupResults: any[] = [];
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],
for (const group of groups) {
// Get active units in this assessment group
const units = await this.tenant.query(
`SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`,
[group.id],
);
// 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 (!units.length) continue;
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],
const frequency = group.frequency || 'monthly';
const period = this.calculatePeriod(frequency, dto.month, dto.year);
const dueDay = Math.min(group.due_day || 1, 28);
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
const dueDate = new Date(dto.year, dto.month - 1, dueDay);
// Use the group's assessment amount (full period amount, not monthly equivalent)
const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0');
let groupCreated = 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],
);
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],
if (existing.length) continue;
// Use unit-level override if set, otherwise use group amount
const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0
? (frequency === 'monthly'
? parseFloat(unit.monthly_assessment)
: frequency === 'quarterly'
? parseFloat(unit.monthly_assessment) * 3
: parseFloat(unit.monthly_assessment) * 12)
: assessmentAmount;
// Create the invoice with status 'pending' (no email sending capability)
const inv = await this.tenant.query(
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id)
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`,
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
period.description, unitAmount, period.start, period.end, group.id],
);
// Create journal entry: DR Accounts Receivable, CR Assessment Income
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, unitAmount, incomeAccount[0].id],
);
await this.tenant.query(
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
);
}
created++;
groupCreated++;
}
created++;
groupResults.push({
group_name: group.name,
frequency,
period: period.description,
invoices_created: groupCreated,
});
}
return { created, month: dto.month, year: dto.year };
return { created, month: dto.month, year: dto.year, groups: groupResults };
}
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
@@ -95,7 +234,7 @@ export class InvoicesService {
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
WHERE i.status IN ('pending', '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 || '%'
@@ -109,7 +248,7 @@ export class InvoicesService {
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')`,
VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`,
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
);
applied++;

View File

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role;
return this.userOrgRepository.save(existing);
}
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else {
// Create new user
const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, 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';
@@ -18,4 +18,12 @@ export class PaymentsController {
@Post()
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
}

View File

@@ -74,17 +74,95 @@ export class PaymentsService {
await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]);
}
// Update invoice if linked
// Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error
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],
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`,
[newPaid, newStatus, newStatus, invoice.id],
);
}
return payment[0];
}
async update(id: string, dto: any, userId: string) {
const existing = await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); }
if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); }
if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); }
if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); }
if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); }
if (!sets.length) return this.findOne(id);
params.push(id);
await this.tenant.query(
`UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
// If amount changed and payment is linked to an invoice, recalculate invoice totals
if (dto.amount !== undefined && existing.invoice_id) {
await this.recalculateInvoice(existing.invoice_id);
}
return this.findOne(id);
}
async delete(id: string) {
const payment = await this.findOne(id);
const invoiceId = payment.invoice_id;
// Delete associated journal entry lines and journal entry
if (payment.journal_entry_id) {
await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]);
await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]);
}
// Delete the payment
await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]);
// Recalculate invoice totals if payment was linked
if (invoiceId) {
await this.recalculateInvoice(invoiceId);
}
return { success: true };
}
private async recalculateInvoice(invoiceId: string) {
// Sum all remaining payments for this invoice
const result = await this.tenant.query(
'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1',
[invoiceId],
);
const totalPaid = parseFloat(result[0].total_paid);
// Get the invoice amount
const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]);
if (!inv.length) return;
const invoiceAmt = parseFloat(inv[0].amount);
let newStatus: string;
if (totalPaid >= invoiceAmt) {
newStatus = 'paid';
} else if (totalPaid > 0) {
newStatus = 'partial';
} else {
newStatus = 'pending';
}
await this.tenant.query(
`UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`,
[totalPaid, newStatus, newStatus, invoiceId],
);
}
}

View File

@@ -0,0 +1,57 @@
-- Migration 011: Add billing frequency support to invoices
-- Adds due_months and due_day to assessment_groups
-- Adds period_start, period_end, assessment_group_id to invoices
DO $$
DECLARE
v_schema TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Add due_months and due_day to assessment_groups
EXECUTE format('
ALTER TABLE %I.assessment_groups
ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1
', v_schema);
-- Add period tracking and assessment group link to invoices
EXECUTE format('
ALTER TABLE %I.invoices
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS assessment_group_id UUID
', v_schema);
-- Backfill due_months based on existing frequency values
EXECUTE format('
UPDATE %I.assessment_groups
SET due_months = CASE frequency
WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[]
WHEN ''annual'' THEN ''{1}''::INTEGER[]
ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[]
END
WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}''
AND frequency != ''monthly''
', v_schema);
-- Backfill period_start/period_end for existing invoices (all monthly)
EXECUTE format('
UPDATE %I.invoices
SET period_start = invoice_date,
period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE
WHERE period_start IS NULL AND invoice_type = ''regular_assessment''
', v_schema);
-- Backfill assessment_group_id on existing invoices from units
EXECUTE format('
UPDATE %I.invoices i
SET assessment_group_id = u.assessment_group_id
FROM %I.units u
WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL
', v_schema, v_schema);
END LOOP;
END $$;

View File

@@ -0,0 +1,33 @@
-- Migration 012: Replace 'sent' status with 'pending' for invoices
-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate
DO $$
DECLARE
v_schema TEXT;
v_constraint TEXT;
BEGIN
FOR v_schema IN
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
LOOP
-- Find and drop the existing status check constraint
SELECT constraint_name INTO v_constraint
FROM information_schema.table_constraints
WHERE table_schema = v_schema
AND table_name = 'invoices'
AND constraint_type = 'CHECK'
AND constraint_name LIKE '%status%';
IF v_constraint IS NOT NULL THEN
EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint);
END IF;
-- Add new constraint that includes 'pending'
EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN (
''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off''
))', v_schema);
-- Convert existing 'sent' invoices to 'pending'
EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema);
END LOOP;
END $$;

View File

@@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups (
special_assessment DECIMAL(10,2) DEFAULT 0.00,
unit_count INTEGER DEFAULT 0,
frequency VARCHAR(20) DEFAULT ''monthly'',
due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'',
due_day INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)', v_schema);
@@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices (
amount DECIMAL(10,2) NOT NULL,
amount_paid DECIMAL(10,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT ''draft'',
period_start DATE,
period_end DATE,
assessment_group_id UUID,
journal_entry_id UUID,
sent_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
@@ -443,10 +449,10 @@ END LOOP;
-- ============================================================
-- 4b. Seed Assessment Groups
-- ============================================================
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15)
EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES
(''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15),
(''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1),
(''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1)
', v_schema);
-- ============================================================

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta",
"version": "2026.3.7-beta",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
@@ -9,9 +9,12 @@ import {
IconUserCog,
IconUsersGroup,
IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
@@ -20,6 +23,7 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
@@ -108,6 +112,16 @@ export function AppLayout() {
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}>
<Menu.Target>
<UnstyledButton>

View File

@@ -10,6 +10,7 @@ import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
defaultOptions: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
);

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
MultiSelect,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
@@ -21,6 +22,8 @@ interface AssessmentGroup {
special_assessment: string;
unit_count: number;
frequency: string;
due_months: number[];
due_day: number;
actual_unit_count: string;
monthly_operating_income: string;
monthly_reserve_income: string;
@@ -49,6 +52,29 @@ const frequencyColors: Record<string, string> = {
annual: 'violet',
};
const MONTH_OPTIONS = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const DEFAULT_DUE_MONTHS: Record<string, string[]> = {
monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'],
quarterly: ['1','4','7','10'],
annual: ['1'],
};
export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
@@ -73,18 +99,31 @@ export function AssessmentGroupsPage() {
specialAssessment: 0,
unitCount: 0,
frequency: 'monthly',
dueMonths: DEFAULT_DUE_MONTHS.monthly,
dueDay: 1,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'),
dueMonths: (v, values) => {
if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months';
if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month';
return null;
},
dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'),
},
});
const saveMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/assessment-groups/${editing.id}`, values)
: api.post('/assessment-groups', values),
mutationFn: (values: any) => {
const payload = {
...values,
dueMonths: values.dueMonths.map(Number),
};
return editing
? api.put(`/assessment-groups/${editing.id}`, payload)
: api.post('/assessment-groups', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] });
@@ -121,6 +160,9 @@ export function AssessmentGroupsPage() {
const handleEdit = (group: AssessmentGroup) => {
setEditing(group);
const dueMonths = group.due_months
? group.due_months.map(String)
: DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly;
form.setValues({
name: group.name,
description: group.description || '',
@@ -128,6 +170,8 @@ export function AssessmentGroupsPage() {
specialAssessment: parseFloat(group.special_assessment || '0'),
unitCount: group.unit_count || 0,
frequency: group.frequency || 'monthly',
dueMonths,
dueDay: group.due_day || 1,
});
open();
};
@@ -138,6 +182,12 @@ export function AssessmentGroupsPage() {
open();
};
const handleFrequencyChange = (value: string | null) => {
if (!value) return;
form.setFieldValue('frequency', value);
form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly);
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -149,6 +199,11 @@ export function AssessmentGroupsPage() {
}
};
const formatDueMonths = (months: number[], frequency: string) => {
if (!months || frequency === 'monthly') return 'Every month';
return months.map((m) => MONTH_ABBREV[m]).join(', ');
};
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
@@ -219,6 +274,7 @@ export function AssessmentGroupsPage() {
<Table.Th>Group Name</Table.Th>
<Table.Th ta="center">Units</Table.Th>
<Table.Th>Frequency</Table.Th>
<Table.Th>Due Months</Table.Th>
<Table.Th ta="right">Regular Assessment</Table.Th>
<Table.Th ta="right">Special Assessment</Table.Th>
<Table.Th ta="right">Monthly Equiv.</Table.Th>
@@ -229,7 +285,7 @@ export function AssessmentGroupsPage() {
<Table.Tbody>
{groups.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc.
</Text>
@@ -263,6 +319,9 @@ export function AssessmentGroupsPage() {
{frequencyLabels[g.frequency] || 'Monthly'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{formatDueMonths(g.due_months, g.frequency)}</Text>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(g.regular_assessment)}{freqSuffix(g.frequency)}
</Table.Td>
@@ -322,8 +381,22 @@ export function AssessmentGroupsPage() {
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'annual', label: 'Annual' },
]}
{...form.getInputProps('frequency')}
value={form.values.frequency}
onChange={handleFrequencyChange}
/>
{form.values.frequency !== 'monthly' && (
<MultiSelect
label={form.values.frequency === 'quarterly' ? 'Billing Quarters (select 4 months)' : 'Due Month'}
description={form.values.frequency === 'quarterly'
? 'Select the first month of each quarter when assessments are due'
: 'Select the month when the annual assessment is due'}
data={MONTH_OPTIONS}
value={form.values.dueMonths}
onChange={(v) => form.setFieldValue('dueMonths', v)}
error={form.errors.dueMonths}
maxValues={form.values.frequency === 'annual' ? 1 : 4}
/>
)}
<Group grow>
<NumberInput
label={`Regular Assessment (per unit${freqSuffix(form.values.frequency)})`}
@@ -340,7 +413,16 @@ export function AssessmentGroupsPage() {
{...form.getInputProps('specialAssessment')}
/>
</Group>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<Group grow>
<NumberInput label="Expected Unit Count" min={0} {...form.getInputProps('unitCount')} />
<NumberInput
label="Due Day of Month"
description="Day invoices are due (1-28)"
min={1}
max={28}
{...form.getInputProps('dueDay')}
/>
</Group>
<Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>

View File

@@ -16,8 +16,8 @@ import {
IconRefresh,
IconInfoCircle,
} from '@tabler/icons-react';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
@@ -313,9 +313,9 @@ export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const queryClient = useQueryClient();
// Track whether last refresh attempt failed (per score type)
const [operatingFailed, setOperatingFailed] = useState(false);
const [reserveFailed, setReserveFailed] = useState(false);
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
const [reserveRefreshing, setReserveRefreshing] = useState(false);
const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'],
@@ -327,33 +327,66 @@ export function DashboardPage() {
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
enabled: !!currentOrg,
// Poll every 3 seconds while a refresh is in progress
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
});
// Separate mutations for each score type
const recalcOperatingMutation = useMutation({
mutationFn: () => api.post('/health-scores/calculate/operating'),
onSuccess: () => {
setOperatingFailed(false);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
onError: () => {
setOperatingFailed(true);
// Still refresh to get whatever the backend saved (could be cached data)
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
});
// Async refresh handlers — trigger the backend and poll for results
const handleRefreshOperating = useCallback(async () => {
const prevId = healthScores?.operating?.id;
setOperatingRefreshing(true);
try {
await api.post('/health-scores/calculate/operating');
} catch {
// Trigger failed at network level — polling will pick up any backend-saved error
}
// Start polling — watch for the health score to change (new id or updated timestamp)
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.operating;
if (newScore && newScore.id !== prevId) {
setOperatingRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
// Safety timeout — stop polling after 11 minutes
setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.operating?.id, queryClient]);
const recalcReserveMutation = useMutation({
mutationFn: () => api.post('/health-scores/calculate/reserve'),
onSuccess: () => {
setReserveFailed(false);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
onError: () => {
setReserveFailed(true);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
});
const handleRefreshReserve = useCallback(async () => {
const prevId = healthScores?.reserve?.id;
setReserveRefreshing(true);
try {
await api.post('/health-scores/calculate/reserve');
} catch {
// Trigger failed at network level
}
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.reserve;
if (newScore && newScore.id !== prevId) {
setReserveRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.reserve?.id, queryClient]);
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -381,7 +414,6 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center>
) : (
<>
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard
score={healthScores?.operating || null}
@@ -391,9 +423,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={recalcOperatingMutation.isPending}
onRefresh={() => recalcOperatingMutation.mutate()}
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
lastFailed={!!healthScores?.operating_last_failed}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -403,9 +435,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={recalcReserveMutation.isPending}
onRefresh={() => recalcReserveMutation.mutate()}
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
lastFailed={!!healthScores?.reserve_last_failed}
/>
</SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Title,
Text,
@@ -33,7 +33,7 @@ import {
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
@@ -107,6 +107,9 @@ interface SavedRecommendation {
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
// ── Helpers ──
@@ -181,14 +184,29 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
// ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
}) {
return (
<Stack>
{/* Last Updated timestamp */}
{/* Last Updated timestamp + failure message */}
{lastUpdated && (
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
{lastFailed && (
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
last analysis failed showing cached data
</Text>
)}
</Stack>
)}
{/* Overall Assessment */}
@@ -327,9 +345,8 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
// ── Main Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
// Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -349,50 +366,86 @@ export function InvestmentPlanningPage() {
},
});
// Load saved recommendation on mount
// Load saved recommendation — polls every 3s when processing
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
refetchInterval: (query) => {
const rec = query.state.data;
// Poll every 3 seconds while processing
if (rec?.status === 'processing') return 3000;
// Also poll if we just triggered (status may not be 'processing' yet)
if (isTriggering) return 3000;
return false;
},
});
// Populate AI results from saved recommendation on load
useEffect(() => {
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// Derive display state from saved recommendation
const isProcessing = savedRec?.status === 'processing' || isTriggering;
const lastFailed = savedRec?.last_failed || false;
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
return data as AIResponse;
},
onSuccess: (data) => {
setAiResult(data);
setLastUpdated(new Date().toISOString());
if (data.recommendations.length > 0) {
notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`,
color: 'green',
});
}
},
onError: (err: any) => {
// Clear triggering flag once backend confirms processing or completes
useEffect(() => {
if (isTriggering && savedRec?.status === 'processing') {
setIsTriggering(false);
}
if (isTriggering && savedRec?.status === 'complete') {
setIsTriggering(false);
}
}, [savedRec?.status, isTriggering]);
// Show notification when processing completes (transition from processing)
const prevStatusRef = useState<string | null>(null);
useEffect(() => {
const [prevStatus, setPrevStatus] = prevStatusRef;
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations',
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
});
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
});
},
});
}
setPrevStatus(savedRec?.status || null);
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
// Trigger AI recommendations (async — returns immediately)
const handleTriggerAI = useCallback(async () => {
setIsTriggering(true);
try {
await api.post('/investment-planning/recommendations');
} catch (err: any) {
setIsTriggering(false);
notifications.show({
message: err.response?.data?.message || 'Failed to start AI analysis',
color: 'red',
});
}
}, []);
// Build AI result from saved recommendation for display
const aiResult: AIResponse | null = hasResults
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: (lastFailed && savedRec?.recommendations?.length)
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: null;
if (snapshotLoading) {
return (
@@ -645,8 +698,8 @@ export function InvestmentPlanningPage() {
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={() => aiMutation.mutate()}
loading={aiMutation.isPending}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
@@ -654,8 +707,8 @@ export function InvestmentPlanningPage() {
</Button>
</Group>
{/* Loading State */}
{aiMutation.isPending && (
{/* Processing State */}
{isProcessing && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
@@ -663,19 +716,32 @@ export function InvestmentPlanningPage() {
Analyzing your financial data and market rates...
</Text>
<Text c="dimmed" size="xs">
This may take a few minutes for complex tenant data
You can navigate away results will appear when ready
</Text>
</Stack>
</Center>
)}
{/* Results */}
{aiResult && !aiMutation.isPending && (
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
{/* Error State (no cached data) */}
{hasError && !isProcessing && (
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
<Text size="sm">
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
</Text>
</Alert>
)}
{/* Results (with optional failure watermark) */}
{aiResult && !isProcessing && (
<RecommendationsDisplay
aiResult={aiResult}
lastUpdated={savedRec?.created_at || undefined}
lastFailed={lastFailed}
/>
)}
{/* Empty State */}
{!aiResult && !aiMutation.isPending && (
{!aiResult && !isProcessing && !hasError && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} />

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card,
NumberInput, Select, Loader, Center, Card, Alert,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconFileInvoice, IconSend } from '@tabler/icons-react';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -15,15 +14,55 @@ interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: string;
description: string; amount: string; amount_paid: string; balance_due: string;
status: string;
status: string; period_start: string; period_end: string;
assessment_group_name: string; frequency: string; owner_name: string;
}
interface PreviewGroup {
id: string;
name: string;
frequency: string;
active_units: number;
regular_assessment: string;
special_assessment: string;
is_billing_month: boolean;
total_amount: number;
period_description: string;
}
interface Preview {
month: number;
year: number;
month_name: string;
groups: PreviewGroup[];
summary: {
total_groups_billing: number;
total_invoices: number;
total_amount: number;
};
}
const statusColors: Record<string, string> = {
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
};
const frequencyColors: Record<string, string> = {
monthly: 'blue', quarterly: 'teal', annual: 'violet',
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
/** Extract last name from "First Last" format */
const getLastName = (ownerName: string | null) => {
if (!ownerName) return '-';
const parts = ownerName.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : ownerName;
};
export function InvoicesPage() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
@@ -35,13 +74,36 @@ export function InvoicesPage() {
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
});
// Fetch preview when month/year changes
const fetchPreview = async (month: number, year: number) => {
setPreviewLoading(true);
try {
const { data } = await api.post('/invoices/generate-preview', { month, year });
setPreview(data);
} catch {
setPreview(null);
}
setPreviewLoading(false);
};
useEffect(() => {
if (bulkOpened) {
fetchPreview(bulkForm.values.month, bulkForm.values.year);
}
}, [bulkOpened, bulkForm.values.month, bulkForm.values.year]);
const bulkMutation = useMutation({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' });
const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || '';
notifications.show({
message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`,
color: 'green',
});
closeBulk();
setPreview(null);
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
@@ -54,8 +116,6 @@ export function InvoicesPage() {
},
});
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>;
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
@@ -66,18 +126,20 @@ export function InvoicesPage() {
<Title order={2}>Invoices</Title>
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
</Group>
<Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(totalOutstanding)}</Text></Card>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</Table.Th><Table.Th ta="right">Amount</Table.Th>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Group</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Period</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th ta="right">Paid</Table.Th><Table.Th ta="right">Balance</Table.Th><Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -86,27 +148,104 @@ export function InvoicesPage() {
<Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
<Table.Td>{i.unit_number}</Table.Td>
<Table.Td>{getLastName(i.owner_name)}</Table.Td>
<Table.Td>
{i.assessment_group_name ? (
<Badge size="sm" variant="light" color={frequencyColors[i.frequency] || 'gray'}>
{i.assessment_group_name}
</Badge>
) : (
<Badge size="sm" variant="light">{i.invoice_type}</Badge>
)}
</Table.Td>
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td>
<Table.Td>
{i.period_start && i.period_end ? (
<Text size="xs" c="dimmed">
{new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}
{i.period_start !== i.period_end && (
<> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })}</>
)}
</Text>
) : (
<Text size="xs" c="dimmed">-</Text>
)}
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
</Table.Tr>
))}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={11}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
<Modal opened={bulkOpened} onClose={() => { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
<Stack>
<Group grow>
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</Group>
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button>
{previewLoading && <Center py="md"><Loader size="sm" /></Center>}
{preview && !previewLoading && (
<Stack gap="xs">
<Text size="sm" fw={600}>Billing Preview for {preview.month_name} {preview.year}</Text>
{preview.groups.map((g) => (
<Card key={g.id} withBorder p="xs" style={{ opacity: g.is_billing_month ? 1 : 0.5 }}>
<Group justify="space-between">
<Group gap="xs">
{g.is_billing_month && g.active_units > 0
? <IconCheck size={16} color="green" />
: <IconX size={16} color="gray" />
}
<div>
<Group gap={6}>
<Text size="sm" fw={500}>{g.name}</Text>
<Badge size="xs" color={frequencyColors[g.frequency]} variant="light">
{g.frequency}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{g.is_billing_month
? `${g.active_units} units - ${g.period_description}`
: `Not a billing month for this group`
}
</Text>
</div>
</Group>
{g.is_billing_month && (
<Text size="sm" fw={500} ff="monospace">{fmt(g.total_amount)}</Text>
)}
</Group>
</Card>
))}
{preview.summary.total_invoices > 0 ? (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Will generate {preview.summary.total_invoices} invoices across{' '}
{preview.summary.total_groups_billing} group(s) totaling {fmt(preview.summary.total_amount)}
</Alert>
) : (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
No assessment groups have billing scheduled for {preview.month_name}. No invoices will be generated.
</Alert>
)}
</Stack>
)}
<Button
type="submit"
loading={bulkMutation.isPending}
disabled={!preview || preview.summary.total_invoices === 0}
>
Generate {preview?.summary.total_invoices || 0} Invoices
</Button>
</Stack>
</form>
</Modal>

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center,
NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
@@ -15,11 +15,13 @@ import { useIsReadOnly } from '../../stores/authStore';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string;
invoice_number: string; payment_date: string; amount: string;
payment_method: string; reference_number: string; status: string;
payment_method: string; reference_number: string; status: string; notes: string;
}
export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
@@ -39,10 +41,18 @@ export function PaymentsPage() {
const form = useForm({
initialValues: {
invoice_id: '', amount: 0, payment_method: 'check',
reference_number: '', payment_date: new Date(),
reference_number: '', payment_date: new Date(), notes: '',
},
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
};
const createMutation = useMutation({
mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id);
@@ -53,22 +63,88 @@ export function PaymentsPage() {
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
invalidateAll();
notifications.show({ message: 'Payment recorded', color: 'green' });
close(); form.reset();
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const updateMutation = useMutation({
mutationFn: (values: any) => {
return api.put(`/payments/${editing!.id}`, {
payment_date: values.payment_date.toISOString().split('T')[0],
amount: values.amount,
payment_method: values.payment_method,
reference_number: values.reference_number,
notes: values.notes,
});
},
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payments/${id}`),
onSuccess: () => {
invalidateAll();
notifications.show({ message: 'Payment deleted', color: 'orange' });
setDeleteConfirm(null);
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (payment: Payment) => {
setEditing(payment);
form.setValues({
invoice_id: payment.invoice_id || '',
amount: parseFloat(payment.amount || '0'),
payment_method: payment.payment_method || 'check',
reference_number: payment.reference_number || '',
payment_date: new Date(payment.payment_date),
notes: payment.notes || '',
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const handleSubmit = (values: any) => {
if (editing) {
updateMutation.mutate(values);
} else {
createMutation.mutate(values);
}
};
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const invoiceOptions = invoices.map((i: any) => ({
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
}));
const formatPeriod = (inv: any) => {
if (inv.period_start && inv.period_end) {
const start = new Date(inv.period_start).toLocaleDateString(undefined, { month: 'short' });
const end = new Date(inv.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return inv.period_start === inv.period_end ? start : `${start}-${end}`;
}
return '';
};
const invoiceOptions = invoices.map((i: any) => {
const period = formatPeriod(i);
const periodStr = period ? ` - ${period}` : '';
return {
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'}${periodStr} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
};
});
if (isLoading) return <Center h={300}><Loader /></Center>;
@@ -76,7 +152,7 @@ export function PaymentsPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Payments</Title>
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>}
{!isReadOnly && <Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Record Payment</Button>}
</Group>
<Table striped highlightOnHover>
<Table.Thead>
@@ -84,6 +160,7 @@ export function PaymentsPage() {
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -96,18 +173,34 @@ export function PaymentsPage() {
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
<Table.Td>{p.reference_number}</Table.Td>
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit payment">
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
{payments.length === 0 && (
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
<Table.Tr><Table.Td colSpan={isReadOnly ? 7 : 8}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title="Record Payment">
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
{/* Create / Edit Payment Modal */}
<Modal opened={opened} onClose={() => { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
{!editing && (
<Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
)}
{editing && (
<TextInput label="Invoice" value={editing.invoice_number || 'N/A'} disabled />
)}
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} />
@@ -118,10 +211,60 @@ export function PaymentsPage() {
]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...form.getInputProps('reference_number')} />
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
<TextInput label="Notes" placeholder="Optional notes"
{...form.getInputProps('notes')} />
<Group justify="space-between">
{editing ? (
<>
<Button
variant="outline"
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => setDeleteConfirm(editing)}
>
Delete Payment
</Button>
<Button type="submit" loading={updateMutation.isPending}>
Update Payment
</Button>
</>
) : (
<Button type="submit" fullWidth loading={createMutation.isPending}>Record Payment</Button>
)}
</Group>
</Stack>
</form>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Delete Payment"
size="sm"
>
<Stack>
<Text size="sm">
Are you sure you want to delete this payment of{' '}
<Text span fw={700}>{deleteConfirm ? fmt(deleteConfirm.amount) : ''}</Text>{' '}
for unit {deleteConfirm?.unit_number}?
</Text>
<Text size="xs" c="dimmed">
This will also remove the associated journal entry and recalculate the invoice balance.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
>
Delete Payment
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -6,9 +6,11 @@ import {
IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return (
<Stack>
@@ -66,7 +68,10 @@ export function UserPreferencesPage() {
<Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div>
<Switch disabled />
<Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
</Group>
<Group justify="space-between">
<div>
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
<Switch disabled />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
</Stack>
</Card>

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.3.2 (beta)</Badge>
<Badge variant="light">2026.3.7 (Beta)</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: 'ledgeriq-preferences',
},
),
);

View File

@@ -23,21 +23,8 @@ server {
proxy_cache_bypass $http_upgrade;
}
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout needed — kept for documentation purposes
# Everything else -> Vite dev server (frontend)
location / {

View File

@@ -74,20 +74,8 @@ server {
proxy_send_timeout 15s;
}
# AI endpoints — longer timeouts (LLM calls can take minutes)
location /api/investment-planning/recommendations {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
location /api/health-scores/calculate {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# --- Frontend → React SPA served by nginx (port 3001) ---
location / {

View File

@@ -40,20 +40,8 @@ server {
proxy_send_timeout 15s;
}
# AI endpoints → longer timeouts
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
location /api/health-scores/calculate {
proxy_pass http://backend;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# --- Static frontend → built React assets ---
location / {

View File

@@ -60,37 +60,8 @@ server {
proxy_cache_bypass $http_upgrade;
}
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI health-score endpoint also needs a longer timeout
location /api/health-scores/calculate {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# Everything else -> Vite dev server (frontend)
location / {

150
scripts/reset-password.sh Executable file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# reset-password.sh — Reset a user's password in HOA LedgerIQ
#
# Usage:
# ./scripts/reset-password.sh <email> <new-password>
#
# Examples:
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
# If running with the SSL override, detect it
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
fi
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Helpers ----
ensure_containers_running() {
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
fi
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
die "Backend container is not running. Start it with: docker compose up -d backend"
fi
}
# ---- CLI ----
usage() {
cat <<EOF
HOA LedgerIQ Password Reset
Usage:
$(basename "$0") <email> <new-password>
Examples:
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
$(basename "$0") admin@sunrisevalley.org SecurePass!
This script:
1. Verifies the user exists in the database
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
3. Updates the password in the database
4. Verifies the new hash works
EOF
exit 0
}
# Parse args
case "${1:-}" in
-h|--help|help|"") usage ;;
esac
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
EMAIL="$1"
NEW_PASSWORD="$2"
# Load .env if present
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$PROJECT_DIR/.env"
set +a
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
fi
# Ensure containers are running
info "Checking containers ..."
ensure_containers_running
# Verify user exists
info "Looking up user: ${EMAIL} ..."
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
if [ -z "$USER_RECORD" ]; then
die "No user found with email: ${EMAIL}"
fi
# Parse user info for display
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
if [ "$IS_SUPER" = "t" ]; then
warn "This is a superadmin account"
fi
# Generate bcrypt hash using bcryptjs inside the backend container
info "Generating bcrypt hash ..."
HASH=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
" "$NEW_PASSWORD" 2>/dev/null)
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
die "Failed to generate bcrypt hash. Is the backend container running?"
fi
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
info "Updating password ..."
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
EOSQL
)
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
die "Password update failed. Result: ${UPDATE_RESULT}"
fi
# Verify the new hash works
info "Verifying new password ..."
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
if [ "$VERIFY" != "true" ]; then
die "Verification failed — the hash does not match the password. Something went wrong."
fi
echo ""
ok "Password reset successful!"
echo ""
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
info " Login: ${EMAIL}"
echo ""