Compare commits
9 Commits
feature-de
...
922674eca4
| Author | SHA1 | Date | |
|---|---|---|---|
| 922674eca4 | |||
| ce3dc79e47 | |||
| 72161f81f5 | |||
| ba072b90f0 | |||
| 4df796e977 | |||
| a7e3f80eda | |||
| cefcc296fb | |||
| f5bea7cdc2 | |||
| 5144da4680 |
@@ -2,6 +2,23 @@ import { Controller, Get } from '@nestjs/common';
|
|||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
|
/**
|
||||||
|
* GET /api — bare root of the API.
|
||||||
|
* Handles requests that omit the trailing slash so nginx's `location /api/`
|
||||||
|
* block (which requires a trailing slash) doesn't fall through to the Vite
|
||||||
|
* frontend proxy. Also gives New Relic and health checkers a real 200 rather
|
||||||
|
* than a 404 that would register as a phantom transaction.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
getRoot() {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'hoa-financial-platform',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/health — explicit named health endpoint for uptime monitors */
|
||||||
@Get('health')
|
@Get('health')
|
||||||
getHealth() {
|
getHealth() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TenantMiddleware } from './database/tenant.middleware';
|
|||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
import { CapabilityGuard } from './common/guards/capability.guard';
|
import { CapabilityGuard } from './common/guards/capability.guard';
|
||||||
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
|
import { NewRelicTransactionInterceptor } from './common/interceptors/newrelic-transaction.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
@@ -109,6 +110,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: NoCacheInterceptor,
|
useClass: NoCacheInterceptor,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: NewRelicTransactionInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a meaningful New Relic transaction name for every HTTP request.
|
||||||
|
*
|
||||||
|
* Without this, NestJS's setGlobalPrefix('api') causes the New Relic Express
|
||||||
|
* instrumentation to group ALL requests under the generic bucket
|
||||||
|
* "Expressjs/GET/api$" (the compiled regex for the global prefix router),
|
||||||
|
* making per-endpoint APM data completely blind.
|
||||||
|
*
|
||||||
|
* This interceptor runs after NestJS routing (so req.route is populated with
|
||||||
|
* the matched pattern, e.g. "/api/accounts/:id") and calls newrelic.setTransactionName()
|
||||||
|
* to override the auto-detected name with "METHOD /route/pattern".
|
||||||
|
*
|
||||||
|
* Gracefully no-ops when:
|
||||||
|
* - NEW_RELIC_ENABLED is not 'true' (dev / CI)
|
||||||
|
* - newrelic package is not installed
|
||||||
|
* - The NR agent fails to load for any reason
|
||||||
|
*/
|
||||||
|
|
||||||
|
let newrelic: any;
|
||||||
|
try {
|
||||||
|
if (process.env.NEW_RELIC_ENABLED === 'true') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
newrelic = require('newrelic');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Package not installed in this environment — skip instrumentation silently
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NewRelicTransactionInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
if (newrelic) {
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
// req.route.path is the Express matched route template (e.g. "/api/accounts/:id").
|
||||||
|
// Falls back to req.path (the actual URL) for unmatched requests so even
|
||||||
|
// 404s get a useful name like "GET /api/unknown-path" instead of "Expressjs/GET/api$".
|
||||||
|
const route: string = (req.route as any)?.path ?? req.path;
|
||||||
|
newrelic.setTransactionName(`${req.method} ${route}`);
|
||||||
|
}
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,8 +43,8 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Put('scenarios/:id')
|
@Put('scenarios/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
updateScenario(@Param('id') id: string, @Body() dto: any, @Req() req: any) {
|
||||||
return this.service.updateScenario(id, dto);
|
return this.service.updateScenario(id, dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('scenarios/:id')
|
@Delete('scenarios/:id')
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export class BoardPlanningService {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateScenario(id: string, dto: any) {
|
async updateScenario(id: string, dto: any, userId?: string) {
|
||||||
await this.getScenarioRow(id);
|
const existing = await this.getScenarioRow(id);
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`UPDATE board_scenarios SET
|
`UPDATE board_scenarios SET
|
||||||
name = COALESCE($2, name),
|
name = COALESCE($2, name),
|
||||||
@@ -63,7 +63,191 @@ export class BoardPlanningService {
|
|||||||
WHERE id = $1 RETURNING *`,
|
WHERE id = $1 RETURNING *`,
|
||||||
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
||||||
);
|
);
|
||||||
return rows[0];
|
const updated = rows[0];
|
||||||
|
|
||||||
|
// When a scenario first transitions to 'active', materialise all pending
|
||||||
|
// investments as real investment_accounts records, dated to their purchase_date.
|
||||||
|
if (dto.status === 'active' && existing.status !== 'active' && userId) {
|
||||||
|
await this.activateScenarioInvestments(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invalidateProjectionCache(id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activateScenarioInvestments(scenarioId: string, userId: string) {
|
||||||
|
const investments = await this.tenant.query(
|
||||||
|
`SELECT * FROM scenario_investments
|
||||||
|
WHERE scenario_id = $1 AND executed_investment_id IS NULL
|
||||||
|
ORDER BY sort_order, purchase_date`,
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const inv of investments) {
|
||||||
|
try {
|
||||||
|
await this.materialiseScenarioInvestment(inv, userId);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Log failure per-investment but don't abort the rest
|
||||||
|
console.error(`[scenario activation] Failed to execute investment ${inv.id} (${inv.label}):`, err?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async materialiseScenarioInvestment(inv: any, userId: string) {
|
||||||
|
const purchaseDate: string = inv.purchase_date
|
||||||
|
? (inv.purchase_date instanceof Date
|
||||||
|
? inv.purchase_date.toISOString().split('T')[0]
|
||||||
|
: String(inv.purchase_date).split('T')[0])
|
||||||
|
: new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Detect a CD rollover: an existing active investment in the same fund that
|
||||||
|
// matures within 7 days of this investment's purchase date.
|
||||||
|
const rolloverRows = await this.tenant.query(
|
||||||
|
`SELECT id, name, current_value, principal, interest_rate, maturity_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE fund_type = $1
|
||||||
|
AND is_active = true
|
||||||
|
AND maturity_date IS NOT NULL
|
||||||
|
AND ABS(maturity_date - $2::date) <= 7
|
||||||
|
ORDER BY ABS(maturity_date - $2::date)
|
||||||
|
LIMIT 1`,
|
||||||
|
[inv.fund_type, purchaseDate],
|
||||||
|
);
|
||||||
|
const isRollover = rolloverRows.length > 0;
|
||||||
|
const rolloverSource = isRollover ? rolloverRows[0] : null;
|
||||||
|
|
||||||
|
// 1. Create the real investment_accounts record (purchase_date = scenario date)
|
||||||
|
const invRows = await this.tenant.query(
|
||||||
|
`INSERT INTO investment_accounts
|
||||||
|
(name, institution, investment_type, fund_type, principal, interest_rate,
|
||||||
|
maturity_date, purchase_date, current_value, notes, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
inv.label,
|
||||||
|
inv.institution,
|
||||||
|
inv.investment_type || 'cd',
|
||||||
|
inv.fund_type,
|
||||||
|
inv.principal,
|
||||||
|
inv.interest_rate || 0,
|
||||||
|
inv.maturity_date,
|
||||||
|
purchaseDate,
|
||||||
|
inv.principal,
|
||||||
|
`Activated from scenario. ${inv.notes || ''}`.trim(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const realInvestment = invRows[0];
|
||||||
|
|
||||||
|
// 2. Journal entries — only if a fiscal period exists for the purchase month
|
||||||
|
const d = new Date(purchaseDate);
|
||||||
|
const yr = d.getFullYear();
|
||||||
|
const mo = d.getMonth() + 1;
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[yr, mo],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (periods.length) {
|
||||||
|
const equityAcctNum = inv.fund_type === 'reserve' ? '3100' : '3000';
|
||||||
|
const equityRows = await this.tenant.query(
|
||||||
|
'SELECT id FROM accounts WHERE account_number = $1 AND is_active = true LIMIT 1',
|
||||||
|
[equityAcctNum],
|
||||||
|
);
|
||||||
|
const primaryRows = await this.tenant.query(
|
||||||
|
`SELECT id FROM accounts
|
||||||
|
WHERE is_primary = true AND fund_type = $1 AND account_type = 'asset' AND is_active = true
|
||||||
|
LIMIT 1`,
|
||||||
|
[inv.fund_type],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (equityRows.length && primaryRows.length) {
|
||||||
|
const primaryId = primaryRows[0].id;
|
||||||
|
const equityId = equityRows[0].id;
|
||||||
|
const periodId = periods[0].id;
|
||||||
|
|
||||||
|
if (isRollover && rolloverSource) {
|
||||||
|
// ── Rollover path ──
|
||||||
|
// Step A: return maturing CD proceeds to primary cash
|
||||||
|
const srcValue = parseFloat(rolloverSource.current_value) || parseFloat(rolloverSource.principal) || 0;
|
||||||
|
const maturityMemo = `Maturity: ${rolloverSource.name} → rollover to ${inv.label}`;
|
||||||
|
|
||||||
|
const matJERows = await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
|
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4) RETURNING id`,
|
||||||
|
[purchaseDate, maturityMemo, periodId, userId],
|
||||||
|
);
|
||||||
|
const matJEId = matJERows[0].id;
|
||||||
|
// Debit primary cash (proceeds arrive)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, 0, $4)`,
|
||||||
|
[matJEId, primaryId, srcValue, maturityMemo],
|
||||||
|
);
|
||||||
|
// Credit equity (reverses the original investment transfer)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, 0, $3, $4)`,
|
||||||
|
[matJEId, equityId, srcValue, maturityMemo],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step B: deploy proceeds into the new CD
|
||||||
|
const reinvestMemo = `Rollover investment: ${inv.label}`;
|
||||||
|
const newJERows = await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
|
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4) RETURNING id`,
|
||||||
|
[purchaseDate, reinvestMemo, periodId, userId],
|
||||||
|
);
|
||||||
|
const newJEId = newJERows[0].id;
|
||||||
|
// Credit primary cash (funds deployed)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, 0, $3, $4)`,
|
||||||
|
[newJEId, primaryId, inv.principal, reinvestMemo],
|
||||||
|
);
|
||||||
|
// Debit equity (fund balance reduced by deployed amount)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, 0, $4)`,
|
||||||
|
[newJEId, equityId, inv.principal, reinvestMemo],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retire the source investment
|
||||||
|
await this.tenant.query(
|
||||||
|
'UPDATE investment_accounts SET is_active = false, updated_at = NOW() WHERE id = $1',
|
||||||
|
[rolloverSource.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ── Fresh purchase path ──
|
||||||
|
const memo = `Scenario investment: ${inv.label}`;
|
||||||
|
const jeRows = await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
|
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4) RETURNING id`,
|
||||||
|
[purchaseDate, memo, periodId, userId],
|
||||||
|
);
|
||||||
|
const jeId = jeRows[0].id;
|
||||||
|
// Credit primary cash (funds leave operating/reserve account)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, 0, $3, $4)`,
|
||||||
|
[jeId, primaryId, inv.principal, memo],
|
||||||
|
);
|
||||||
|
// Debit equity (marks the transfer out of the fund balance)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, 0, $4)`,
|
||||||
|
[jeId, equityId, inv.principal, memo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Link scenario investment back to the real account
|
||||||
|
await this.tenant.query(
|
||||||
|
'UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2',
|
||||||
|
[realInvestment.id, inv.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return realInvestment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteScenario(id: string) {
|
async deleteScenario(id: string) {
|
||||||
|
|||||||
@@ -162,26 +162,18 @@ export class JournalEntriesService {
|
|||||||
if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry');
|
if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry');
|
||||||
if (je.is_void) throw new BadRequestException('Already voided');
|
if (je.is_void) throw new BadRequestException('Already voided');
|
||||||
|
|
||||||
// Reverse account balances
|
|
||||||
for (const line of je.lines) {
|
|
||||||
const debit = parseFloat(line.debit) || 0;
|
|
||||||
const credit = parseFloat(line.credit) || 0;
|
|
||||||
const reverseAmount = credit - debit;
|
|
||||||
|
|
||||||
await this.tenant.query(
|
|
||||||
`UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[reverseAmount, line.account_id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`,
|
`UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`,
|
||||||
[userId, reason, id],
|
[userId, reason, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create reversing entry
|
// Create a reversing entry dated to the original entry's date so historical
|
||||||
|
// period balances stay accurate. The post() call handles accounts.balance updates —
|
||||||
|
// we do NOT manually reverse balances here to avoid double-counting.
|
||||||
const reverseDto: CreateJournalEntryDto = {
|
const reverseDto: CreateJournalEntryDto = {
|
||||||
entryDate: new Date().toISOString().split('T')[0],
|
entryDate: je.entry_date instanceof Date
|
||||||
|
? je.entry_date.toISOString().split('T')[0]
|
||||||
|
: String(je.entry_date).split('T')[0],
|
||||||
description: `VOID: ${je.description}`,
|
description: `VOID: ${je.description}`,
|
||||||
referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`,
|
referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`,
|
||||||
entryType: 'adjustment',
|
entryType: 'adjustment',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class MonthlyActualsService {
|
|||||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
AND je.is_posted = true AND je.is_void = false
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
AND je.entry_type = 'monthly_actual'
|
||||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||||
AND EXTRACT(MONTH FROM je.entry_date) = $2
|
AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||||
WHERE a.is_active = true
|
WHERE a.is_active = true
|
||||||
@@ -91,21 +92,28 @@ export class MonthlyActualsService {
|
|||||||
await this.journalEntriesService.void(entry.id, userId, `Replaced by updated monthly actuals for ${monthLabel} ${year}`);
|
await this.journalEntriesService.void(entry.id, userId, `Replaced by updated monthly actuals for ${monthLabel} ${year}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Find primary operating cash account (offset account for double-entry)
|
// 2. Find equity accounts per fund type to use as the double-entry clearing offset.
|
||||||
let cashAccounts = await this.tenant.query(
|
// Using equity instead of cash means monthly actuals never move the cash balance —
|
||||||
`SELECT id FROM accounts WHERE is_primary = true AND fund_type = 'operating' AND account_type = 'asset' LIMIT 1`,
|
// cash is tracked exclusively via real transaction journal entries.
|
||||||
|
// Equity normal balance is credit; a debit position here represents recognized income
|
||||||
|
// exceeding expenses (the P&L surplus cleared to fund balance).
|
||||||
|
const equityAccountRows = await this.tenant.query(
|
||||||
|
`SELECT id, fund_type FROM accounts
|
||||||
|
WHERE account_type = 'equity' AND is_active = true
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN account_number IN ('3000','3100') THEN 0 ELSE 1 END,
|
||||||
|
account_number`,
|
||||||
);
|
);
|
||||||
if (!cashAccounts.length) {
|
const equityByFund: Record<string, string> = {};
|
||||||
cashAccounts = await this.tenant.query(
|
for (const row of equityAccountRows) {
|
||||||
`SELECT id FROM accounts WHERE account_number = '1000' AND account_type = 'asset' LIMIT 1`,
|
if (!equityByFund[row.fund_type]) equityByFund[row.fund_type] = row.id;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!cashAccounts.length) {
|
const fallbackEquityId = equityByFund['operating'] || equityByFund['reserve'] || null;
|
||||||
|
if (!fallbackEquityId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No primary cash account found. Please set a primary operating account on the Accounts page.',
|
'No equity account found. Please ensure equity accounts (3000/3100) are set up in your Chart of Accounts.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const cashAccountId = cashAccounts[0].id;
|
|
||||||
|
|
||||||
// 3. Filter to lines with actual amounts
|
// 3. Filter to lines with actual amounts
|
||||||
const filteredLines = lines.filter((l) => l.amount !== 0);
|
const filteredLines = lines.filter((l) => l.amount !== 0);
|
||||||
@@ -113,53 +121,59 @@ export class MonthlyActualsService {
|
|||||||
return { message: 'No actuals to save', journal_entry_id: null };
|
return { message: 'No actuals to save', journal_entry_id: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Look up account types for each line
|
// 4. Look up account types AND fund types for each line
|
||||||
const accountIds = filteredLines.map((l) => l.accountId);
|
const accountIds = filteredLines.map((l) => l.accountId);
|
||||||
const accountRows = await this.tenant.query(
|
const accountRows = await this.tenant.query(
|
||||||
`SELECT id, account_type FROM accounts WHERE id = ANY($1)`,
|
`SELECT id, account_type, fund_type FROM accounts WHERE id = ANY($1)`,
|
||||||
[accountIds],
|
[accountIds],
|
||||||
);
|
);
|
||||||
const accountTypeMap = new Map(accountRows.map((a: any) => [a.id, a.account_type]));
|
const accountInfoMap = new Map<string, { type: string; fundType: string }>(
|
||||||
|
accountRows.map((a: any) => [a.id, { type: a.account_type as string, fundType: (a.fund_type || 'operating') as string }]),
|
||||||
|
);
|
||||||
|
|
||||||
// 5. Build journal entry lines
|
// 5. Build journal entry lines; track net equity offset per fund
|
||||||
const jeLines: any[] = [];
|
const jeLines: any[] = [];
|
||||||
let totalCashDebit = 0;
|
// equityNetByFund: positive → net debit on equity (income > expense for fund)
|
||||||
let totalCashCredit = 0;
|
// negative → net credit on equity (expense > income for fund)
|
||||||
|
const equityNetByFund: Record<string, number> = {};
|
||||||
|
|
||||||
for (const line of filteredLines) {
|
for (const line of filteredLines) {
|
||||||
const acctType = accountTypeMap.get(line.accountId);
|
const acctInfo = accountInfoMap.get(line.accountId);
|
||||||
if (!acctType) continue;
|
if (!acctInfo) continue;
|
||||||
const abs = Math.abs(line.amount);
|
const abs = Math.abs(line.amount);
|
||||||
|
const fund = acctInfo.fundType;
|
||||||
|
|
||||||
if (acctType === 'expense') {
|
if (acctInfo.type === 'expense') {
|
||||||
if (line.amount > 0) {
|
if (line.amount > 0) {
|
||||||
// Normal expense: debit expense, credit cash
|
|
||||||
jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual` });
|
jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual` });
|
||||||
totalCashCredit += abs;
|
equityNetByFund[fund] = (equityNetByFund[fund] || 0) + abs; // equity to be credited
|
||||||
} else {
|
} else {
|
||||||
// Negative expense (refund/correction): credit expense, debit cash
|
|
||||||
jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual (correction)` });
|
jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual (correction)` });
|
||||||
totalCashDebit += abs;
|
equityNetByFund[fund] = (equityNetByFund[fund] || 0) - abs;
|
||||||
}
|
}
|
||||||
} else if (acctType === 'income') {
|
} else if (acctInfo.type === 'income') {
|
||||||
if (line.amount > 0) {
|
if (line.amount > 0) {
|
||||||
// Normal income: credit income, debit cash
|
|
||||||
jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual` });
|
jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual` });
|
||||||
totalCashDebit += abs;
|
equityNetByFund[fund] = (equityNetByFund[fund] || 0) - abs; // equity to be debited
|
||||||
} else {
|
} else {
|
||||||
// Negative income (correction): debit income, credit cash
|
|
||||||
jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual (correction)` });
|
jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual (correction)` });
|
||||||
totalCashCredit += abs;
|
equityNetByFund[fund] = (equityNetByFund[fund] || 0) + abs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Add offsetting cash line(s) to balance the entry
|
// 6. Add one equity clearing line per fund to balance the entry
|
||||||
const netCash = totalCashDebit - totalCashCredit;
|
for (const [fund, net] of Object.entries(equityNetByFund)) {
|
||||||
if (netCash > 0) {
|
if (net === 0) continue;
|
||||||
jeLines.push({ accountId: cashAccountId, debit: netCash, credit: 0, memo: `${monthLabel} actuals offset` });
|
const equityId = equityByFund[fund] || fallbackEquityId;
|
||||||
} else if (netCash < 0) {
|
// net > 0 means expenses exceed income for this fund → credit equity (equity absorbs expense)
|
||||||
jeLines.push({ accountId: cashAccountId, debit: 0, credit: Math.abs(netCash), memo: `${monthLabel} actuals offset` });
|
// net < 0 means income exceeds expenses → debit equity (income clears through equity)
|
||||||
|
jeLines.push({
|
||||||
|
accountId: equityId,
|
||||||
|
debit: net < 0 ? Math.abs(net) : 0,
|
||||||
|
credit: net > 0 ? net : 0,
|
||||||
|
memo: `${monthLabel} actuals clearing`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Set entry_date to last day of the month
|
// 7. Set entry_date to last day of the month
|
||||||
|
|||||||
@@ -1008,6 +1008,19 @@ export class ReportsService {
|
|||||||
WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// ── 5b) Get investment purchases (cash converts to an investment balance in the
|
||||||
|
// month the CD is bought). Only investments purchased on/after startYear-01-01 are
|
||||||
|
// indexed here — anything earlier is already counted in the opening investment
|
||||||
|
// balance below. Without this, point-in-time opening balances would silently drop
|
||||||
|
// every CD bought during the charted window.
|
||||||
|
const purchases = await this.tenant.query(`
|
||||||
|
SELECT fund_type, current_value, purchase_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
AND purchase_date IS NOT NULL
|
||||||
|
AND purchase_date >= $1::date
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
// ── 6) Get capital project planned expenses ──
|
// ── 6) Get capital project planned expenses ──
|
||||||
const projectExpenses = await this.tenant.query(`
|
const projectExpenses = await this.tenant.query(`
|
||||||
SELECT estimated_cost, target_year, target_month, fund_source
|
SELECT estimated_cost, target_year, target_month, fund_source
|
||||||
@@ -1077,6 +1090,19 @@ export class ReportsService {
|
|||||||
else maturityIndex[key].reserve += maturityTotal;
|
else maturityIndex[key].reserve += maturityTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index investment purchases by year-month — added to the running investment
|
||||||
|
// balance in the month the CD was bought (applies to both historical & forecast
|
||||||
|
// months, since a purchase is a real event regardless of where "now" falls).
|
||||||
|
const purchaseIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const inv of purchases) {
|
||||||
|
const d = new Date(inv.purchase_date);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
if (!purchaseIndex[key]) purchaseIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const val = parseFloat(inv.current_value) || 0;
|
||||||
|
if (inv.fund_type === 'operating') purchaseIndex[key].operating += val;
|
||||||
|
else purchaseIndex[key].reserve += val;
|
||||||
|
}
|
||||||
|
|
||||||
// Index project expenses by year-month
|
// Index project expenses by year-month
|
||||||
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
for (const p of projectExpenses) {
|
for (const p of projectExpenses) {
|
||||||
@@ -1089,9 +1115,25 @@ export class ReportsService {
|
|||||||
else projectIndex[key].reserve += cost;
|
else projectIndex[key].reserve += cost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Investment opening balances at start of period (approximate: use current values)
|
// Investment balances at the start of the period — computed from the investment_accounts
|
||||||
let runOpInv = opInv;
|
// table as of startYear-01-01. We use current_value for all active investments that
|
||||||
let runResInv = resInv;
|
// existed before startYear (purchase_date < startYear-01-01). Investments purchased
|
||||||
|
// after that date will be added when their purchase month is processed in the forecast loop.
|
||||||
|
const openingInvOp = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE fund_type = 'operating' AND is_active = true
|
||||||
|
AND (purchase_date IS NULL OR purchase_date < $1::date)
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
const openingInvRes = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE fund_type = 'reserve' AND is_active = true
|
||||||
|
AND (purchase_date IS NULL OR purchase_date < $1::date)
|
||||||
|
`, [`${startYear}-01-01`]);
|
||||||
|
|
||||||
|
let runOpInv = parseFloat(openingInvOp[0]?.total || '0');
|
||||||
|
let runResInv = parseFloat(openingInvRes[0]?.total || '0');
|
||||||
|
|
||||||
// Determine which months have actual journal entries
|
// Determine which months have actual journal entries
|
||||||
// A month is "actual" only if it's not in the future AND has real journal entry data
|
// A month is "actual" only if it's not in the future AND has real journal entry data
|
||||||
@@ -1113,6 +1155,12 @@ export class ReportsService {
|
|||||||
const isHistorical = isPastMonth && hasActuals;
|
const isHistorical = isPastMonth && hasActuals;
|
||||||
const label = `${monthLabels[month - 1]} ${year}`;
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
|
||||||
|
// Apply investment purchases for this month before branching — a CD bought
|
||||||
|
// this month raises the investment balance whether the month is actual or forecast.
|
||||||
|
const purchased = purchaseIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
runOpInv += purchased.operating;
|
||||||
|
runResInv += purchased.reserve;
|
||||||
|
|
||||||
if (isHistorical) {
|
if (isHistorical) {
|
||||||
// Use actual journal entry changes from asset accounts
|
// Use actual journal entry changes from asset accounts
|
||||||
const opChange = histIndex[`${year}-${month}-operating`] || 0;
|
const opChange = histIndex[`${year}-${month}-operating`] || 0;
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
|
# Exact match for bare /api (no trailing slash).
|
||||||
|
# nginx's `location /api/` below requires a trailing slash, so a request for
|
||||||
|
# GET /api would fall through to the Vite proxy, which then forwards it to
|
||||||
|
# the backend — arriving as an unmatched path that New Relic registers as
|
||||||
|
# the phantom "Expressjs/GET/api$" transaction bucket.
|
||||||
|
# This exact-match block catches it first and proxies it directly to the
|
||||||
|
# backend, where AppController's @Get() handler returns a clean 200.
|
||||||
|
location = /api {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# API requests -> NestJS backend
|
# API requests -> NestJS backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ DB_USER="${POSTGRES_USER:-hoafinance}"
|
|||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||||
MIGRATION_DIR="$PROJECT_DIR/db/migrations"
|
MIGRATION_DIR="$PROJECT_DIR/db/migrations"
|
||||||
HEALTH_URL="http://localhost:3000/api"
|
HEALTH_URL="http://localhost:3000/api"
|
||||||
HEALTH_RETRIES=36
|
HEALTH_RETRIES=20
|
||||||
HEALTH_INTERVAL=5
|
HEALTH_INTERVAL=5
|
||||||
HEALTH_START_WAIT=10
|
HEALTH_START_WAIT=30
|
||||||
LOG_DIR="$PROJECT_DIR/logs"
|
LOG_DIR="$PROJECT_DIR/logs"
|
||||||
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
|
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
|
||||||
@@ -359,47 +359,38 @@ fi
|
|||||||
# ====================================================================
|
# ====================================================================
|
||||||
echo ""
|
echo ""
|
||||||
log "--- Step 5/6: Verifying application health ---"
|
log "--- Step 5/6: Verifying application health ---"
|
||||||
|
log "Waiting ${HEALTH_START_WAIT}s for backend to initialize (matches Docker start_period) ..."
|
||||||
# After a fresh image build, NestJS cold-start can take 2-3 minutes:
|
|
||||||
# New Relic init → TypeORM connections → Redis → BullMQ → NestJS bootstrap
|
|
||||||
# Docker's own healthcheck (start_period:30s + 3×15s retries = ~75s) is too
|
|
||||||
# aggressive and will mark the container "unhealthy" before the app finishes
|
|
||||||
# booting. So we do NOT rely on Docker's health status — we probe the HTTP
|
|
||||||
# endpoint directly from the host and give it up to ~3 minutes total.
|
|
||||||
TOTAL_WAIT=$((HEALTH_START_WAIT + HEALTH_RETRIES * HEALTH_INTERVAL))
|
|
||||||
log "Will wait up to ${TOTAL_WAIT}s for backend to respond at $HEALTH_URL ..."
|
|
||||||
sleep "$HEALTH_START_WAIT"
|
sleep "$HEALTH_START_WAIT"
|
||||||
|
|
||||||
|
# Primary check: Docker's own container health status
|
||||||
|
# (docker-compose.prod.yml already defines a healthcheck using wget inside the container)
|
||||||
HEALTHY=false
|
HEALTHY=false
|
||||||
for ((i=1; i<=HEALTH_RETRIES; i++)); do
|
for ((i=1; i<=HEALTH_RETRIES; i++)); do
|
||||||
# Direct HTTP check from the host using wget (available on Ubuntu)
|
CONTAINER_HEALTH=$($COMPOSE_CMD ps backend --format '{{.Health}}' 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$CONTAINER_HEALTH" = "healthy" ]; then
|
||||||
|
HEALTHY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also try a direct HTTP check from the host as a secondary signal
|
||||||
|
# Use wget (available on Ubuntu) since curl may not be installed
|
||||||
if wget -qO- --timeout=5 "$HEALTH_URL" >/dev/null 2>&1; then
|
if wget -qO- --timeout=5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
HEALTHY=true
|
HEALTHY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Also check Docker's container health for informational logging
|
log " Health check attempt $i/$HEALTH_RETRIES — container status: ${CONTAINER_HEALTH}, retrying in ${HEALTH_INTERVAL}s ..."
|
||||||
CONTAINER_HEALTH=$($COMPOSE_CMD ps backend --format '{{.Health}}' 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
# If the container exited or was removed, fail immediately — no point waiting
|
|
||||||
CONTAINER_STATUS=$($COMPOSE_CMD ps backend --format '{{.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
if echo "$CONTAINER_STATUS" | grep -qi "exit\|dead\|removed"; then
|
|
||||||
err "Backend container has stopped unexpectedly: $CONTAINER_STATUS"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
log " Health check attempt $i/$HEALTH_RETRIES — docker: ${CONTAINER_HEALTH}, retrying in ${HEALTH_INTERVAL}s ..."
|
|
||||||
sleep "$HEALTH_INTERVAL"
|
sleep "$HEALTH_INTERVAL"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$HEALTHY" = true ]; then
|
if [ "$HEALTHY" = true ]; then
|
||||||
ok "Backend is healthy and responding at $HEALTH_URL"
|
ok "Backend is healthy and responding"
|
||||||
else
|
else
|
||||||
# Log diagnostics before triggering rollback
|
# Log diagnostics before triggering rollback
|
||||||
err "Backend failed to respond after ${TOTAL_WAIT}s"
|
err "Backend failed to respond after $((HEALTH_START_WAIT + HEALTH_RETRIES * HEALTH_INTERVAL))s"
|
||||||
warn "Container status: $($COMPOSE_CMD ps backend 2>/dev/null || echo 'unknown')"
|
warn "Container status: $($COMPOSE_CMD ps backend 2>/dev/null || echo 'unknown')"
|
||||||
warn "Recent backend logs:"
|
warn "Recent backend logs:"
|
||||||
$COMPOSE_CMD logs --tail=30 backend 2>/dev/null || true
|
$COMPOSE_CMD logs --tail=20 backend 2>/dev/null || true
|
||||||
err "Triggering automatic rollback ..."
|
err "Triggering automatic rollback ..."
|
||||||
exit 1 # trap will handle rollback
|
exit 1 # trap will handle rollback
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user