Sprint 6: Monthly actuals input, reconciliation, and file attachments
Add spreadsheet-style Monthly Actuals page for entering monthly actuals against budget with auto-generated journal entries and reconciliation flag. Add file attachment support (PDF, images, spreadsheets) on journal entries for receipts and invoices. Enhance Budget vs Actual report with month filter dropdown. Add reconciled badge to Transactions page. Replace bcrypt with bcryptjs to fix Docker cross-platform native binding issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8878
backend/package-lock.json
generated
Normal file
8878
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.2",
|
||||
@@ -42,9 +42,10 @@
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.17.12",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
@@ -56,13 +57,23 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": { "^@/(.*)$": "<rootDir>/$1" }
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CapitalProjectsModule } from './modules/capital-projects/capital-projec
|
||||
import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-groups.module';
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -56,6 +58,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
|
||||
ReportsModule,
|
||||
AssessmentGroupsModule,
|
||||
ProjectsModule,
|
||||
MonthlyActualsModule,
|
||||
AttachmentsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ export class TenantSchemaService {
|
||||
reference_number VARCHAR(100),
|
||||
entry_type VARCHAR(50) NOT NULL CHECK (entry_type IN (
|
||||
'manual', 'assessment', 'payment', 'late_fee', 'transfer',
|
||||
'adjustment', 'closing', 'opening_balance'
|
||||
'adjustment', 'closing', 'opening_balance', 'monthly_actual'
|
||||
)),
|
||||
fiscal_period_id UUID NOT NULL REFERENCES "${s}".fiscal_periods(id),
|
||||
source_type VARCHAR(50),
|
||||
@@ -81,6 +81,7 @@ export class TenantSchemaService {
|
||||
posted_by UUID,
|
||||
posted_at TIMESTAMPTZ,
|
||||
is_void BOOLEAN DEFAULT FALSE,
|
||||
is_reconciled BOOLEAN DEFAULT FALSE,
|
||||
voided_by UUID,
|
||||
voided_at TIMESTAMPTZ,
|
||||
void_reason TEXT,
|
||||
@@ -313,7 +314,20 @@ export class TenantSchemaService {
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Attachments (file storage for receipts/invoices)
|
||||
`CREATE TABLE "${s}".attachments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
journal_entry_id UUID NOT NULL REFERENCES "${s}".journal_entries(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
file_data BYTEA NOT NULL,
|
||||
uploaded_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Indexes
|
||||
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
|
||||
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
|
||||
`CREATE INDEX "idx_${s}_je_fiscal" ON "${s}".journal_entries(fiscal_period_id)`,
|
||||
`CREATE INDEX "idx_${s}_jel_entry" ON "${s}".journal_entry_lines(journal_entry_id)`,
|
||||
|
||||
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
55
backend/src/modules/attachments/attachments.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller, Get, Post, Delete, Param, UseGuards, Request, Res,
|
||||
UseInterceptors, UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AttachmentsService } from './attachments.service';
|
||||
|
||||
@ApiTags('attachments')
|
||||
@Controller('attachments')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AttachmentsController {
|
||||
constructor(private attachmentsService: AttachmentsService) {}
|
||||
|
||||
@Post('journal-entry/:journalEntryId')
|
||||
@ApiOperation({ summary: 'Upload an attachment for a journal entry' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
}))
|
||||
async upload(
|
||||
@Param('journalEntryId') journalEntryId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.attachmentsService.upload(journalEntryId, file, req.user.sub);
|
||||
}
|
||||
|
||||
@Get('journal-entry/:journalEntryId')
|
||||
@ApiOperation({ summary: 'List attachments for a journal entry' })
|
||||
async listByJournalEntry(@Param('journalEntryId') journalEntryId: string) {
|
||||
return this.attachmentsService.findByJournalEntry(journalEntryId);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
@ApiOperation({ summary: 'Download an attachment' })
|
||||
async download(@Param('id') id: string, @Res() res: Response) {
|
||||
const attachment = await this.attachmentsService.download(id);
|
||||
res.set({
|
||||
'Content-Type': attachment.mime_type,
|
||||
'Content-Disposition': `attachment; filename="${attachment.filename}"`,
|
||||
'Content-Length': attachment.file_data.length,
|
||||
});
|
||||
res.send(attachment.file_data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an attachment' })
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.attachmentsService.delete(id);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/attachments/attachments.module.ts
Normal file
10
backend/src/modules/attachments/attachments.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentsController } from './attachments.controller';
|
||||
import { AttachmentsService } from './attachments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AttachmentsController],
|
||||
providers: [AttachmentsService],
|
||||
exports: [AttachmentsService],
|
||||
})
|
||||
export class AttachmentsModule {}
|
||||
78
backend/src/modules/attachments/attachments.service.ts
Normal file
78
backend/src/modules/attachments/attachments.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
|
||||
async upload(journalEntryId: string, file: Express.Multer.File, userId: string) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException('File size exceeds 10MB limit');
|
||||
}
|
||||
if (!ALLOWED_MIMES.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`File type "${file.mimetype}" not allowed. Allowed: PDF, JPEG, PNG, WebP, GIF, XLS, XLSX`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify journal entry exists
|
||||
const jeRows = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries WHERE id = $1`,
|
||||
[journalEntryId],
|
||||
);
|
||||
if (!jeRows.length) {
|
||||
throw new NotFoundException('Journal entry not found');
|
||||
}
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO attachments (journal_entry_id, filename, mime_type, file_size, file_data, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, filename, mime_type, file_size, created_at`,
|
||||
[journalEntryId, file.originalname, file.mimetype, file.size, file.buffer, userId],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findByJournalEntry(journalEntryId: string) {
|
||||
return this.tenant.query(
|
||||
`SELECT id, journal_entry_id, filename, mime_type, file_size, created_at
|
||||
FROM attachments
|
||||
WHERE journal_entry_id = $1
|
||||
ORDER BY created_at`,
|
||||
[journalEntryId],
|
||||
);
|
||||
}
|
||||
|
||||
async download(id: string) {
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT filename, mime_type, file_data FROM attachments WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!rows.length) throw new NotFoundException('Attachment not found');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const result = await this.tenant.query(
|
||||
`DELETE FROM attachments WHERE id = $1 RETURNING id`,
|
||||
[id],
|
||||
);
|
||||
if (!result.length) throw new NotFoundException('Attachment not found');
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@@ -40,7 +40,7 @@ export class CreateJournalEntryDto {
|
||||
referenceNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'manual', required: false })
|
||||
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance'])
|
||||
@IsIn(['manual', 'assessment', 'payment', 'late_fee', 'transfer', 'adjustment', 'closing', 'opening_balance', 'monthly_actual'])
|
||||
@IsOptional()
|
||||
entryType?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||
|
||||
@ApiTags('monthly-actuals')
|
||||
@Controller('monthly-actuals')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MonthlyActualsController {
|
||||
constructor(private monthlyActualsService: MonthlyActualsService) {}
|
||||
|
||||
@Get(':year/:month')
|
||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||
}
|
||||
|
||||
@Post(':year/:month')
|
||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||
async save(
|
||||
@Param('year') year: string,
|
||||
@Param('month') month: string,
|
||||
@Body() body: { lines: { accountId: string; amount: number }[] },
|
||||
@Request() req: any,
|
||||
) {
|
||||
return this.monthlyActualsService.saveActuals(
|
||||
parseInt(year),
|
||||
parseInt(month),
|
||||
body.lines,
|
||||
req.user.sub,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MonthlyActualsController } from './monthly-actuals.controller';
|
||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||
import { JournalEntriesModule } from '../journal-entries/journal-entries.module';
|
||||
|
||||
@Module({
|
||||
imports: [JournalEntriesModule],
|
||||
controllers: [MonthlyActualsController],
|
||||
providers: [MonthlyActualsService],
|
||||
})
|
||||
export class MonthlyActualsModule {}
|
||||
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
191
backend/src/modules/monthly-actuals/monthly-actuals.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
import { JournalEntriesService } from '../journal-entries/journal-entries.service';
|
||||
|
||||
const MONTH_COLS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
|
||||
const MONTH_LABELS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
@Injectable()
|
||||
export class MonthlyActualsService {
|
||||
constructor(
|
||||
private tenant: TenantService,
|
||||
private journalEntriesService: JournalEntriesService,
|
||||
) {}
|
||||
|
||||
async getActualsGrid(year: number, month: number) {
|
||||
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||
|
||||
const budgetCol = MONTH_COLS[month - 1];
|
||||
|
||||
// Get all income/expense accounts with budget amounts and existing actuals
|
||||
const rows = await this.tenant.query(
|
||||
`SELECT
|
||||
a.id as account_id,
|
||||
a.account_number,
|
||||
a.name as account_name,
|
||||
a.account_type,
|
||||
a.fund_type,
|
||||
COALESCE(b.${budgetCol}, 0) as budget_amount,
|
||||
COALESCE(SUM(
|
||||
CASE WHEN a.account_type IN ('expense', 'asset') THEN jel.debit - jel.credit
|
||||
ELSE jel.credit - jel.debit END
|
||||
), 0) as actual_amount
|
||||
FROM accounts a
|
||||
LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
AND EXTRACT(YEAR FROM je.entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||
WHERE a.is_active = true
|
||||
AND a.account_type IN ('income', 'expense')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||
b.${budgetCol}
|
||||
ORDER BY a.account_number`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
// Check if there's an existing monthly_actual journal entry for this month
|
||||
const existingJE = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries
|
||||
WHERE entry_type = 'monthly_actual'
|
||||
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||
AND is_void = false
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
month_label: MONTH_LABELS[month - 1],
|
||||
existing_journal_entry_id: existingJE[0]?.id || null,
|
||||
lines: rows.map((r: any) => ({
|
||||
...r,
|
||||
budget_amount: parseFloat(r.budget_amount || '0'),
|
||||
actual_amount: parseFloat(r.actual_amount || '0'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async saveActuals(year: number, month: number, lines: { accountId: string; amount: number }[], userId: string) {
|
||||
if (month < 1 || month > 12) throw new BadRequestException('Month must be 1-12');
|
||||
|
||||
const monthLabel = MONTH_LABELS[month - 1];
|
||||
|
||||
// 1. Void any existing monthly_actual entries for this year/month
|
||||
const existingEntries = await this.tenant.query(
|
||||
`SELECT id FROM journal_entries
|
||||
WHERE entry_type = 'monthly_actual'
|
||||
AND EXTRACT(YEAR FROM entry_date) = $1
|
||||
AND EXTRACT(MONTH FROM entry_date) = $2
|
||||
AND is_void = false`,
|
||||
[year, month],
|
||||
);
|
||||
|
||||
for (const entry of existingEntries) {
|
||||
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)
|
||||
let cashAccounts = await this.tenant.query(
|
||||
`SELECT id FROM accounts WHERE is_primary = true AND fund_type = 'operating' AND account_type = 'asset' LIMIT 1`,
|
||||
);
|
||||
if (!cashAccounts.length) {
|
||||
cashAccounts = await this.tenant.query(
|
||||
`SELECT id FROM accounts WHERE account_number = '1000' AND account_type = 'asset' LIMIT 1`,
|
||||
);
|
||||
}
|
||||
if (!cashAccounts.length) {
|
||||
throw new BadRequestException(
|
||||
'No primary cash account found. Please set a primary operating account on the Accounts page.',
|
||||
);
|
||||
}
|
||||
const cashAccountId = cashAccounts[0].id;
|
||||
|
||||
// 3. Filter to lines with actual amounts
|
||||
const filteredLines = lines.filter((l) => l.amount !== 0);
|
||||
if (filteredLines.length === 0) {
|
||||
return { message: 'No actuals to save', journal_entry_id: null };
|
||||
}
|
||||
|
||||
// 4. Look up account types for each line
|
||||
const accountIds = filteredLines.map((l) => l.accountId);
|
||||
const accountRows = await this.tenant.query(
|
||||
`SELECT id, account_type FROM accounts WHERE id = ANY($1)`,
|
||||
[accountIds],
|
||||
);
|
||||
const accountTypeMap = new Map(accountRows.map((a: any) => [a.id, a.account_type]));
|
||||
|
||||
// 5. Build journal entry lines
|
||||
const jeLines: any[] = [];
|
||||
let totalCashDebit = 0;
|
||||
let totalCashCredit = 0;
|
||||
|
||||
for (const line of filteredLines) {
|
||||
const acctType = accountTypeMap.get(line.accountId);
|
||||
if (!acctType) continue;
|
||||
|
||||
if (acctType === 'expense') {
|
||||
// Expense: debit expense account, credit cash
|
||||
jeLines.push({
|
||||
accountId: line.accountId,
|
||||
debit: Math.abs(line.amount),
|
||||
credit: 0,
|
||||
memo: `${monthLabel} actual`,
|
||||
});
|
||||
totalCashCredit += Math.abs(line.amount);
|
||||
} else if (acctType === 'income') {
|
||||
// Income: credit income account, debit cash
|
||||
jeLines.push({
|
||||
accountId: line.accountId,
|
||||
debit: 0,
|
||||
credit: Math.abs(line.amount),
|
||||
memo: `${monthLabel} actual`,
|
||||
});
|
||||
totalCashDebit += Math.abs(line.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Add offsetting cash line(s) to balance the entry
|
||||
const netCash = totalCashDebit - totalCashCredit;
|
||||
if (netCash > 0) {
|
||||
jeLines.push({ accountId: cashAccountId, debit: netCash, credit: 0, memo: `${monthLabel} actuals offset` });
|
||||
} else if (netCash < 0) {
|
||||
jeLines.push({ accountId: cashAccountId, debit: 0, credit: Math.abs(netCash), memo: `${monthLabel} actuals offset` });
|
||||
}
|
||||
|
||||
// 7. Set entry_date to last day of the month
|
||||
const lastDay = new Date(year, month, 0); // month is 1-indexed; new Date(2026, 1, 0) = Jan 31
|
||||
const dateStr = lastDay.toISOString().split('T')[0];
|
||||
|
||||
// 8. Create journal entry via existing service
|
||||
const je = await this.journalEntriesService.create(
|
||||
{
|
||||
entryDate: dateStr,
|
||||
description: `Monthly actuals for ${monthLabel} ${year}`,
|
||||
referenceNumber: `ACTUAL-${year}-${String(month).padStart(2, '0')}`,
|
||||
entryType: 'monthly_actual',
|
||||
lines: jeLines,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
// 9. Auto-post the entry
|
||||
await this.journalEntriesService.post(je.id, userId);
|
||||
|
||||
// 10. Set is_reconciled flag
|
||||
await this.tenant.query(
|
||||
`UPDATE journal_entries SET is_reconciled = true WHERE id = $1`,
|
||||
[je.id],
|
||||
);
|
||||
|
||||
return {
|
||||
message: `Saved ${filteredLines.length} actuals for ${monthLabel} ${year}`,
|
||||
journal_entry_id: je.id,
|
||||
lines_count: filteredLines.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Organization } from './entities/organization.entity';
|
||||
import { UserOrganization } from './entities/user-organization.entity';
|
||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsService {
|
||||
|
||||
Reference in New Issue
Block a user