Initial commit: Financials plugin v1.0.0
This commit is contained in:
commit
3136044d82
46 changed files with 7080 additions and 0 deletions
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to the Financials Plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2025-11-02
|
||||
|
||||
### Added
|
||||
- Comprehensive financial management system for HOA operations
|
||||
- Chart of accounts with account hierarchy
|
||||
- Budget management for monthly, quarterly, and annual periods
|
||||
- Budget variance reporting
|
||||
- Expense tracking and categorization
|
||||
- Revenue management and tracking
|
||||
- Financial reporting and analytics
|
||||
- Bank account management
|
||||
- Bank reconciliation functionality
|
||||
- General ledger with transaction tracking
|
||||
- Tax settings and tax management
|
||||
- Special assessments management
|
||||
- Unit balance tracking and calculation
|
||||
- Transaction management (debits, credits)
|
||||
- Payment processing and tracking
|
||||
- Invoice generation and management
|
||||
- Unit monthly fee management
|
||||
- Financial insights and dashboards
|
||||
|
||||
### Technical
|
||||
- Plugin structure following Etihadat plugin standards
|
||||
- BaseRepository pattern for database operations
|
||||
- Multi-tenant support with site isolation
|
||||
- RESTful API endpoints
|
||||
- Permission-based access control
|
||||
- Transaction-based balance calculation
|
||||
- Support for multiple currencies
|
||||
|
||||
### Dependencies
|
||||
- Core Etihadat API: ^2.3.0
|
||||
- Database abstraction via BaseRepository
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
- **1.0.0** (2025-11-02): Initial release
|
||||
|
||||
---
|
||||
|
||||
**Note**: For detailed development notes, see `version.txt`.
|
||||
|
||||
253
__tests__/helpers/testHelper.js
Normal file
253
__tests__/helpers/testHelper.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* Financial Plugin Test Helper
|
||||
*
|
||||
* Provides utilities for testing the financial plugin endpoints
|
||||
* Uses REAL PostgreSQL database - NO mocks
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Create test fixtures for financial plugin testing
|
||||
*/
|
||||
class FinancialTestHelper {
|
||||
constructor() {
|
||||
this.siteId = null;
|
||||
this.unitId = null;
|
||||
this.userId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test site
|
||||
*/
|
||||
async createTestSite() {
|
||||
const { siteRepository } = require('../../../src/repositories');
|
||||
|
||||
const siteData = {
|
||||
name: `Test Site ${Date.now()}`,
|
||||
address: '123 Test Street',
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
owner_name: 'Test Owner',
|
||||
owner_email: `owner-${Date.now()}@example.com`,
|
||||
phone: '+1234567890',
|
||||
plan_id: null,
|
||||
subscription_type: 'monthly',
|
||||
is_active: true,
|
||||
operational_mode: 'dev'
|
||||
};
|
||||
|
||||
const site = await siteRepository.createSite(siteData);
|
||||
this.siteId = site.id;
|
||||
return site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test building
|
||||
*/
|
||||
async createTestBuilding(siteId) {
|
||||
const { buildingRepository } = require('../../../src/repositories');
|
||||
|
||||
const buildingData = {
|
||||
site_id: siteId,
|
||||
name: 'Test Building',
|
||||
address: '123 Test Avenue',
|
||||
total_floors: 5,
|
||||
total_units: 10
|
||||
};
|
||||
|
||||
const building = await buildingRepository.createBuilding(buildingData);
|
||||
return building;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test unit
|
||||
*/
|
||||
async createTestUnit(siteId, buildingId) {
|
||||
const { unitsRepository } = require('../../../src/repositories');
|
||||
|
||||
const unitData = {
|
||||
site_id: siteId,
|
||||
building_id: buildingId,
|
||||
unit_number: `U${Date.now()}`,
|
||||
type: 'residential',
|
||||
size: 100,
|
||||
monthly_service_fee: 500,
|
||||
location: 'Floor 3'
|
||||
};
|
||||
|
||||
const unit = await unitsRepository.createUnit(unitData);
|
||||
this.unitId = unit.id;
|
||||
return unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test user for authentication
|
||||
*/
|
||||
async createTestUser(siteId) {
|
||||
const { userRepository } = require('../../../src/repositories');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const passwordHash = await bcrypt.hash('test123', 10);
|
||||
const userData = {
|
||||
email: `user-${Date.now()}@test.com`,
|
||||
name: 'Test User',
|
||||
role: 'hoa_admin',
|
||||
site_id: siteId,
|
||||
is_active: true,
|
||||
password_hash: passwordHash
|
||||
};
|
||||
|
||||
const user = await userRepository.createUser(userData);
|
||||
this.userId = user.id;
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Chart of Accounts entries for a site
|
||||
*/
|
||||
async createTestAccounts(siteId) {
|
||||
const { chartOfAccountsRepository } = require('../../repositories');
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
site_id: siteId,
|
||||
account_code: '1000',
|
||||
account_name: 'Cash',
|
||||
account_type: 'asset',
|
||||
description: 'Cash on hand',
|
||||
is_active: true,
|
||||
created_by: this.userId || 'test-user'
|
||||
},
|
||||
{
|
||||
site_id: siteId,
|
||||
account_code: '1100',
|
||||
account_name: 'Accounts Receivable',
|
||||
account_type: 'asset',
|
||||
description: 'Amounts owed by residents',
|
||||
is_active: true,
|
||||
created_by: this.userId || 'test-user'
|
||||
},
|
||||
{
|
||||
site_id: siteId,
|
||||
account_code: '2000',
|
||||
account_name: 'Accounts Payable',
|
||||
account_type: 'liability',
|
||||
description: 'Amounts owed to vendors',
|
||||
is_active: true,
|
||||
created_by: this.userId || 'test-user'
|
||||
}
|
||||
];
|
||||
|
||||
const createdAccounts = [];
|
||||
for (const account of accounts) {
|
||||
const created = await chartOfAccountsRepository.create(account);
|
||||
createdAccounts.push(created);
|
||||
}
|
||||
|
||||
return createdAccounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create unit balance record
|
||||
*/
|
||||
async createTestUnitBalance(unitId, siteId, startingBalance = 1000) {
|
||||
const { unitBalanceRepository } = require('../../repositories');
|
||||
|
||||
const balanceData = {
|
||||
unit_id: unitId,
|
||||
site_id: siteId,
|
||||
starting_balance: startingBalance,
|
||||
current_balance: startingBalance,
|
||||
created_by: this.userId || 'test-user'
|
||||
};
|
||||
|
||||
return await unitBalanceRepository.create(balanceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test invoice
|
||||
*/
|
||||
async createTestInvoice(unitId, siteId, amount = 500) {
|
||||
const { invoiceRepository } = require('../../repositories');
|
||||
|
||||
const today = new Date();
|
||||
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
|
||||
const invoiceData = {
|
||||
site_id: siteId,
|
||||
unit_id: unitId,
|
||||
invoice_number: `INV-${Date.now()}`,
|
||||
invoice_type: 'monthly_fee',
|
||||
description: 'Monthly maintenance fee',
|
||||
amount: amount,
|
||||
due_date: nextMonth.toISOString().split('T')[0],
|
||||
issue_date: today.toISOString().split('T')[0],
|
||||
status: 'pending',
|
||||
created_by: this.userId || 'test-user'
|
||||
};
|
||||
|
||||
return await invoiceRepository.create(invoiceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test payment
|
||||
*/
|
||||
async createTestPayment(unitId, siteId, amount = 300, invoiceId = null) {
|
||||
const { paymentRepository } = require('../../repositories');
|
||||
|
||||
const paymentData = {
|
||||
site_id: siteId,
|
||||
unit_id: unitId,
|
||||
invoice_id: invoiceId,
|
||||
payment_amount: amount,
|
||||
payment_method: 'cash',
|
||||
payment_reference: `PAY-${Date.now()}`,
|
||||
created_by: this.userId || 'test-user'
|
||||
};
|
||||
|
||||
return await paymentRepository.create(paymentData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test data
|
||||
*/
|
||||
async cleanup() {
|
||||
// Cleanup will be handled by the schema manager
|
||||
// This method is here for future use if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock request object for testing
|
||||
*/
|
||||
getMockRequest(siteId, userId) {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
site_id: siteId,
|
||||
role: 'hoa_admin'
|
||||
},
|
||||
query: {
|
||||
site_id: siteId
|
||||
},
|
||||
body: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock response object for testing
|
||||
*/
|
||||
getMockResponse() {
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis()
|
||||
};
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FinancialTestHelper;
|
||||
|
||||
|
||||
|
||||
347
__tests__/routes/accounts.test.js
Normal file
347
__tests__/routes/accounts.test.js
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* Chart of Accounts Route Tests
|
||||
*
|
||||
* Tests for `/api/plugins/financials/accounts` endpoints
|
||||
* Uses REAL PostgreSQL database - NO mocks
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const accountsRouter = require('../../routes/accounts');
|
||||
const FinancialTestHelper = require('../helpers/testHelper');
|
||||
|
||||
describe('Chart of Accounts Routes', () => {
|
||||
let app;
|
||||
let testHelper;
|
||||
let testSite;
|
||||
let testUserId;
|
||||
let chartOfAccountsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create Express app for testing
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/plugins/financials', accountsRouter);
|
||||
|
||||
// Initialize test helper
|
||||
testHelper = new FinancialTestHelper();
|
||||
|
||||
// Create test data
|
||||
testSite = await testHelper.createTestSite();
|
||||
testUserId = testHelper.userId;
|
||||
|
||||
// Import repository
|
||||
chartOfAccountsRepository = require('../../repositories').chartOfAccountsRepository;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup handled by global schema manager
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/accounts', () => {
|
||||
it('should return all accounts for a site', async () => {
|
||||
// Create test accounts
|
||||
const accountsData = [
|
||||
{
|
||||
site_id: testSite.id,
|
||||
account_code: '1000',
|
||||
account_name: 'Cash Account',
|
||||
account_type: 'asset',
|
||||
description: 'Cash and cash equivalents',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
},
|
||||
{
|
||||
site_id: testSite.id,
|
||||
account_code: '2000',
|
||||
account_name: 'Accounts Payable',
|
||||
account_type: 'liability',
|
||||
description: 'Amounts owed to vendors',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
}
|
||||
];
|
||||
|
||||
// Insert accounts using repository
|
||||
for (const account of accountsData) {
|
||||
await chartOfAccountsRepository.create(account);
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/accounts')
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify account structure matches expected format
|
||||
const account = response.body.data.find(a => a.account_code === '1000');
|
||||
expect(account).toBeDefined();
|
||||
expect(account.account_name).toBe('Cash Account');
|
||||
expect(account.account_type).toBe('asset');
|
||||
});
|
||||
|
||||
it('should return empty array if no accounts exist', async () => {
|
||||
// Create a different site with no accounts
|
||||
const anotherSite = await testHelper.createTestSite();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/accounts')
|
||||
.query({ site_id: anotherSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter accounts by active status', async () => {
|
||||
// Create active and inactive accounts
|
||||
await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '3000',
|
||||
account_name: 'Active Account',
|
||||
account_type: 'asset',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '4000',
|
||||
account_name: 'Inactive Account',
|
||||
account_type: 'asset',
|
||||
is_active: false,
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/accounts')
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should include both active and inactive (implementation detail)
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/accounts/:id', () => {
|
||||
let testAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test account
|
||||
testAccount = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '5000',
|
||||
account_name: 'Test Account',
|
||||
account_type: 'equity',
|
||||
description: 'Test account for detailed view',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should return account details by ID', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/accounts/${testAccount.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.id).toBe(testAccount.id);
|
||||
expect(response.body.data.account_code).toBe('5000');
|
||||
expect(response.body.data.account_name).toBe('Test Account');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent account', async () => {
|
||||
const fakeId = '00000000-0000-0000-0000-000000000000';
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/accounts/${fakeId}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugins/financials/accounts', () => {
|
||||
it('should create a new account', async () => {
|
||||
const newAccount = {
|
||||
site_id: testSite.id,
|
||||
account_code: '6000',
|
||||
account_name: 'New Test Account',
|
||||
account_type: 'expense',
|
||||
description: 'Test expense account',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/accounts')
|
||||
.send(newAccount);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.account_code).toBe('6000');
|
||||
expect(response.body.data.account_name).toBe('New Test Account');
|
||||
});
|
||||
|
||||
it('should reject duplicate account codes for same site', async () => {
|
||||
const account1 = {
|
||||
site_id: testSite.id,
|
||||
account_code: '7000',
|
||||
account_name: 'First Account',
|
||||
account_type: 'asset',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/api/plugins/financials/accounts')
|
||||
.send(account1);
|
||||
|
||||
// Try to create another with same code
|
||||
const account2 = {
|
||||
site_id: testSite.id,
|
||||
account_code: '7000',
|
||||
account_name: 'Duplicate Account',
|
||||
account_type: 'asset',
|
||||
is_active: true
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/accounts')
|
||||
.send(account2);
|
||||
|
||||
// Should fail validation
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('should require all required fields', async () => {
|
||||
const incompleteAccount = {
|
||||
site_id: testSite.id,
|
||||
account_name: 'Incomplete Account'
|
||||
// Missing account_code and account_type
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/accounts')
|
||||
.send(incompleteAccount);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/plugins/financials/accounts/:id', () => {
|
||||
let testAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
testAccount = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '8000',
|
||||
account_name: 'Original Name',
|
||||
account_type: 'revenue',
|
||||
description: 'Original description',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should update account details', async () => {
|
||||
const updateData = {
|
||||
account_name: 'Updated Name',
|
||||
description: 'Updated description',
|
||||
is_active: false
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/plugins/financials/accounts/${testAccount.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.account_name).toBe('Updated Name');
|
||||
expect(response.body.data.description).toBe('Updated description');
|
||||
expect(response.body.data.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent account', async () => {
|
||||
const fakeId = '00000000-0000-0000-0000-000000000000';
|
||||
const response = await request(app)
|
||||
.put(`/api/plugins/financials/accounts/${fakeId}`)
|
||||
.send({ account_name: 'Test' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/plugins/financials/accounts/:id', () => {
|
||||
let testAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
testAccount = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '9000',
|
||||
account_name: 'Account to Delete',
|
||||
account_type: 'asset',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete an account', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/plugins/financials/accounts/${testAccount.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
// Verify account is deleted
|
||||
const deletedAccount = await chartOfAccountsRepository.findById(testAccount.id);
|
||||
expect(deletedAccount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent account', async () => {
|
||||
const fakeId = '00000000-0000-0000-0000-000000000000';
|
||||
const response = await request(app)
|
||||
.delete(`/api/plugins/financials/accounts/${fakeId}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/accounts/:id/balance', () => {
|
||||
let testAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
testAccount = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '10000',
|
||||
account_name: 'Balance Test Account',
|
||||
account_type: 'asset',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should return account balance calculation', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/accounts/${testAccount.id}/balance`)
|
||||
.query({
|
||||
site_id: testSite.id,
|
||||
start_date: '2024-01-01',
|
||||
end_date: '2024-12-31'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.account_id).toBeDefined();
|
||||
// Balance calculation structure should exist
|
||||
// (exact structure depends on implementation)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
306
__tests__/routes/balances.test.js
Normal file
306
__tests__/routes/balances.test.js
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* Unit Balance Route Tests
|
||||
*
|
||||
* Tests for `/api/plugins/financials/balances` endpoints
|
||||
* CRITICAL - Frontend hook uses these endpoints
|
||||
* Uses REAL PostgreSQL database - NO mocks
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const balancesRouter = require('../../routes/balances');
|
||||
const FinancialTestHelper = require('../helpers/testHelper');
|
||||
|
||||
describe('Unit Balance Routes', () => {
|
||||
let app;
|
||||
let testHelper;
|
||||
let testSite;
|
||||
let testBuilding;
|
||||
let testUnit;
|
||||
let testUserId;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create Express app for testing
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/plugins/financials', balancesRouter);
|
||||
|
||||
// Initialize test helper
|
||||
testHelper = new FinancialTestHelper();
|
||||
|
||||
// Create test data
|
||||
testSite = await testHelper.createTestSite();
|
||||
testUserId = testHelper.userId;
|
||||
testBuilding = await testHelper.createTestBuilding(testSite.id);
|
||||
testUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup handled by global schema manager
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/balances/units/:unitId', () => {
|
||||
it('should calculate unit balance with real data', async () => {
|
||||
// Create starting balance
|
||||
await testHelper.createTestUnitBalance(testUnit.id, testSite.id, 1000);
|
||||
|
||||
// Create invoice (adds to balance)
|
||||
const invoice1 = await testHelper.createTestInvoice(testUnit.id, testSite.id, 500);
|
||||
|
||||
// Create payment (reduces balance)
|
||||
const payment1 = await testHelper.createTestPayment(testUnit.id, testSite.id, 200);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${testUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
// Verify balance structure matches frontend expectations
|
||||
const balance = response.body.data;
|
||||
expect(balance.unitId).toBe(testUnit.id);
|
||||
expect(balance.siteId).toBe(testSite.id);
|
||||
|
||||
// Should have these fields (matching frontend expectations)
|
||||
expect(balance.startingBalance).toBeDefined();
|
||||
expect(balance.monthlyFees).toBeDefined();
|
||||
expect(balance.serviceInvoices).toBeDefined();
|
||||
expect(balance.workOrderInvoices).toBeDefined();
|
||||
expect(balance.totalPayments).toBeDefined();
|
||||
expect(balance.currentBalance).toBeDefined();
|
||||
expect(balance.totalCharges).toBeDefined();
|
||||
expect(balance.lastUpdated).toBeDefined();
|
||||
|
||||
// Verify balance calculation is correct
|
||||
// currentBalance = startingBalance + charges - payments
|
||||
const calculated = balance.startingBalance + balance.totalCharges - balance.totalPayments;
|
||||
expect(balance.currentBalance).toBeCloseTo(calculated, 2);
|
||||
});
|
||||
|
||||
it('should return zero balances for unit with no transactions', async () => {
|
||||
// Create a new unit with no transactions
|
||||
const freshUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${freshUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.currentBalance).toBeDefined();
|
||||
// Should return zero or empty balance structure
|
||||
});
|
||||
|
||||
it('should calculate balance with multiple invoices and payments', async () => {
|
||||
// Create unit balance starting at 1000
|
||||
await testHelper.createTestUnitBalance(testSite.id, testBuilding.id, 1000);
|
||||
|
||||
// Create multiple invoices
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await testHelper.createTestInvoice(testSite.id, testBuilding.id, 500);
|
||||
}
|
||||
|
||||
// Create multiple payments
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await testHelper.createTestPayment(testSite.id, testBuilding.id, 300);
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${testSite.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const balance = response.body.data;
|
||||
|
||||
// Verify calculation
|
||||
// Expected: 1000 (starting) + 1500 (3 invoices) - 600 (2 payments) = 1900
|
||||
expect(balance.currentBalance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle negative balance (overpayment)', async () => {
|
||||
const negativeBalanceUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
|
||||
// Create large payment
|
||||
await testHelper.createTestPayment(
|
||||
negativeBalanceUnit.id,
|
||||
testSite.id,
|
||||
2000 // Large payment
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${negativeBalanceUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const balance = response.body.data;
|
||||
|
||||
// Balance can be negative (overpayment)
|
||||
expect(typeof balance.currentBalance).toBe('number');
|
||||
});
|
||||
|
||||
it('should require site_id parameter', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${testUnit.id}`);
|
||||
|
||||
// Should handle missing site_id gracefully
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugins/financials/balances/units/batch', () => {
|
||||
it('should return balances for multiple units', async () => {
|
||||
// Create multiple units
|
||||
const units = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const unit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
await testHelper.createTestUnitBalance(unit.id, testSite.id, 500 + i * 100);
|
||||
units.push(unit);
|
||||
}
|
||||
|
||||
const unitIds = units.map(u => u.id);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/balances/units/batch')
|
||||
.send({
|
||||
unit_ids: unitIds,
|
||||
site_id: testSite.id
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
// Should return object with unitId as keys
|
||||
expect(typeof response.body.data).toBe('object');
|
||||
expect(response.body.data[units[0].id]).toBeDefined();
|
||||
expect(response.body.data[units[1].id]).toBeDefined();
|
||||
expect(response.body.data[units[2].id]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty array', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/balances/units/batch')
|
||||
.send({
|
||||
unit_ids: [],
|
||||
site_id: testSite.id
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual({});
|
||||
});
|
||||
|
||||
it('should return balances for valid units and skip invalid ones', async () => {
|
||||
const validUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
await testHelper.createTestUnitBalance(validUnit.id, testSite.id, 500);
|
||||
|
||||
const invalidId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/balances/units/batch')
|
||||
.send({
|
||||
unit_ids: [validUnit.id, invalidId],
|
||||
site_id: testSite.id
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should return balance for valid unit
|
||||
// Should skip or handle invalid unit
|
||||
expect(response.body.data[validUnit.id]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Balance Calculation Formula Validation', () => {
|
||||
it('should calculate: currentBalance = startingBalance + charges - payments', async () => {
|
||||
const formulaTestUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
|
||||
const startingBalance = 1000;
|
||||
await testHelper.createTestUnitBalance(formulaTestUnit.id, testSite.id, startingBalance);
|
||||
|
||||
// Add charges (invoices)
|
||||
const invoice1 = await testHelper.createTestInvoice(formulaTestUnit.id, testSite.id, 500);
|
||||
const invoice2 = await testHelper.createTestInvoice(formulaTestUnit.id, testSite.id, 300);
|
||||
|
||||
// Add payments
|
||||
const payment1 = await testHelper.createTestPayment(formulaTestUnit.id, testSite.id, 400);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${formulaTestUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
const balance = response.body.data;
|
||||
|
||||
// Formula: startingBalance (1000) + charges (800) - payments (400) = 1400
|
||||
const expected = startingBalance + 800 - 400; // 1400
|
||||
expect(balance.currentBalance).toBeCloseTo(expected, 2);
|
||||
});
|
||||
|
||||
it('should include all invoice types in charges', async () => {
|
||||
const multiInvoiceUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
|
||||
await testHelper.createTestUnitBalance(multiInvoiceUnit.id, testSite.id, 500);
|
||||
|
||||
// Create different types of invoices
|
||||
await testHelper.createTestInvoice(multiInvoiceUnit.id, testSite.id, 300); // monthly_fee
|
||||
|
||||
// TODO: Add service invoices and work order invoices when those features are implemented
|
||||
// For now, just verify monthly fees are counted
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${multiInvoiceUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.totalCharges).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unit with no balance record (zero balance)', async () => {
|
||||
const newUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
// No balance record created
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${newUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
// Should return zero or empty balance
|
||||
});
|
||||
|
||||
it('should handle very large balance values', async () => {
|
||||
const largeBalanceUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
|
||||
const largeAmount = 999999.99;
|
||||
await testHelper.createTestUnitBalance(largeBalanceUnit.id, testSite.id, largeAmount);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${largeBalanceUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.currentBalance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return consistent balance across multiple requests', async () => {
|
||||
const consistencyTestUnit = await testHelper.createTestUnit(testSite.id, testBuilding.id);
|
||||
await testHelper.createTestUnitBalance(consistencyTestUnit.id, testSite.id, 500);
|
||||
|
||||
// Get balance multiple times
|
||||
const response1 = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${consistencyTestUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
const response2 = await request(app)
|
||||
.get(`/api/plugins/financials/balances/units/${consistencyTestUnit.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response1.body.data.currentBalance).toBe(response2.body.data.currentBalance);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
389
__tests__/routes/transactions.test.js
Normal file
389
__tests__/routes/transactions.test.js
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* Transaction Route Tests
|
||||
*
|
||||
* Tests for `/api/plugins/financials/transactions` endpoints
|
||||
* Uses REAL PostgreSQL database - NO mocks
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const transactionsRouter = require('../../routes/transactions');
|
||||
const FinancialTestHelper = require('../helpers/testHelper');
|
||||
|
||||
describe('Transaction Routes', () => {
|
||||
let app;
|
||||
let testHelper;
|
||||
let testSite;
|
||||
let testUserId;
|
||||
let testAccount1;
|
||||
let testAccount2;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create Express app for testing
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/plugins/financials', transactionsRouter);
|
||||
|
||||
// Initialize test helper
|
||||
testHelper = new FinancialTestHelper();
|
||||
|
||||
// Create test data
|
||||
testSite = await testHelper.createTestSite();
|
||||
testUserId = testHelper.userId;
|
||||
|
||||
// Create test accounts for double-entry
|
||||
const { chartOfAccountsRepository } = require('../../repositories');
|
||||
testAccount1 = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '11000',
|
||||
account_name: 'Test Asset Account',
|
||||
account_type: 'asset',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
testAccount2 = await chartOfAccountsRepository.create({
|
||||
site_id: testSite.id,
|
||||
account_code: '12000',
|
||||
account_name: 'Test Revenue Account',
|
||||
account_type: 'revenue',
|
||||
is_active: true,
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup handled by global schema manager
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/transactions', () => {
|
||||
it('should return all transactions for a site', async () => {
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
|
||||
// Create test transactions
|
||||
const transaction1 = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-15',
|
||||
reference_number: 'REF-001',
|
||||
description: 'Test transaction 1',
|
||||
transaction_type: 'income',
|
||||
amount: 500,
|
||||
currency: 'USD',
|
||||
status: 'approved',
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
const transaction2 = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-16',
|
||||
reference_number: 'REF-002',
|
||||
description: 'Test transaction 2',
|
||||
transaction_type: 'expense',
|
||||
amount: 200,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/transactions')
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should filter transactions by type', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/transactions')
|
||||
.query({
|
||||
site_id: testSite.id,
|
||||
type: 'income'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.every(t => t.transaction_type === 'income')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter transactions by date range', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/transactions')
|
||||
.query({
|
||||
site_id: testSite.id,
|
||||
start_date: '2024-01-01',
|
||||
end_date: '2024-12-31'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugins/financials/transactions', () => {
|
||||
it('should create a transaction with double-entry validation', async () => {
|
||||
const transactionData = {
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-20',
|
||||
reference_number: 'REF-003',
|
||||
description: 'Test double-entry transaction',
|
||||
transaction_type: 'transfer',
|
||||
amount: 1000,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId,
|
||||
// Double-entry lines
|
||||
debit_account_id: testAccount1.id,
|
||||
debit_amount: 1000,
|
||||
credit_account_id: testAccount2.id,
|
||||
credit_amount: 1000
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/transactions')
|
||||
.send(transactionData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.transaction_type).toBe('transfer');
|
||||
});
|
||||
|
||||
it('should reject transaction where debits do not equal credits', async () => {
|
||||
const invalidTransaction = {
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-20',
|
||||
description: 'Unbalanced transaction',
|
||||
transaction_type: 'transfer',
|
||||
amount: 1000,
|
||||
created_by: testUserId,
|
||||
// Unbalanced: debits (1000) != credits (500)
|
||||
debit_account_id: testAccount1.id,
|
||||
debit_amount: 1000,
|
||||
credit_account_id: testAccount2.id,
|
||||
credit_amount: 500
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/transactions')
|
||||
.send(invalidTransaction);
|
||||
|
||||
// Should reject unbalanced transaction
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should require all mandatory fields', async () => {
|
||||
const incompleteTransaction = {
|
||||
site_id: testSite.id,
|
||||
description: 'Incomplete transaction'
|
||||
// Missing required fields
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/transactions')
|
||||
.send(incompleteTransaction);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should create transaction lines for double-entry', async () => {
|
||||
const transactionData = {
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-21',
|
||||
reference_number: 'REF-004',
|
||||
description: 'Transaction with lines',
|
||||
transaction_type: 'income',
|
||||
amount: 500,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId,
|
||||
debit_account_id: testAccount1.id,
|
||||
debit_amount: 500,
|
||||
credit_account_id: testAccount2.id,
|
||||
credit_amount: 500
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/plugins/financials/transactions')
|
||||
.send(transactionData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// Transaction lines should be created
|
||||
// (will be verified by checking transaction_lines table)
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plugins/financials/transactions/:id', () => {
|
||||
let testTransaction;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
testTransaction = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-25',
|
||||
reference_number: 'REF-005',
|
||||
description: 'Detail test transaction',
|
||||
transaction_type: 'expense',
|
||||
amount: 300,
|
||||
currency: 'USD',
|
||||
status: 'approved',
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should return transaction details', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/transactions/${testTransaction.id}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.id).toBe(testTransaction.id);
|
||||
expect(response.body.data.description).toBe('Detail test transaction');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent transaction', async () => {
|
||||
const fakeId = '00000000-0000-0000-0000-000000000000';
|
||||
const response = await request(app)
|
||||
.get(`/api/plugins/financials/transactions/${fakeId}`)
|
||||
.query({ site_id: testSite.id });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/plugins/financials/transactions/:id/approve', () => {
|
||||
let pendingTransaction;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
pendingTransaction = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-26',
|
||||
reference_number: 'REF-006',
|
||||
description: 'Pending approval transaction',
|
||||
transaction_type: 'expense',
|
||||
amount: 400,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId
|
||||
});
|
||||
});
|
||||
|
||||
it('should approve a transaction', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/plugins/financials/transactions/${pendingTransaction.id}/approve`)
|
||||
.send({ approved_by: testUserId });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.status).toBe('approved');
|
||||
expect(response.body.data.approved_by).toBe(testUserId);
|
||||
expect(response.body.data.approved_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent transaction', async () => {
|
||||
const fakeId = '00000000-0000-0000-0000-000000000000';
|
||||
const response = await request(app)
|
||||
.put(`/api/plugins/financials/transactions/${fakeId}/approve`)
|
||||
.send({ approved_by: testUserId });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Double-Entry Validation', () => {
|
||||
it('should enforce debits equal credits when creating transaction lines', async () => {
|
||||
const { transactionLineRepository } = require('../../repositories');
|
||||
|
||||
// Create transaction first
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
const transaction = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-27',
|
||||
reference_number: 'REF-007',
|
||||
description: 'Balance test transaction',
|
||||
transaction_type: 'transfer',
|
||||
amount: 750,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
// Create balanced transaction lines
|
||||
const line1 = await transactionLineRepository.create({
|
||||
transaction_id: transaction.id,
|
||||
account_id: testAccount1.id,
|
||||
debit_amount: 750,
|
||||
credit_amount: 0
|
||||
});
|
||||
|
||||
const line2 = await transactionLineRepository.create({
|
||||
transaction_id: transaction.id,
|
||||
account_id: testAccount2.id,
|
||||
debit_amount: 0,
|
||||
credit_amount: 750
|
||||
});
|
||||
|
||||
// Verify lines are balanced
|
||||
const allLines = await transactionLineRepository.findByTransactionId(transaction.id);
|
||||
const totalDebits = allLines.reduce((sum, line) => sum + parseFloat(line.debit_amount || 0), 0);
|
||||
const totalCredits = allLines.reduce((sum, line) => sum + parseFloat(line.credit_amount || 0), 0);
|
||||
|
||||
expect(totalDebits).toBe(totalCredits);
|
||||
expect(totalDebits).toBe(750);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle transactions with zero amount', async () => {
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
const zeroTransaction = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-28',
|
||||
reference_number: 'REF-008',
|
||||
description: 'Zero amount transaction',
|
||||
transaction_type: 'transfer',
|
||||
amount: 0,
|
||||
currency: 'USD',
|
||||
status: 'pending',
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
expect(zeroTransaction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle transactions with decimal amounts', async () => {
|
||||
const { transactionRepository } = require('../../repositories');
|
||||
const decimalTransaction = await transactionRepository.create({
|
||||
site_id: testSite.id,
|
||||
transaction_date: '2024-01-29',
|
||||
reference_number: 'REF-009',
|
||||
description: 'Decimal amount transaction',
|
||||
transaction_type: 'income',
|
||||
amount: 123.45,
|
||||
currency: 'USD',
|
||||
status: 'approved',
|
||||
created_by: testUserId
|
||||
});
|
||||
|
||||
expect(decimalTransaction.amount).toBeCloseTo(123.45, 2);
|
||||
});
|
||||
|
||||
it('should filter transactions by status', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/plugins/financials/transactions')
|
||||
.query({
|
||||
site_id: testSite.id,
|
||||
status: 'approved'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.every(t => t.status === 'approved')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
373
database/schema.sql
Normal file
373
database/schema.sql
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
-- Financials Plugin Database Schema
|
||||
-- This schema is isolated in the plugin_financials namespace
|
||||
|
||||
-- Chart of Accounts
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.chart_of_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
account_code VARCHAR(20) NOT NULL,
|
||||
account_name VARCHAR(255) NOT NULL,
|
||||
account_type VARCHAR(50) NOT NULL, -- asset, liability, equity, revenue, expense
|
||||
parent_account_id UUID REFERENCES plugin_financials.chart_of_accounts(id),
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
UNIQUE(site_id, account_code)
|
||||
);
|
||||
|
||||
-- Budgets
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.budgets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
fiscal_year INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
total_amount DECIMAL(15,2) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'draft', -- draft, active, closed
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Budget Items
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.budget_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
budget_id UUID NOT NULL REFERENCES plugin_financials.budgets(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES plugin_financials.chart_of_accounts(id),
|
||||
planned_amount DECIMAL(15,2) NOT NULL,
|
||||
actual_amount DECIMAL(15,2) DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Transactions
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
transaction_date DATE NOT NULL,
|
||||
reference_number VARCHAR(50),
|
||||
description TEXT NOT NULL,
|
||||
transaction_type VARCHAR(20) NOT NULL, -- income, expense, transfer, opening_balance, adjustment
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected, posted
|
||||
category VARCHAR(100),
|
||||
vendor_id UUID,
|
||||
unit_id UUID,
|
||||
payment_method VARCHAR(50),
|
||||
notes TEXT,
|
||||
attachments JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Transaction Lines (Double-entry accounting)
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.transaction_lines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
transaction_id UUID NOT NULL REFERENCES plugin_financials.transactions(id) ON DELETE CASCADE,
|
||||
account_id UUID NOT NULL REFERENCES plugin_financials.chart_of_accounts(id),
|
||||
debit_amount DECIMAL(15,2) DEFAULT 0,
|
||||
credit_amount DECIMAL(15,2) DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Expenses
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.expenses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
expense_date DATE NOT NULL,
|
||||
vendor_name VARCHAR(255),
|
||||
vendor_id UUID,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||
total_amount DECIMAL(15,2) NOT NULL,
|
||||
payment_status VARCHAR(20) DEFAULT 'pending', -- pending, paid, overdue
|
||||
payment_method VARCHAR(50),
|
||||
receipt_url VARCHAR(500),
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMP WITH TIME ZONE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Revenue/Income
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.revenue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
revenue_date DATE NOT NULL,
|
||||
source VARCHAR(100) NOT NULL, -- maintenance_fees, late_fees, special_assessments, etc.
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
unit_id UUID,
|
||||
resident_id UUID,
|
||||
payment_status VARCHAR(20) DEFAULT 'pending', -- pending, received, overdue
|
||||
payment_method VARCHAR(50),
|
||||
receipt_url VARCHAR(500),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Unit Balance Tracking
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.unit_balances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
unit_id UUID NOT NULL,
|
||||
site_id UUID NOT NULL,
|
||||
current_balance DECIMAL(15,2) DEFAULT 0, -- Current total balance (calculated from transactions)
|
||||
last_updated TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
UNIQUE(unit_id, site_id)
|
||||
);
|
||||
|
||||
-- Invoices (for monthly fees, services, work orders, etc.)
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
unit_id UUID NOT NULL,
|
||||
invoice_number VARCHAR(50) NOT NULL,
|
||||
invoice_type VARCHAR(50) NOT NULL, -- monthly_fee, service, work_order, other
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
due_date DATE NOT NULL,
|
||||
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, paid, overdue, cancelled
|
||||
work_order_id UUID, -- Reference to work order if applicable
|
||||
payment_date DATE,
|
||||
payment_method VARCHAR(50),
|
||||
payment_reference VARCHAR(100),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
UNIQUE(site_id, invoice_number)
|
||||
);
|
||||
|
||||
-- Unit Monthly Fee Settings
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.unit_monthly_fees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
unit_id UUID NOT NULL,
|
||||
site_id UUID NOT NULL,
|
||||
monthly_fee_amount DECIMAL(15,2) NOT NULL,
|
||||
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
effective_to DATE, -- NULL means currently active
|
||||
fee_description TEXT DEFAULT 'Monthly maintenance fee',
|
||||
auto_generate_invoice BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
UNIQUE(unit_id, effective_from)
|
||||
);
|
||||
|
||||
-- Payments (tracks all payments made against invoices)
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
unit_id UUID NOT NULL,
|
||||
invoice_id UUID REFERENCES plugin_financials.invoices(id),
|
||||
payment_amount DECIMAL(15,2) NOT NULL,
|
||||
payment_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
payment_method VARCHAR(50) NOT NULL, -- cash, check, bank_transfer, credit_card, etc.
|
||||
payment_reference VARCHAR(100), -- check number, transaction ID, etc.
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Balance History (audit trail for balance changes)
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.balance_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
unit_id UUID NOT NULL,
|
||||
site_id UUID NOT NULL,
|
||||
change_type VARCHAR(50) NOT NULL, -- invoice_added, payment_made, adjustment, starting_balance
|
||||
change_amount DECIMAL(15,2) NOT NULL, -- positive for charges, negative for payments
|
||||
balance_before DECIMAL(15,2) NOT NULL,
|
||||
balance_after DECIMAL(15,2) NOT NULL,
|
||||
reference_id UUID, -- invoice_id, payment_id, etc.
|
||||
reference_type VARCHAR(50), -- invoice, payment, adjustment
|
||||
description TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_unit_balances_unit_id ON plugin_financials.unit_balances(unit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_unit_id ON plugin_financials.invoices(unit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON plugin_financials.invoices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON plugin_financials.invoices(due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_unit_id ON plugin_financials.payments(unit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_invoice_id ON plugin_financials.payments(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_history_unit_id ON plugin_financials.balance_history(unit_id);
|
||||
|
||||
-- Financial Reports
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
report_type VARCHAR(50) NOT NULL, -- income_statement, balance_sheet, cash_flow, budget_variance
|
||||
report_name VARCHAR(255) NOT NULL,
|
||||
report_date DATE NOT NULL,
|
||||
parameters JSONB,
|
||||
generated_by UUID NOT NULL,
|
||||
generated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
file_url VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'generated' -- generated, sent, archived
|
||||
);
|
||||
|
||||
-- Tax Settings
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.tax_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
tax_name VARCHAR(100) NOT NULL,
|
||||
tax_rate DECIMAL(5,4) NOT NULL,
|
||||
tax_type VARCHAR(20) NOT NULL, -- percentage, fixed
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
applies_to VARCHAR(100), -- all, expenses, specific_categories
|
||||
effective_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Financial Analytics
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.analytics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
metric_name VARCHAR(100) NOT NULL,
|
||||
metric_value DECIMAL(15,2) NOT NULL,
|
||||
metric_date DATE NOT NULL,
|
||||
category VARCHAR(100),
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Special Assessments
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.special_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
assessment_name VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
total_amount DECIMAL(15,2) NOT NULL,
|
||||
amount_per_unit DECIMAL(15,2) NOT NULL,
|
||||
due_date DATE NOT NULL,
|
||||
unit_count INTEGER NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, invoiced, collected, cancelled
|
||||
project_type VARCHAR(100), -- renovation, repair, upgrade, emergency
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Bank Accounts
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.bank_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
account_name VARCHAR(255) NOT NULL,
|
||||
bank_name VARCHAR(255) NOT NULL,
|
||||
account_number VARCHAR(100),
|
||||
routing_number VARCHAR(50),
|
||||
account_type VARCHAR(50) DEFAULT 'checking', -- checking, savings, reserve
|
||||
ledger_account_id UUID REFERENCES plugin_financials.chart_of_accounts(id),
|
||||
current_balance DECIMAL(15,2) DEFAULT 0,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Bank Statements
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.bank_statements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_account_id UUID NOT NULL REFERENCES plugin_financials.bank_accounts(id) ON DELETE CASCADE,
|
||||
site_id UUID NOT NULL,
|
||||
statement_date DATE NOT NULL,
|
||||
opening_balance DECIMAL(15,2) NOT NULL,
|
||||
closing_balance DECIMAL(15,2) NOT NULL,
|
||||
total_deposits DECIMAL(15,2) DEFAULT 0,
|
||||
total_withdrawals DECIMAL(15,2) DEFAULT 0,
|
||||
statement_file_url VARCHAR(500),
|
||||
is_reconciled BOOLEAN DEFAULT false,
|
||||
reconciled_date DATE,
|
||||
reconciled_by UUID,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Bank Statement Transactions
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.bank_statement_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_statement_id UUID NOT NULL REFERENCES plugin_financials.bank_statements(id) ON DELETE CASCADE,
|
||||
transaction_date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(15,2) NOT NULL,
|
||||
transaction_type VARCHAR(20) NOT NULL, -- deposit, withdrawal, fee, interest
|
||||
reference_number VARCHAR(100),
|
||||
is_matched BOOLEAN DEFAULT false,
|
||||
matched_transaction_id UUID, -- Links to plugin_financials.transactions if matched
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Bank Reconciliation Records
|
||||
CREATE TABLE IF NOT EXISTS plugin_financials.bank_reconciliation_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_id UUID NOT NULL,
|
||||
bank_account_id UUID NOT NULL REFERENCES plugin_financials.bank_accounts(id) ON DELETE CASCADE,
|
||||
statement_id UUID NOT NULL REFERENCES plugin_financials.bank_statements(id),
|
||||
reconciliation_date DATE NOT NULL,
|
||||
ledger_balance DECIMAL(15,2) NOT NULL,
|
||||
bank_balance DECIMAL(15,2) NOT NULL,
|
||||
outstanding_deposits DECIMAL(15,2) DEFAULT 0,
|
||||
outstanding_withdrawals DECIMAL(15,2) DEFAULT 0,
|
||||
adjustments DECIMAL(15,2) DEFAULT 0,
|
||||
reconciled_balance DECIMAL(15,2) NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by UUID NOT NULL,
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_chart_of_accounts_site_id ON plugin_financials.chart_of_accounts(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chart_of_accounts_account_type ON plugin_financials.chart_of_accounts(account_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_site_id ON plugin_financials.budgets(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_fiscal_year ON plugin_financials.budgets(fiscal_year);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_site_id ON plugin_financials.transactions(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON plugin_financials.transactions(transaction_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_type ON plugin_financials.transactions(transaction_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_site_id ON plugin_financials.expenses(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_date ON plugin_financials.expenses(expense_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_revenue_site_id ON plugin_financials.revenue(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_revenue_date ON plugin_financials.revenue(revenue_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_site_id ON plugin_financials.reports(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_site_id ON plugin_financials.analytics(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_date ON plugin_financials.analytics(metric_date);
|
||||
|
||||
-- Bank Reconciliation Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_accounts_site_id ON plugin_financials.bank_accounts(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_accounts_ledger_account ON plugin_financials.bank_accounts(ledger_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_statements_account ON plugin_financials.bank_statements(bank_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_statements_site_id ON plugin_financials.bank_statements(site_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_statement_transactions_statement ON plugin_financials.bank_statement_transactions(bank_statement_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_reconciliation_account ON plugin_financials.bank_reconciliation_records(bank_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bank_reconciliation_site_id ON plugin_financials.bank_reconciliation_records(site_id);
|
||||
97
plugin.json
Normal file
97
plugin.json
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"name": "financials",
|
||||
"displayName": "Financials Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Comprehensive financial management including accounting, budgeting, expense tracking, and financial reporting for HOA management",
|
||||
"apiVersion": "1.0",
|
||||
"author": "Etihadat Team",
|
||||
"website": "https://etihadat.com",
|
||||
"coreApiVersion": "^2.3.0",
|
||||
"coreApiMinVersion": "2.3.0",
|
||||
"coreApiMaxVersion": "3.0.0",
|
||||
"database": {
|
||||
"schema": "plugin_financials",
|
||||
"tables": [],
|
||||
"migrations": {
|
||||
"enabled": true,
|
||||
"path": "database/migrations"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"entryPoint": "routes/index.js",
|
||||
"exclude": ["__tests__", "node_modules", ".git", "build", ".DS_Store"],
|
||||
"include": ["database", "routes", "repositories", "services", "frontend"]
|
||||
},
|
||||
"frontend": {
|
||||
"enabled": true,
|
||||
"bundle": "frontend/bundle.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"core": "^2.3.0"
|
||||
},
|
||||
"permissions": [
|
||||
"view_financials",
|
||||
"manage_accounts",
|
||||
"create_budgets",
|
||||
"generate_reports",
|
||||
"manage_expenses",
|
||||
"manage_revenue",
|
||||
"view_analytics",
|
||||
"manage_tax_settings",
|
||||
"approve_transactions"
|
||||
],
|
||||
"pricingPlans": [
|
||||
{
|
||||
"name": "Starter",
|
||||
"monthlyPrice": 49,
|
||||
"annualPrice": 490,
|
||||
"features": [
|
||||
"Basic accounting",
|
||||
"Expense tracking",
|
||||
"Monthly reports",
|
||||
"Payment tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Professional",
|
||||
"monthlyPrice": 99,
|
||||
"annualPrice": 990,
|
||||
"features": [
|
||||
"Advanced accounting",
|
||||
"Budget management (monthly, quarterly, annual)",
|
||||
"Budget variance reporting",
|
||||
"Financial analytics",
|
||||
"Custom reports",
|
||||
"Multi-currency support",
|
||||
"Tax management",
|
||||
"Audit trails"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Enterprise",
|
||||
"monthlyPrice": 199,
|
||||
"annualPrice": 1990,
|
||||
"features": [
|
||||
"Full financial suite",
|
||||
"Advanced budget management (all periods)",
|
||||
"Multi-site management",
|
||||
"Advanced analytics",
|
||||
"API access",
|
||||
"Custom integrations",
|
||||
"Advanced reporting",
|
||||
"Financial forecasting",
|
||||
"Compliance management"
|
||||
]
|
||||
}
|
||||
],
|
||||
"routes": {
|
||||
"accounts": "/accounts",
|
||||
"budgets": "/budgets",
|
||||
"expenses": "/expenses",
|
||||
"revenue": "/revenue",
|
||||
"reports": "/reports",
|
||||
"analytics": "/analytics",
|
||||
"transactions": "/transactions",
|
||||
"tax": "/tax"
|
||||
}
|
||||
}
|
||||
178
repositories/AnalyticsRepository.js
Normal file
178
repositories/AnalyticsRepository.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Analytics Repository
|
||||
*
|
||||
* Manages financial analytics and metrics.
|
||||
*/
|
||||
class AnalyticsRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_analytics');
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'metric_date', orderDirection: 'desc' });
|
||||
}
|
||||
|
||||
async findByMetric(siteId, metricName) {
|
||||
return await this.findAll({ site_id: siteId, metric_name: metricName }, {
|
||||
orderBy: 'metric_date',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
}
|
||||
|
||||
async createAnalytic(analyticData) {
|
||||
return await this.create({
|
||||
...analyticData,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive financial analytics
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range options
|
||||
* @returns {Promise<Object>} Analytics data
|
||||
*/
|
||||
async getFinancialAnalytics(siteId, options = {}) {
|
||||
try {
|
||||
const { revenueRepository, expenseRepository } = require('./index');
|
||||
const { budgetRepository } = require('./index');
|
||||
const { transactionRepository, transactionLineRepository } = require('./index');
|
||||
const { chartOfAccountsRepository } = require('./index');
|
||||
|
||||
// Get date range
|
||||
const startDate = options.start_date || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0];
|
||||
const endDate = options.end_date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// Calculate total revenue
|
||||
const revenues = await revenueRepository.findAll({ site_id: siteId });
|
||||
let totalRevenue = 0;
|
||||
for (const rev of revenues) {
|
||||
if (rev.revenue_date >= startDate && rev.revenue_date <= endDate) {
|
||||
totalRevenue += parseFloat(rev.amount || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total expenses
|
||||
const expenses = await expenseRepository.findAll({ site_id: siteId });
|
||||
let totalExpenses = 0;
|
||||
for (const exp of expenses) {
|
||||
if (exp.expense_date >= startDate && exp.expense_date <= endDate) {
|
||||
totalExpenses += parseFloat(exp.amount || 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate net income
|
||||
const netIncome = totalRevenue - totalExpenses;
|
||||
|
||||
// Get budget for variance calculation
|
||||
let budgetVariance = null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const budgets = await budgetRepository.findAll({ site_id: siteId, fiscal_year: currentYear });
|
||||
if (budgets.length > 0) {
|
||||
const budget = budgets[0];
|
||||
const budgetedAmount = parseFloat(budget.total_amount || 0);
|
||||
const actualAmount = totalExpenses;
|
||||
budgetVariance = budgetedAmount > 0 ? ((actualAmount - budgetedAmount) / budgetedAmount * 100) : 0;
|
||||
}
|
||||
|
||||
// Calculate cash flow (simplified - from transactions)
|
||||
const transactions = await transactionRepository.findAll({ site_id: siteId });
|
||||
let cashFlow = 0;
|
||||
for (const trans of transactions) {
|
||||
if (trans.transaction_date >= startDate && trans.transaction_date <= endDate) {
|
||||
if (trans.transaction_type === 'income') {
|
||||
cashFlow += parseFloat(trans.amount || 0);
|
||||
} else if (trans.transaction_type === 'expense') {
|
||||
cashFlow -= parseFloat(trans.amount || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
period: {
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
},
|
||||
total_revenue: totalRevenue,
|
||||
total_expenses: totalExpenses,
|
||||
net_income: netIncome,
|
||||
budget_variance: budgetVariance,
|
||||
cash_flow: cashFlow
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to calculate financial analytics: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HOA-specific metrics
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Object>} HOA metrics
|
||||
*/
|
||||
async getHOAMetrics(siteId) {
|
||||
try {
|
||||
const { unitBalanceRepository, invoiceRepository, paymentRepository } = require('./index');
|
||||
|
||||
// Get all unit balances
|
||||
const unitBalances = await unitBalanceRepository.findAll({ site_id: siteId });
|
||||
|
||||
let totalOutstanding = 0;
|
||||
let totalPaid = 0;
|
||||
let unitCount = 0;
|
||||
|
||||
for (const unit of unitBalances) {
|
||||
const balance = await unitBalanceRepository.calculateUnitBalance(unit.unit_id, siteId);
|
||||
totalOutstanding += parseFloat(balance.current_balance || 0);
|
||||
unitCount++;
|
||||
}
|
||||
|
||||
// Get all invoices
|
||||
const invoices = await invoiceRepository.findAll({ site_id: siteId });
|
||||
let paidCount = 0;
|
||||
let overdueCount = 0;
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const amount = parseFloat(invoice.amount || 0);
|
||||
if (invoice.status === 'paid') {
|
||||
totalPaid += amount;
|
||||
paidCount++;
|
||||
} else if (invoice.status === 'overdue') {
|
||||
overdueCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent payments
|
||||
const payments = await paymentRepository.findAll({ site_id: siteId });
|
||||
const recentPayments = payments
|
||||
.filter(p => p.payment_date >= new Date(new Date().setMonth(new Date().getMonth() - 1)).toISOString().split('T')[0])
|
||||
.reduce((sum, p) => sum + parseFloat(p.payment_amount || 0), 0);
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
unit_metrics: {
|
||||
total_units: unitCount,
|
||||
total_outstanding: totalOutstanding,
|
||||
average_outstanding: unitCount > 0 ? totalOutstanding / unitCount : 0
|
||||
},
|
||||
invoice_metrics: {
|
||||
total_invoices: invoices.length,
|
||||
paid_invoices: paidCount,
|
||||
overdue_invoices: overdueCount,
|
||||
total_paid: totalPaid
|
||||
},
|
||||
recent_activity: {
|
||||
payments_last_month: recentPayments
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to calculate HOA metrics: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AnalyticsRepository;
|
||||
|
||||
|
||||
30
repositories/BalanceHistoryRepository.js
Normal file
30
repositories/BalanceHistoryRepository.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Balance History Repository
|
||||
*
|
||||
* Manages audit trail for balance changes.
|
||||
*/
|
||||
class BalanceHistoryRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_balance_history');
|
||||
}
|
||||
|
||||
async findByUnitId(unitId, siteId) {
|
||||
return await this.findAll({ unit_id: unitId, site_id: siteId }, {
|
||||
orderBy: 'created_at',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
}
|
||||
|
||||
async createHistoryEntry(entryData) {
|
||||
return await this.create({
|
||||
...entryData,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BalanceHistoryRepository;
|
||||
|
||||
|
||||
86
repositories/BankAccountRepository.js
Normal file
86
repositories/BankAccountRepository.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Bank Account Repository
|
||||
*
|
||||
* Manages bank account information for reconciliation purposes.
|
||||
*/
|
||||
class BankAccountRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_bank_accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bank accounts by site
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of bank accounts
|
||||
*/
|
||||
async findBySiteId(siteId) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, is_active: true });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bank account by ID with ledger account details
|
||||
* @param {string} accountId - Bank account ID
|
||||
* @returns {Promise<Object>} Bank account with ledger account details
|
||||
*/
|
||||
async findByIdWithLedger(accountId) {
|
||||
try {
|
||||
const account = await this.findById(accountId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get ledger account details if linked
|
||||
if (account.ledger_account_id) {
|
||||
const { chartOfAccountsRepository } = require('./index');
|
||||
const ledgerAccount = await chartOfAccountsRepository.findById(account.ledger_account_id);
|
||||
return {
|
||||
...account,
|
||||
ledger_account: ledgerAccount
|
||||
};
|
||||
}
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bank account
|
||||
* @param {Object} accountData - Bank account data
|
||||
* @returns {Promise<Object>} Created bank account
|
||||
*/
|
||||
async createBankAccount(accountData) {
|
||||
try {
|
||||
return await this.create(accountData);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bank account balance
|
||||
* @param {string} accountId - Bank account ID
|
||||
* @param {number} balance - New balance
|
||||
* @returns {Promise<Object>} Updated account
|
||||
*/
|
||||
async updateBalance(accountId, balance) {
|
||||
try {
|
||||
return await this.updateById(accountId, {
|
||||
current_balance: balance,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BankAccountRepository;
|
||||
|
||||
113
repositories/BankReconciliationRepository.js
Normal file
113
repositories/BankReconciliationRepository.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Bank Reconciliation Repository
|
||||
*
|
||||
* Manages bank reconciliation records and calculations.
|
||||
*/
|
||||
class BankReconciliationRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_bank_reconciliation_records');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reconciliations by bank account
|
||||
* @param {string} bankAccountId - Bank account ID
|
||||
* @returns {Promise<Array>} Array of reconciliation records
|
||||
*/
|
||||
async findByBankAccountId(bankAccountId) {
|
||||
try {
|
||||
return await this.findAll({ bank_account_id: bankAccountId });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create reconciliation record
|
||||
* @param {Object} reconciliationData - Reconciliation data
|
||||
* @returns {Promise<Object>} Created reconciliation
|
||||
*/
|
||||
async createReconciliation(reconciliationData) {
|
||||
try {
|
||||
return await this.create(reconciliationData);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate reconciliation metrics
|
||||
* @param {string} bankAccountId - Bank account ID
|
||||
* @param {string} asOfDate - Date as of
|
||||
* @returns {Promise<Object>} Reconciliation metrics
|
||||
*/
|
||||
async calculateReconciliationMetrics(bankAccountId, asOfDate) {
|
||||
try {
|
||||
const { bankAccountRepository, bankStatementRepository, transactionRepository, transactionLineRepository } = require('./index');
|
||||
const { chartOfAccountsRepository } = require('./index');
|
||||
|
||||
// Get bank account with ledger account
|
||||
const bankAccount = await bankAccountRepository.findByIdWithLedger(bankAccountId);
|
||||
if (!bankAccount) {
|
||||
throw new Error('Bank account not found');
|
||||
}
|
||||
|
||||
// Get ledger balance from transactions
|
||||
let ledgerBalance = 0;
|
||||
if (bankAccount.ledger_account_id) {
|
||||
const balance = await transactionLineRepository.findAll({
|
||||
account_id: bankAccount.ledger_account_id
|
||||
});
|
||||
|
||||
for (const line of balance) {
|
||||
const transaction = await transactionRepository.findById(line.transaction_id);
|
||||
if (transaction && transaction.transaction_date <= asOfDate) {
|
||||
ledgerBalance += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get bank balance
|
||||
const bankBalance = parseFloat(bankAccount.current_balance || 0);
|
||||
|
||||
// Calculate outstanding items
|
||||
const outstandingDeposits = 0; // TODO: Calculate from unreconciled deposits
|
||||
const outstandingWithdrawals = 0; // TODO: Calculate from unreconciled withdrawals
|
||||
|
||||
const reconciledBalance = bankBalance + outstandingDeposits - outstandingWithdrawals;
|
||||
|
||||
return {
|
||||
ledger_balance: ledgerBalance,
|
||||
bank_balance: bankBalance,
|
||||
outstanding_deposits: outstandingDeposits,
|
||||
outstanding_withdrawals: outstandingWithdrawals,
|
||||
reconciled_balance: reconciledBalance,
|
||||
variance: Math.abs(ledgerBalance - reconciledBalance)
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to calculate reconciliation metrics: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve reconciliation
|
||||
* @param {string} reconciliationId - Reconciliation ID
|
||||
* @param {string} userId - User ID who approved
|
||||
* @returns {Promise<Object>} Updated reconciliation
|
||||
*/
|
||||
async approveReconciliation(reconciliationId, userId) {
|
||||
try {
|
||||
return await this.updateById(reconciliationId, {
|
||||
approved_by: userId,
|
||||
approved_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BankReconciliationRepository;
|
||||
|
||||
89
repositories/BankStatementRepository.js
Normal file
89
repositories/BankStatementRepository.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Bank Statement Repository
|
||||
*
|
||||
* Manages bank statements and their transactions for reconciliation.
|
||||
*/
|
||||
class BankStatementRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_bank_statements');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find statements by bank account
|
||||
* @param {string} bankAccountId - Bank account ID
|
||||
* @returns {Promise<Array>} Array of bank statements
|
||||
*/
|
||||
async findByBankAccountId(bankAccountId) {
|
||||
try {
|
||||
return await this.findAll({ bank_account_id: bankAccountId });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unreconciled statements
|
||||
* @param {string} bankAccountId - Bank account ID
|
||||
* @returns {Promise<Array>} Array of unreconciled statements
|
||||
*/
|
||||
async findUnreconciled(bankAccountId) {
|
||||
try {
|
||||
return await this.findAll({
|
||||
bank_account_id: bankAccountId,
|
||||
is_reconciled: false
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bank statement with transactions
|
||||
* @param {Object} statementData - Statement data
|
||||
* @param {Array} transactions - Array of transaction data
|
||||
* @returns {Promise<Object>} Created statement with transactions
|
||||
*/
|
||||
async createStatementWithTransactions(statementData, transactions = []) {
|
||||
try {
|
||||
const statement = await this.create(statementData);
|
||||
|
||||
if (transactions.length > 0) {
|
||||
const { bankStatementTransactionRepository } = require('./index');
|
||||
for (const transaction of transactions) {
|
||||
await bankStatementTransactionRepository.create({
|
||||
...transaction,
|
||||
bank_statement_id: statement.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statement;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark statement as reconciled
|
||||
* @param {string} statementId - Statement ID
|
||||
* @param {string} userId - User ID who reconciled
|
||||
* @returns {Promise<Object>} Updated statement
|
||||
*/
|
||||
async markAsReconciled(statementId, userId) {
|
||||
try {
|
||||
return await this.updateById(statementId, {
|
||||
is_reconciled: true,
|
||||
reconciled_date: new Date(),
|
||||
reconciled_by: userId,
|
||||
updated_at: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BankStatementRepository;
|
||||
|
||||
77
repositories/BankStatementTransactionRepository.js
Normal file
77
repositories/BankStatementTransactionRepository.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Bank Statement Transaction Repository
|
||||
*
|
||||
* Manages individual transactions on bank statements.
|
||||
*/
|
||||
class BankStatementTransactionRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_bank_statement_transactions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by statement ID
|
||||
* @param {string} statementId - Statement ID
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByStatementId(statementId) {
|
||||
try {
|
||||
return await this.findAll({ bank_statement_id: statementId });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find unmatched transactions
|
||||
* @param {string} statementId - Statement ID
|
||||
* @returns {Promise<Array>} Array of unmatched transactions
|
||||
*/
|
||||
async findUnmatched(statementId) {
|
||||
try {
|
||||
return await this.findAll({
|
||||
bank_statement_id: statementId,
|
||||
is_matched: false
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match transaction with ledger transaction
|
||||
* @param {string} transactionId - Bank statement transaction ID
|
||||
* @param {string} ledgerTransactionId - Ledger transaction ID
|
||||
* @returns {Promise<Object>} Updated transaction
|
||||
*/
|
||||
async matchTransaction(transactionId, ledgerTransactionId) {
|
||||
try {
|
||||
return await this.updateById(transactionId, {
|
||||
is_matched: true,
|
||||
matched_transaction_id: ledgerTransactionId
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmatch transaction
|
||||
* @param {string} transactionId - Bank statement transaction ID
|
||||
* @returns {Promise<Object>} Updated transaction
|
||||
*/
|
||||
async unmatchTransaction(transactionId) {
|
||||
try {
|
||||
return await this.updateById(transactionId, {
|
||||
is_matched: false,
|
||||
matched_transaction_id: null
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BankStatementTransactionRepository;
|
||||
|
||||
235
repositories/BaseFinancialRepository.js
Normal file
235
repositories/BaseFinancialRepository.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
const BaseRepository = require('../../../src/database/repository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Base Financial Repository
|
||||
*
|
||||
* Extends core BaseRepository to handle pg_ prefixed plugin tables.
|
||||
* All financial plugin repositories should extend this class.
|
||||
*
|
||||
* Features:
|
||||
* - Uses pg_ prefix to identify plugin tables in main schema
|
||||
* - Inherits all BaseRepository methods (findOne, findAll, create, etc.)
|
||||
* - Multi-tenant support via site_id
|
||||
* - Database abstraction (works with any supported database)
|
||||
*/
|
||||
class BaseFinancialRepository extends BaseRepository {
|
||||
constructor(tableName) {
|
||||
super(tableName);
|
||||
this.schema = 'public'; // Use main schema with pg_ prefixed tables
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database connection
|
||||
* @returns {Object} Database connection
|
||||
*/
|
||||
getConnection() {
|
||||
if (global.dbConnection && typeof global.dbConnection.getConnection === 'function') {
|
||||
return global.dbConnection.getConnection();
|
||||
}
|
||||
throw new Error('No database connection available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection is Supabase client
|
||||
* @param {Object} connection
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupabase(connection) {
|
||||
return connection && typeof connection.from === 'function' && typeof connection.auth === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Override findAll to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async findAll(criteria = {}, options = {}) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
// Supabase style - use pg_ prefixed tables in public schema
|
||||
let query = connection.from(this.tableName).select('*');
|
||||
|
||||
// Apply criteria
|
||||
if (Object.keys(criteria).length > 0) {
|
||||
query = query.match(criteria);
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
if (options.orderBy) {
|
||||
const ascending = options.orderDirection === 'asc' || options.orderDirection === 'ASC';
|
||||
query = query.order(options.orderBy, { ascending });
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if (options.limit) query = query.limit(options.limit);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} else {
|
||||
// Knex style - for other databases
|
||||
return super.findAll(criteria, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override findOne to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async findOne(criteria) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const { data, error } = await connection
|
||||
.from(this.tableName)
|
||||
.select('*')
|
||||
.match(criteria)
|
||||
.limit(1);
|
||||
if (error) throw error;
|
||||
return data && data[0];
|
||||
} else {
|
||||
return super.findOne(criteria);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override create to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async create(data) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const { data: result, error } = await connection
|
||||
.from(this.tableName)
|
||||
.insert([data])
|
||||
.select();
|
||||
if (error) throw error;
|
||||
return result && result[0];
|
||||
} else {
|
||||
return super.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override updateById to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async updateById(id, updateData) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const { data, error } = await connection
|
||||
.from(this.tableName)
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.select();
|
||||
if (error) throw error;
|
||||
return data && data[0];
|
||||
} else {
|
||||
return super.updateById(id, updateData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override deleteById to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async deleteById(id) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const { error } = await connection
|
||||
.from(this.tableName)
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
} else {
|
||||
return super.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override count to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async count(criteria = {}) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const { count, error } = await connection
|
||||
.from(this.tableName)
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.match(criteria);
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
} else {
|
||||
return super.count(criteria);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override findWhere to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async findWhere(conditions, options = {}) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
let query = connection.from(this.tableName).select('*');
|
||||
|
||||
// Apply conditions
|
||||
for (const [field, condition] of Object.entries(conditions)) {
|
||||
if (condition.eq !== undefined) query = query.eq(field, condition.eq);
|
||||
if (condition.neq !== undefined) query = query.neq(field, condition.neq);
|
||||
if (condition.gt !== undefined) query = query.gt(field, condition.gt);
|
||||
if (condition.gte !== undefined) query = query.gte(field, condition.gte);
|
||||
if (condition.lt !== undefined) query = query.lt(field, condition.lt);
|
||||
if (condition.lte !== undefined) query = query.lte(field, condition.lte);
|
||||
if (condition.like !== undefined) query = query.like(field, condition.like);
|
||||
if (condition.in !== undefined) query = query.in(field, condition.in);
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
if (options.orderBy) {
|
||||
const ascending = options.orderDirection === 'asc' || options.orderDirection === 'ASC';
|
||||
query = query.order(options.orderBy, { ascending });
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if (options.limit) query = query.limit(options.limit);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
} else {
|
||||
return super.findWhere(conditions, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override paginate to handle pg_ prefixed tables in main schema
|
||||
*/
|
||||
async paginate(criteria = {}, page = 1, limit = 10, options = {}) {
|
||||
const connection = this.getConnection();
|
||||
if (this.isSupabase(connection)) {
|
||||
const offset = (page - 1) * limit;
|
||||
let query = connection
|
||||
.from(this.tableName)
|
||||
.select('*', { count: 'exact' })
|
||||
.match(criteria);
|
||||
query = query.range(offset, offset + limit - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
const total = typeof count === 'number' ? count : (data ? data.length : 0);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data: data || [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return super.paginate(criteria, page, limit, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseFinancialRepository;
|
||||
|
||||
|
||||
28
repositories/BudgetItemRepository.js
Normal file
28
repositories/BudgetItemRepository.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Budget Item Repository
|
||||
*
|
||||
* Manages budget line items.
|
||||
*/
|
||||
class BudgetItemRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_budget_items');
|
||||
}
|
||||
|
||||
async findByBudgetId(budgetId) {
|
||||
return await this.findAll({ budget_id: budgetId }, { orderBy: 'created_at' });
|
||||
}
|
||||
|
||||
async createItem(itemData) {
|
||||
return await this.create({
|
||||
...itemData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BudgetItemRepository;
|
||||
|
||||
|
||||
37
repositories/BudgetRepository.js
Normal file
37
repositories/BudgetRepository.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Budget Repository
|
||||
*
|
||||
* Manages budget planning and tracking (PREMIUM FEATURE).
|
||||
*/
|
||||
class BudgetRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_budgets');
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'fiscal_year' });
|
||||
}
|
||||
|
||||
async findByFiscalYear(siteId, fiscalYear) {
|
||||
return await this.findOne({ site_id: siteId, fiscal_year: fiscalYear });
|
||||
}
|
||||
|
||||
async createBudget(budgetData) {
|
||||
if (!budgetData.site_id || !budgetData.name || !budgetData.fiscal_year) {
|
||||
throw new Error('Missing required fields for budget');
|
||||
}
|
||||
|
||||
return await this.create({
|
||||
...budgetData,
|
||||
status: budgetData.status || 'draft',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BudgetRepository;
|
||||
|
||||
|
||||
183
repositories/ChartOfAccountsRepository.js
Normal file
183
repositories/ChartOfAccountsRepository.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Chart of Accounts Repository
|
||||
*
|
||||
* Manages financial chart of accounts for HOA operations.
|
||||
* Supports multi-tenant sites with account hierarchy.
|
||||
*/
|
||||
class ChartOfAccountsRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_chart_of_accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all accounts for a specific site
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Query options (orderBy, orderDirection, limit)
|
||||
* @returns {Promise<Array>} Array of accounts
|
||||
*/
|
||||
async findBySiteId(siteId, options = {}) {
|
||||
try {
|
||||
const defaultOptions = {
|
||||
orderBy: 'account_code',
|
||||
orderDirection: 'asc',
|
||||
...options
|
||||
};
|
||||
return await this.findAll({ site_id: siteId }, defaultOptions);
|
||||
} catch (error) {
|
||||
logger.error('Error finding accounts by site ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active accounts for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of active accounts
|
||||
*/
|
||||
async findActiveAccountsBySite(siteId) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, is_active: true }, { orderBy: 'account_code' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding active accounts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find accounts by type
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} accountType - Account type (asset, liability, equity, revenue, expense)
|
||||
* @returns {Promise<Array>} Array of accounts
|
||||
*/
|
||||
async findByType(siteId, accountType) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, account_type: accountType }, { orderBy: 'account_code' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding accounts by type:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find account by code for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} accountCode - Account code
|
||||
* @returns {Promise<Object|null>} Account or null
|
||||
*/
|
||||
async findByCode(siteId, accountCode) {
|
||||
try {
|
||||
return await this.findOne({ site_id: siteId, account_code: accountCode });
|
||||
} catch (error) {
|
||||
logger.error('Error finding account by code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find child accounts (accounts with a parent)
|
||||
* @param {string} parentAccountId - Parent account ID
|
||||
* @returns {Promise<Array>} Array of child accounts
|
||||
*/
|
||||
async findChildren(parentAccountId) {
|
||||
try {
|
||||
return await this.findAll({ parent_account_id: parentAccountId }, { orderBy: 'account_code' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding child accounts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create account with validation
|
||||
* @param {Object} accountData - Account data
|
||||
* @returns {Promise<Object>} Created account
|
||||
*/
|
||||
async createAccount(accountData) {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!accountData.site_id || !accountData.account_code || !accountData.account_name || !accountData.account_type) {
|
||||
throw new Error('Missing required fields: site_id, account_code, account_name, account_type');
|
||||
}
|
||||
|
||||
// Check if account code already exists for this site
|
||||
const existingAccount = await this.findByCode(accountData.site_id, accountData.account_code);
|
||||
if (existingAccount) {
|
||||
throw new Error('Account with this code already exists for this site');
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
const account = {
|
||||
...accountData,
|
||||
is_active: accountData.is_active !== undefined ? accountData.is_active : true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.create(account);
|
||||
} catch (error) {
|
||||
logger.error('Error creating account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account with validation
|
||||
* @param {string} accountId - Account ID
|
||||
* @param {Object} updateData - Update data
|
||||
* @returns {Promise<Object>} Updated account
|
||||
*/
|
||||
async updateAccount(accountId, updateData) {
|
||||
try {
|
||||
const update = {
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.updateById(accountId, update);
|
||||
} catch (error) {
|
||||
logger.error('Error updating account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account has transactions (used to prevent deletion)
|
||||
* @param {string} accountId - Account ID
|
||||
* @returns {Promise<boolean>} True if account has transactions
|
||||
*/
|
||||
async hasTransactions(accountId) {
|
||||
try {
|
||||
// This will be implemented when TransactionLineRepository is created
|
||||
// For now, we'll need to check manually
|
||||
// TODO: Add check using TransactionLineRepository when available
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Error checking account transactions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate account (soft delete)
|
||||
* @param {string} accountId - Account ID
|
||||
* @returns {Promise<Object>} Updated account
|
||||
*/
|
||||
async deactivate(accountId) {
|
||||
try {
|
||||
return await this.updateById(accountId, {
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChartOfAccountsRepository;
|
||||
|
||||
|
||||
38
repositories/ExpenseRepository.js
Normal file
38
repositories/ExpenseRepository.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Expense Repository
|
||||
*
|
||||
* Manages expense tracking.
|
||||
*/
|
||||
class ExpenseRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_expenses');
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'expense_date', orderDirection: 'desc' });
|
||||
}
|
||||
|
||||
async findByStatus(siteId, status) {
|
||||
return await this.findAll({ site_id: siteId, payment_status: status });
|
||||
}
|
||||
|
||||
async createExpense(expenseData) {
|
||||
if (!expenseData.site_id || !expenseData.expense_date || !expenseData.amount) {
|
||||
throw new Error('Missing required fields for expense');
|
||||
}
|
||||
|
||||
return await this.create({
|
||||
...expenseData,
|
||||
payment_status: expenseData.payment_status || 'pending',
|
||||
total_amount: parseFloat(expenseData.amount) + parseFloat(expenseData.tax_amount || 0),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExpenseRepository;
|
||||
|
||||
|
||||
229
repositories/GeneralLedgerRepository.js
Normal file
229
repositories/GeneralLedgerRepository.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* General Ledger Repository
|
||||
*
|
||||
* Manages general ledger functionality by aggregating transaction lines by account.
|
||||
* Provides running balances, account history, and ledger views.
|
||||
*/
|
||||
class GeneralLedgerRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_transaction_lines'); // Work with transaction_lines for ledger
|
||||
this.transactionRepository = null;
|
||||
this.transactionLineRepository = null;
|
||||
|
||||
// Lazy load to avoid circular dependencies
|
||||
this.getRepositories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required repositories
|
||||
*/
|
||||
getRepositories() {
|
||||
if (!this.transactionRepository) {
|
||||
const { transactionRepository, transactionLineRepository } = require('./index');
|
||||
this.transactionRepository = transactionRepository;
|
||||
this.transactionLineRepository = transactionLineRepository;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ledger entries for an account with running balance
|
||||
* @param {string} accountId - Account ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range and filter options
|
||||
* @returns {Promise<Array>} Array of ledger entries with running balance
|
||||
*/
|
||||
async getAccountLedger(accountId, siteId, options = {}) {
|
||||
try {
|
||||
const { startDate, endDate } = options;
|
||||
|
||||
// Get all transaction lines for this account within the date range
|
||||
const query = {
|
||||
account_id: accountId
|
||||
};
|
||||
|
||||
// If we have site_id in transaction_lines, we can filter by it
|
||||
// Otherwise, we need to join with transactions table
|
||||
const allLines = await this.transactionLineRepository.findAll(query);
|
||||
|
||||
// Get related transactions to filter by site_id and date
|
||||
const linesWithTransactions = await Promise.all(
|
||||
allLines.map(async (line) => {
|
||||
const transaction = await this.transactionRepository.findById(line.transaction_id);
|
||||
return { ...line, transaction };
|
||||
})
|
||||
);
|
||||
|
||||
// Filter by site_id and date range
|
||||
let filteredLines = linesWithTransactions.filter(line => {
|
||||
if (line.transaction?.site_id !== siteId) {
|
||||
return false;
|
||||
}
|
||||
if (startDate && line.transaction?.transaction_date < startDate) {
|
||||
return false;
|
||||
}
|
||||
if (endDate && line.transaction?.transaction_date > endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate running balance
|
||||
let runningBalance = 0;
|
||||
const ledgerEntries = filteredLines.map(line => {
|
||||
const debitAmount = parseFloat(line.debit_amount || 0);
|
||||
const creditAmount = parseFloat(line.credit_amount || 0);
|
||||
|
||||
// Calculate balance change (debits increase assets, credits decrease)
|
||||
// For equity/revenue/liabilities it's the opposite
|
||||
const balanceChange = debitAmount - creditAmount;
|
||||
runningBalance += balanceChange;
|
||||
|
||||
return {
|
||||
id: line.id,
|
||||
transaction_id: line.transaction_id,
|
||||
transaction_date: line.transaction?.transaction_date,
|
||||
description: line.description || line.transaction?.description,
|
||||
debit_amount: debitAmount,
|
||||
credit_amount: creditAmount,
|
||||
balance_change: balanceChange,
|
||||
running_balance: runningBalance,
|
||||
reference_number: line.transaction?.reference_number,
|
||||
transaction_type: line.transaction?.transaction_type
|
||||
};
|
||||
});
|
||||
|
||||
return ledgerEntries;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get account ledger: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balance summary
|
||||
* @param {string} accountId - Account ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range options
|
||||
* @returns {Promise<Object>} Account balance summary
|
||||
*/
|
||||
async getAccountBalance(accountId, siteId, options = {}) {
|
||||
try {
|
||||
const { startDate, endDate } = options;
|
||||
|
||||
const ledger = await this.getAccountLedger(accountId, siteId, options);
|
||||
|
||||
// Calculate opening balance (balance before start date)
|
||||
const openingBalanceQuery = {
|
||||
account_id: accountId
|
||||
};
|
||||
const allLines = await this.transactionLineRepository.findAll(openingBalanceQuery);
|
||||
|
||||
const linesBeforePeriod = [];
|
||||
if (startDate) {
|
||||
for (const line of allLines) {
|
||||
const transaction = await this.transactionRepository.findById(line.transaction_id);
|
||||
if (transaction?.site_id === siteId && transaction?.transaction_date < startDate) {
|
||||
linesBeforePeriod.push({ line, transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openingBalance = linesBeforePeriod.reduce((balance, item) => {
|
||||
const debitAmount = parseFloat(item.line.debit_amount || 0);
|
||||
const creditAmount = parseFloat(item.line.credit_amount || 0);
|
||||
return balance + (debitAmount - creditAmount);
|
||||
}, 0);
|
||||
|
||||
// Calculate totals for the period
|
||||
const totalDebits = ledger.reduce((sum, entry) => sum + entry.debit_amount, 0);
|
||||
const totalCredits = ledger.reduce((sum, entry) => sum + entry.credit_amount, 0);
|
||||
const closingBalance = openingBalance + (totalDebits - totalCredits);
|
||||
|
||||
return {
|
||||
account_id: accountId,
|
||||
site_id: siteId,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
opening_balance: openingBalance,
|
||||
total_debits: totalDebits,
|
||||
total_credits: totalCredits,
|
||||
closing_balance: closingBalance,
|
||||
transaction_count: ledger.length
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get account balance: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get general ledger for all accounts with balances
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Filter options
|
||||
* @returns {Promise<Array>} Array of account ledger summaries
|
||||
*/
|
||||
async getGeneralLedger(siteId, options = {}) {
|
||||
try {
|
||||
const { ChartOfAccountsRepository } = require('./index');
|
||||
const chartOfAccountsRepository = new ChartOfAccountsRepository();
|
||||
|
||||
// Get all active accounts for this site
|
||||
const accounts = await chartOfAccountsRepository.findAll({
|
||||
site_id: siteId,
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Get balance for each account
|
||||
const ledgerAccounts = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
const balance = await this.getAccountBalance(account.id, siteId, options);
|
||||
return {
|
||||
account_id: account.id,
|
||||
account_code: account.account_code,
|
||||
account_name: account.account_name,
|
||||
account_type: account.account_type,
|
||||
opening_balance: balance.opening_balance,
|
||||
total_debits: balance.total_debits,
|
||||
total_credits: balance.total_credits,
|
||||
closing_balance: balance.closing_balance,
|
||||
transaction_count: balance.transaction_count
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ledgerAccounts;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get general ledger: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trial balance
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Filter options
|
||||
* @returns {Promise<Object>} Trial balance report
|
||||
*/
|
||||
async getTrialBalance(siteId, options = {}) {
|
||||
try {
|
||||
const generalLedger = await this.getGeneralLedger(siteId, options);
|
||||
|
||||
const trialBalance = {
|
||||
site_id: siteId,
|
||||
report_date: options.endDate || new Date().toISOString().split('T')[0],
|
||||
accounts: generalLedger,
|
||||
totals: {
|
||||
total_debits: generalLedger.reduce((sum, account) => sum + account.total_debits, 0),
|
||||
total_credits: generalLedger.reduce((sum, account) => sum + account.total_credits, 0),
|
||||
net_balance: generalLedger.reduce((sum, account) => sum + account.closing_balance, 0)
|
||||
}
|
||||
};
|
||||
|
||||
return trialBalance;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get trial balance: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GeneralLedgerRepository;
|
||||
|
||||
224
repositories/InvoiceRepository.js
Normal file
224
repositories/InvoiceRepository.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Invoice Repository
|
||||
*
|
||||
* Manages invoice creation, tracking, and payments for units.
|
||||
*/
|
||||
class InvoiceRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_invoices');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices for a unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Promise<Array>} Array of invoices
|
||||
*/
|
||||
async findByUnitId(unitId, options = {}) {
|
||||
try {
|
||||
return await this.findAll({ unit_id: unitId }, {
|
||||
orderBy: 'issue_date',
|
||||
orderDirection: 'desc',
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding invoices by unit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Promise<Array>} Array of invoices
|
||||
*/
|
||||
async findBySiteId(siteId, options = {}) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId }, {
|
||||
orderBy: 'issue_date',
|
||||
orderDirection: 'desc',
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding invoices by site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices by status
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} status - Invoice status (pending, paid, overdue, cancelled)
|
||||
* @returns {Promise<Array>} Array of invoices
|
||||
*/
|
||||
async findByStatus(siteId, status) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, status }, { orderBy: 'due_date' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding invoices by status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice by invoice number
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} invoiceNumber - Invoice number
|
||||
* @returns {Promise<Object|null>} Invoice or null
|
||||
*/
|
||||
async findByInvoiceNumber(siteId, invoiceNumber) {
|
||||
try {
|
||||
return await this.findOne({ site_id: siteId, invoice_number: invoiceNumber });
|
||||
} catch (error) {
|
||||
logger.error('Error finding invoice by number:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find overdue invoices
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of overdue invoices
|
||||
*/
|
||||
async findOverdue(siteId) {
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return await this.findWhere({
|
||||
site_id: { eq: siteId },
|
||||
status: { eq: 'pending' },
|
||||
due_date: { lt: today }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding overdue invoices:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invoice with validation
|
||||
* @param {Object} invoiceData - Invoice data
|
||||
* @returns {Promise<Object>} Created invoice
|
||||
*/
|
||||
async createInvoice(invoiceData) {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!invoiceData.site_id || !invoiceData.unit_id || !invoiceData.invoice_number ||
|
||||
!invoiceData.invoice_type || !invoiceData.description || !invoiceData.amount ||
|
||||
!invoiceData.due_date) {
|
||||
throw new Error('Missing required fields for invoice creation');
|
||||
}
|
||||
|
||||
// Check if invoice number already exists for this site
|
||||
const existingInvoice = await this.findByInvoiceNumber(invoiceData.site_id, invoiceData.invoice_number);
|
||||
if (existingInvoice) {
|
||||
throw new Error('Invoice with this number already exists for this site');
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
const invoice = {
|
||||
...invoiceData,
|
||||
status: invoiceData.status || 'pending',
|
||||
issue_date: invoiceData.issue_date || new Date().toISOString().split('T')[0],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.create(invoice);
|
||||
} catch (error) {
|
||||
logger.error('Error creating invoice:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invoice as paid
|
||||
* @param {string} invoiceId - Invoice ID
|
||||
* @param {Object} paymentInfo - Payment information
|
||||
* @returns {Promise<Object>} Updated invoice
|
||||
*/
|
||||
async markAsPaid(invoiceId, paymentInfo = {}) {
|
||||
try {
|
||||
const update = {
|
||||
status: 'paid',
|
||||
payment_date: paymentInfo.payment_date || new Date().toISOString().split('T')[0],
|
||||
payment_method: paymentInfo.payment_method,
|
||||
payment_reference: paymentInfo.payment_reference,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.updateById(invoiceId, update);
|
||||
} catch (error) {
|
||||
logger.error('Error marking invoice as paid:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark invoice as overdue
|
||||
* @param {string} invoiceId - Invoice ID
|
||||
* @returns {Promise<Object>} Updated invoice
|
||||
*/
|
||||
async markAsOverdue(invoiceId) {
|
||||
try {
|
||||
return await this.updateById(invoiceId, {
|
||||
status: 'overdue',
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error marking invoice as overdue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice statistics for a unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @returns {Promise<Object>} Invoice statistics
|
||||
*/
|
||||
async getInvoiceStats(unitId) {
|
||||
try {
|
||||
const invoices = await this.findByUnitId(unitId);
|
||||
|
||||
const stats = {
|
||||
total: invoices.length,
|
||||
pending: invoices.filter(i => i.status === 'pending').length,
|
||||
paid: invoices.filter(i => i.status === 'paid').length,
|
||||
overdue: invoices.filter(i => i.status === 'overdue').length,
|
||||
totalAmount: invoices.reduce((sum, inv) => sum + parseFloat(inv.amount), 0),
|
||||
paidAmount: invoices
|
||||
.filter(i => i.status === 'paid')
|
||||
.reduce((sum, inv) => sum + parseFloat(inv.amount), 0),
|
||||
pendingAmount: invoices
|
||||
.filter(i => i.status === 'pending' || i.status === 'overdue')
|
||||
.reduce((sum, inv) => sum + parseFloat(inv.amount), 0)
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('Error getting invoice stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoices for work order
|
||||
* @param {string} workOrderId - Work order ID
|
||||
* @returns {Promise<Array>} Array of invoices
|
||||
*/
|
||||
async findByWorkOrderId(workOrderId) {
|
||||
try {
|
||||
return await this.findAll({ work_order_id: workOrderId }, { orderBy: 'created_at' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding invoices by work order:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = InvoiceRepository;
|
||||
|
||||
|
||||
111
repositories/PaymentRepository.js
Normal file
111
repositories/PaymentRepository.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Payment Repository
|
||||
*
|
||||
* Manages payment tracking for invoices.
|
||||
*/
|
||||
class PaymentRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_payments');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments for a unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Promise<Array>} Array of payments
|
||||
*/
|
||||
async findByUnitId(unitId, options = {}) {
|
||||
try {
|
||||
return await this.findAll({ unit_id: unitId }, {
|
||||
orderBy: 'payment_date',
|
||||
orderDirection: 'desc',
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding payments by unit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of payments
|
||||
*/
|
||||
async findBySiteId(siteId) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId }, {
|
||||
orderBy: 'payment_date',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding payments by site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find payments for an invoice
|
||||
* @param {string} invoiceId - Invoice ID
|
||||
* @returns {Promise<Array>} Array of payments
|
||||
*/
|
||||
async findByInvoiceId(invoiceId) {
|
||||
try {
|
||||
return await this.findAll({ invoice_id: invoiceId }, { orderBy: 'payment_date' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding payments by invoice:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create payment record
|
||||
* @param {Object} paymentData - Payment data
|
||||
* @returns {Promise<Object>} Created payment
|
||||
*/
|
||||
async createPayment(paymentData) {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!paymentData.site_id || !paymentData.unit_id || !paymentData.payment_amount ||
|
||||
!paymentData.payment_method) {
|
||||
throw new Error('Missing required fields for payment');
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
const payment = {
|
||||
...paymentData,
|
||||
payment_date: paymentData.payment_date || new Date().toISOString().split('T')[0],
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.create(payment);
|
||||
} catch (error) {
|
||||
logger.error('Error creating payment:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total payments for a unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @returns {Promise<number>} Total payment amount
|
||||
*/
|
||||
async getTotalPayments(unitId) {
|
||||
try {
|
||||
const payments = await this.findByUnitId(unitId);
|
||||
return payments.reduce((total, payment) => {
|
||||
return total + parseFloat(payment.payment_amount || 0);
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
logger.error('Error calculating total payments:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentRepository;
|
||||
|
||||
|
||||
242
repositories/ReportRepository.js
Normal file
242
repositories/ReportRepository.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Report Repository
|
||||
*
|
||||
* Manages financial report generation and storage.
|
||||
*/
|
||||
class ReportRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_reports');
|
||||
}
|
||||
|
||||
getRepositories() {
|
||||
if (!this.transactionLineRepository) {
|
||||
const { transactionRepository, transactionLineRepository, chartOfAccountsRepository } = require('./index');
|
||||
this.transactionRepository = transactionRepository;
|
||||
this.transactionLineRepository = transactionLineRepository;
|
||||
this.chartOfAccountsRepository = chartOfAccountsRepository;
|
||||
}
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'generated_at', orderDirection: 'desc' });
|
||||
}
|
||||
|
||||
async findByType(siteId, reportType) {
|
||||
return await this.findAll({ site_id: siteId, report_type: reportType }, {
|
||||
orderBy: 'generated_at',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
}
|
||||
|
||||
async createReport(reportData) {
|
||||
return await this.create({
|
||||
...reportData,
|
||||
generated_at: new Date().toISOString(),
|
||||
status: reportData.status || 'generated'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Trial Balance Report
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range
|
||||
* @returns {Promise<Object>} Trial balance report
|
||||
*/
|
||||
async generateTrialBalance(siteId, options = {}) {
|
||||
try {
|
||||
this.getRepositories();
|
||||
const { generalLedgerRepository } = require('./index');
|
||||
|
||||
return await generalLedgerRepository.getTrialBalance(siteId, options);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate trial balance: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Balance Sheet
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range
|
||||
* @returns {Promise<Object>} Balance sheet report
|
||||
*/
|
||||
async generateBalanceSheet(siteId, options = {}) {
|
||||
try {
|
||||
this.getRepositories();
|
||||
|
||||
const accounts = await this.chartOfAccountsRepository.findAll({ site_id: siteId, is_active: true });
|
||||
|
||||
const { transactionRepository, transactionLineRepository } = require('./index');
|
||||
|
||||
const assets = [];
|
||||
const liabilities = [];
|
||||
const equity = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const lines = await this.transactionLineRepository.findAll({ account_id: account.id });
|
||||
let balance = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const transaction = await transactionRepository.findById(line.transaction_id);
|
||||
if (transaction && transaction.site_id === siteId) {
|
||||
if (options.endDate && transaction.transaction_date <= options.endDate) {
|
||||
balance += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
} else if (!options.endDate) {
|
||||
balance += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accountData = {
|
||||
account_code: account.account_code,
|
||||
account_name: account.account_name,
|
||||
balance: balance
|
||||
};
|
||||
|
||||
if (account.account_type === 'asset') {
|
||||
assets.push(accountData);
|
||||
} else if (account.account_type === 'liability') {
|
||||
liabilities.push(accountData);
|
||||
} else if (account.account_type === 'equity') {
|
||||
equity.push(accountData);
|
||||
}
|
||||
}
|
||||
|
||||
const totalAssets = assets.reduce((sum, a) => sum + a.balance, 0);
|
||||
const totalLiabilities = liabilities.reduce((sum, l) => sum + l.balance, 0);
|
||||
const totalEquity = equity.reduce((sum, e) => sum + e.balance, 0);
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
report_date: options.endDate || new Date().toISOString().split('T')[0],
|
||||
assets: {
|
||||
accounts: assets,
|
||||
total: totalAssets
|
||||
},
|
||||
liabilities: {
|
||||
accounts: liabilities,
|
||||
total: totalLiabilities
|
||||
},
|
||||
equity: {
|
||||
accounts: equity,
|
||||
total: totalEquity
|
||||
},
|
||||
total_liabilities_and_equity: totalLiabilities + totalEquity,
|
||||
balanced: Math.abs(totalAssets - (totalLiabilities + totalEquity)) < 0.01
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate balance sheet: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Income Statement
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Date range
|
||||
* @returns {Promise<Object>} Income statement report
|
||||
*/
|
||||
async generateIncomeStatement(siteId, options = {}) {
|
||||
try {
|
||||
this.getRepositories();
|
||||
|
||||
const revenueAccounts = await this.chartOfAccountsRepository.findAll({
|
||||
site_id: siteId,
|
||||
account_type: 'revenue',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const expenseAccounts = await this.chartOfAccountsRepository.findAll({
|
||||
site_id: siteId,
|
||||
account_type: 'expense',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const { transactionRepository, transactionLineRepository } = require('./index');
|
||||
|
||||
const revenues = [];
|
||||
const expenses = [];
|
||||
|
||||
// Calculate revenue
|
||||
for (const account of revenueAccounts) {
|
||||
const lines = await this.transactionLineRepository.findAll({ account_id: account.id });
|
||||
let total = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const transaction = await transactionRepository.findById(line.transaction_id);
|
||||
if (transaction && transaction.site_id === siteId) {
|
||||
const transactionDate = transaction.transaction_date;
|
||||
if (options.startDate && options.endDate) {
|
||||
if (transactionDate >= options.startDate && transactionDate <= options.endDate) {
|
||||
total += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
} else {
|
||||
total += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (total !== 0) {
|
||||
revenues.push({
|
||||
account_code: account.account_code,
|
||||
account_name: account.account_name,
|
||||
amount: total
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expenses
|
||||
for (const account of expenseAccounts) {
|
||||
const lines = await this.transactionLineRepository.findAll({ account_id: account.id });
|
||||
let total = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const transaction = await transactionRepository.findById(line.transaction_id);
|
||||
if (transaction && transaction.site_id === siteId) {
|
||||
const transactionDate = transaction.transaction_date;
|
||||
if (options.startDate && options.endDate) {
|
||||
if (transactionDate >= options.startDate && transactionDate <= options.endDate) {
|
||||
total += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
} else {
|
||||
total += parseFloat(line.debit_amount || 0) - parseFloat(line.credit_amount || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (total !== 0) {
|
||||
expenses.push({
|
||||
account_code: account.account_code,
|
||||
account_name: account.account_name,
|
||||
amount: total
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalRevenue = revenues.reduce((sum, r) => sum + r.amount, 0);
|
||||
const totalExpenses = expenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
const netIncome = totalRevenue - totalExpenses;
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
start_date: options.startDate,
|
||||
end_date: options.endDate,
|
||||
revenues: {
|
||||
accounts: revenues,
|
||||
total: totalRevenue
|
||||
},
|
||||
expenses: {
|
||||
accounts: expenses,
|
||||
total: totalExpenses
|
||||
},
|
||||
net_income: netIncome
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate income statement: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ReportRepository;
|
||||
|
||||
|
||||
37
repositories/RevenueRepository.js
Normal file
37
repositories/RevenueRepository.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Revenue Repository
|
||||
*
|
||||
* Manages revenue and income tracking.
|
||||
*/
|
||||
class RevenueRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_revenue');
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'revenue_date', orderDirection: 'desc' });
|
||||
}
|
||||
|
||||
async findBySource(siteId, source) {
|
||||
return await this.findAll({ site_id: siteId, source });
|
||||
}
|
||||
|
||||
async createRevenue(revenueData) {
|
||||
if (!revenueData.site_id || !revenueData.revenue_date || !revenueData.amount) {
|
||||
throw new Error('Missing required fields for revenue');
|
||||
}
|
||||
|
||||
return await this.create({
|
||||
...revenueData,
|
||||
payment_status: revenueData.payment_status || 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RevenueRepository;
|
||||
|
||||
|
||||
54
repositories/SpecialAssessmentRepository.js
Normal file
54
repositories/SpecialAssessmentRepository.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Special Assessment Repository
|
||||
*
|
||||
* Manages special assessments - one-time charges divided among units equally.
|
||||
*/
|
||||
class SpecialAssessmentRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_special_assessments');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create special assessment and auto-generate invoices
|
||||
* @param {Object} assessmentData - Assessment data
|
||||
* @returns {Promise<Object>} Created assessment with invoices
|
||||
*/
|
||||
async createAssessment(assessmentData) {
|
||||
try {
|
||||
const { invoiceRepository } = require('./index');
|
||||
// Note: unitRepository is not in financials plugin - we'll need to get units differently
|
||||
|
||||
// Create the assessment record
|
||||
const assessment = await this.create(assessmentData);
|
||||
|
||||
// Calculate per-unit amount
|
||||
const amountPerUnit = parseFloat(assessmentData.total_amount) / assessmentData.unit_count;
|
||||
|
||||
return {
|
||||
...assessment,
|
||||
amount_per_unit: amountPerUnit,
|
||||
units_affected: assessmentData.unit_count
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create special assessment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find assessments by site
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of assessments
|
||||
*/
|
||||
async findBySiteId(siteId) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'created_at', orderDirection: 'desc' });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SpecialAssessmentRepository;
|
||||
|
||||
32
repositories/TaxSettingsRepository.js
Normal file
32
repositories/TaxSettingsRepository.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Tax Settings Repository
|
||||
*
|
||||
* Manages tax configuration.
|
||||
*/
|
||||
class TaxSettingsRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_tax_settings');
|
||||
}
|
||||
|
||||
async findBySiteId(siteId) {
|
||||
return await this.findAll({ site_id: siteId });
|
||||
}
|
||||
|
||||
async findActive(siteId) {
|
||||
return await this.findAll({ site_id: siteId, is_active: true });
|
||||
}
|
||||
|
||||
async createTaxSetting(taxData) {
|
||||
return await this.create({
|
||||
...taxData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaxSettingsRepository;
|
||||
|
||||
|
||||
75
repositories/TransactionLineRepository.js
Normal file
75
repositories/TransactionLineRepository.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Transaction Line Repository
|
||||
*
|
||||
* Manages transaction lines for double-entry bookkeeping.
|
||||
* Each transaction should have balanced debit and credit lines.
|
||||
*/
|
||||
class TransactionLineRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_transaction_lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find lines for a transaction
|
||||
* @param {string} transactionId - Transaction ID
|
||||
* @returns {Promise<Array>} Array of transaction lines
|
||||
*/
|
||||
async findByTransactionId(transactionId) {
|
||||
try {
|
||||
return await this.findAll({ transaction_id: transactionId }, { orderBy: 'created_at' });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find lines for an account
|
||||
* @param {string} accountId - Account ID
|
||||
* @returns {Promise<Array>} Array of transaction lines
|
||||
*/
|
||||
async findByAccountId(accountId) {
|
||||
try {
|
||||
return await this.findAll({ account_id: accountId }, {
|
||||
orderBy: 'created_at',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transaction lines in batch
|
||||
* @param {Array<Object>} lines - Array of transaction lines
|
||||
* @returns {Promise<Array>} Created lines
|
||||
*/
|
||||
async createLines(lines) {
|
||||
try {
|
||||
const createdLines = [];
|
||||
for (const line of lines) {
|
||||
const created = await this.create(line);
|
||||
createdLines.push(created);
|
||||
}
|
||||
return createdLines;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate double-entry balance
|
||||
* @param {Array<Object>} lines - Transaction lines
|
||||
* @returns {boolean} True if debits equal credits
|
||||
*/
|
||||
validateBalance(lines) {
|
||||
const totalDebits = lines.reduce((sum, line) => sum + parseFloat(line.debit_amount || 0), 0);
|
||||
const totalCredits = lines.reduce((sum, line) => sum + parseFloat(line.credit_amount || 0), 0);
|
||||
return totalDebits === totalCredits;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TransactionLineRepository;
|
||||
|
||||
|
||||
182
repositories/TransactionRepository.js
Normal file
182
repositories/TransactionRepository.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Transaction Repository
|
||||
*
|
||||
* Manages financial transactions with double-entry bookkeeping support.
|
||||
*/
|
||||
class TransactionRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_transactions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findBySiteId(siteId, options = {}) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId }, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'desc',
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by type
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} transactionType - Transaction type (income, expense, transfer)
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByType(siteId, transactionType) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, transaction_type: transactionType }, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by type:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by date range
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} startDate - Start date
|
||||
* @param {string} endDate - End date
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByDateRange(siteId, startDate, endDate) {
|
||||
try {
|
||||
return await this.findWhere({
|
||||
site_id: { eq: siteId },
|
||||
transaction_date: { gte: startDate, lte: endDate }
|
||||
}, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'asc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by date range:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by status
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {string} status - Transaction status
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByStatus(siteId, status) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId, status }, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transaction with validation
|
||||
* @param {Object} transactionData - Transaction data
|
||||
* @returns {Promise<Object>} Created transaction
|
||||
*/
|
||||
async createTransaction(transactionData) {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!transactionData.site_id || !transactionData.transaction_date ||
|
||||
!transactionData.description || !transactionData.transaction_type ||
|
||||
!transactionData.amount) {
|
||||
throw new Error('Missing required fields for transaction');
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
const transaction = {
|
||||
...transactionData,
|
||||
status: transactionData.status || 'pending',
|
||||
currency: transactionData.currency || 'USD',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return await this.create(transaction);
|
||||
} catch (error) {
|
||||
logger.error('Error creating transaction:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve transaction
|
||||
* @param {string} transactionId - Transaction ID
|
||||
* @param {string} approvedBy - User ID who approved
|
||||
* @returns {Promise<Object>} Updated transaction
|
||||
*/
|
||||
async approveTransaction(transactionId, approvedBy) {
|
||||
try {
|
||||
return await this.updateById(transactionId, {
|
||||
status: 'approved',
|
||||
approved_by: approvedBy,
|
||||
approved_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error approving transaction:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by unit ID
|
||||
* @param {string} unitId - Unit ID
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByUnitId(unitId) {
|
||||
try {
|
||||
return await this.findAll({ unit_id: unitId }, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'asc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by unit:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find transactions by unit ID and type
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {string} transactionType - Transaction type
|
||||
* @returns {Promise<Array>} Array of transactions
|
||||
*/
|
||||
async findByUnitIdAndType(unitId, transactionType) {
|
||||
try {
|
||||
return await this.findAll({
|
||||
unit_id: unitId,
|
||||
transaction_type: transactionType
|
||||
}, {
|
||||
orderBy: 'transaction_date',
|
||||
orderDirection: 'asc'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error finding transactions by unit and type:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TransactionRepository;
|
||||
|
||||
|
||||
173
repositories/UnitBalanceRepository.js
Normal file
173
repositories/UnitBalanceRepository.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
/**
|
||||
* Unit Balance Repository
|
||||
*
|
||||
* Manages unit balance tracking for HOA financial management.
|
||||
* This is CRITICAL for the frontend - the useUnitBalance hook depends on this.
|
||||
*/
|
||||
class UnitBalanceRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_unit_balances');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find balance for a specific unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Object|null>} Unit balance or null
|
||||
*/
|
||||
async findByUnitId(unitId, siteId) {
|
||||
try {
|
||||
return await this.findOne({ unit_id: unitId, site_id: siteId });
|
||||
} catch (error) {
|
||||
logger.error('Error finding unit balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find balances for multiple units (batch lookup)
|
||||
* @param {Array<string>} unitIds - Array of unit IDs
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Object>} Object with unitId as key and balance as value
|
||||
*/
|
||||
async findByMultipleUnitIds(unitIds, siteId) {
|
||||
try {
|
||||
const balances = {};
|
||||
const results = await this.findWhere({
|
||||
unit_id: { in: unitIds },
|
||||
site_id: { eq: siteId }
|
||||
});
|
||||
|
||||
// Convert array to object for easy lookup
|
||||
results.forEach(balance => {
|
||||
balances[balance.unit_id] = balance;
|
||||
});
|
||||
|
||||
return balances;
|
||||
} catch (error) {
|
||||
logger.error('Error finding multiple unit balances:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update unit balance
|
||||
* @param {Object} balanceData - Balance data
|
||||
* @returns {Promise<Object>} Created or updated balance
|
||||
*/
|
||||
async createOrUpdateUnitBalance(balanceData) {
|
||||
try {
|
||||
const { unit_id, site_id } = balanceData;
|
||||
const existing = await this.findByUnitId(unit_id, site_id);
|
||||
|
||||
if (existing) {
|
||||
// Update existing balance
|
||||
return await this.updateById(existing.id, {
|
||||
...balanceData,
|
||||
last_updated: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// Create new balance
|
||||
const balance = {
|
||||
...balanceData,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
return await this.create(balance);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating/updating unit balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current balance for a unit
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @param {number} newBalance - New balance amount
|
||||
* @returns {Promise<Object>} Updated balance
|
||||
*/
|
||||
async updateCurrentBalance(unitId, siteId, newBalance) {
|
||||
try {
|
||||
const existing = await this.findByUnitId(unitId, siteId);
|
||||
if (!existing) {
|
||||
throw new Error('Unit balance not found');
|
||||
}
|
||||
|
||||
return await this.updateById(existing.id, {
|
||||
current_balance: newBalance,
|
||||
last_updated: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating current balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate unit balance from invoices and payments
|
||||
* This will be called by the balances route to get REAL balance
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Object>} Balance breakdown
|
||||
*/
|
||||
async calculateUnitBalance(unitId, siteId) {
|
||||
try {
|
||||
const balanceRecord = await this.findByUnitId(unitId, siteId);
|
||||
|
||||
// Balance is now calculated from transactions, not stored
|
||||
const currentBalance = balanceRecord ? parseFloat(balanceRecord.current_balance) : 0;
|
||||
|
||||
return {
|
||||
unitId,
|
||||
siteId,
|
||||
currentBalance,
|
||||
lastUpdated: balanceRecord ? balanceRecord.last_updated : null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error calculating unit balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all balances for a site
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Array>} Array of unit balances
|
||||
*/
|
||||
async findBySiteId(siteId) {
|
||||
try {
|
||||
return await this.findAll({ site_id: siteId }, { orderBy: 'last_updated', orderDirection: 'desc' });
|
||||
} catch (error) {
|
||||
logger.error('Error finding balances by site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset unit balance
|
||||
* @param {string} unitId - Unit ID
|
||||
* @param {string} siteId - Site ID
|
||||
* @returns {Promise<Object>} Reset balance
|
||||
*/
|
||||
async resetBalance(unitId, siteId) {
|
||||
try {
|
||||
return await this.createOrUpdateUnitBalance({
|
||||
unit_id: unitId,
|
||||
site_id: siteId,
|
||||
current_balance: 0,
|
||||
created_by: 'system' // Will be set by route
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resetting balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UnitBalanceRepository;
|
||||
|
||||
|
||||
43
repositories/UnitMonthlyFeeRepository.js
Normal file
43
repositories/UnitMonthlyFeeRepository.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const BaseFinancialRepository = require('./BaseFinancialRepository');
|
||||
|
||||
/**
|
||||
* Unit Monthly Fee Repository
|
||||
*
|
||||
* Manages monthly fee settings for units.
|
||||
*/
|
||||
class UnitMonthlyFeeRepository extends BaseFinancialRepository {
|
||||
constructor() {
|
||||
super('pg_fn_unit_monthly_fees');
|
||||
}
|
||||
|
||||
async findByUnitId(unitId, siteId) {
|
||||
return await this.findAll({ unit_id: unitId, site_id: siteId }, {
|
||||
orderBy: 'effective_from',
|
||||
orderDirection: 'desc'
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveFee(unitId, siteId) {
|
||||
const now = new Date().toISOString().split('T')[0];
|
||||
const fees = await this.findWhere({
|
||||
unit_id: { eq: unitId },
|
||||
site_id: { eq: siteId },
|
||||
effective_from: { lte: now }
|
||||
});
|
||||
|
||||
// Return the most recent active fee (effective_to is NULL or in future)
|
||||
return fees.find(fee => !fee.effective_to || fee.effective_to >= now) || null;
|
||||
}
|
||||
|
||||
async createMonthlyFee(feeData) {
|
||||
return await this.create({
|
||||
...feeData,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UnitMonthlyFeeRepository;
|
||||
|
||||
|
||||
97
repositories/index.js
Normal file
97
repositories/index.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Financial Plugin Repositories
|
||||
// All repositories extend BaseFinancialRepository for consistent database abstraction
|
||||
|
||||
const ChartOfAccountsRepository = require('./ChartOfAccountsRepository');
|
||||
const UnitBalanceRepository = require('./UnitBalanceRepository');
|
||||
const InvoiceRepository = require('./InvoiceRepository');
|
||||
const PaymentRepository = require('./PaymentRepository');
|
||||
const TransactionRepository = require('./TransactionRepository');
|
||||
const TransactionLineRepository = require('./TransactionLineRepository');
|
||||
const GeneralLedgerRepository = require('./GeneralLedgerRepository');
|
||||
const BudgetRepository = require('./BudgetRepository');
|
||||
const BudgetItemRepository = require('./BudgetItemRepository');
|
||||
const ExpenseRepository = require('./ExpenseRepository');
|
||||
const RevenueRepository = require('./RevenueRepository');
|
||||
const BalanceHistoryRepository = require('./BalanceHistoryRepository');
|
||||
const UnitMonthlyFeeRepository = require('./UnitMonthlyFeeRepository');
|
||||
const ReportRepository = require('./ReportRepository');
|
||||
const TaxSettingsRepository = require('./TaxSettingsRepository');
|
||||
const AnalyticsRepository = require('./AnalyticsRepository');
|
||||
const BankAccountRepository = require('./BankAccountRepository');
|
||||
const BankStatementRepository = require('./BankStatementRepository');
|
||||
const BankStatementTransactionRepository = require('./BankStatementTransactionRepository');
|
||||
const BankReconciliationRepository = require('./BankReconciliationRepository');
|
||||
const SpecialAssessmentRepository = require('./SpecialAssessmentRepository');
|
||||
|
||||
// Create singleton instances
|
||||
const chartOfAccountsRepository = new ChartOfAccountsRepository();
|
||||
const unitBalanceRepository = new UnitBalanceRepository();
|
||||
const invoiceRepository = new InvoiceRepository();
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const transactionRepository = new TransactionRepository();
|
||||
const transactionLineRepository = new TransactionLineRepository();
|
||||
const generalLedgerRepository = new GeneralLedgerRepository();
|
||||
const budgetRepository = new BudgetRepository();
|
||||
const budgetItemRepository = new BudgetItemRepository();
|
||||
const expenseRepository = new ExpenseRepository();
|
||||
const revenueRepository = new RevenueRepository();
|
||||
const balanceHistoryRepository = new BalanceHistoryRepository();
|
||||
const unitMonthlyFeeRepository = new UnitMonthlyFeeRepository();
|
||||
const reportRepository = new ReportRepository();
|
||||
const taxSettingsRepository = new TaxSettingsRepository();
|
||||
const analyticsRepository = new AnalyticsRepository();
|
||||
const bankAccountRepository = new BankAccountRepository();
|
||||
const bankStatementRepository = new BankStatementRepository();
|
||||
const bankStatementTransactionRepository = new BankStatementTransactionRepository();
|
||||
const bankReconciliationRepository = new BankReconciliationRepository();
|
||||
const specialAssessmentRepository = new SpecialAssessmentRepository();
|
||||
|
||||
module.exports = {
|
||||
// Repository Classes
|
||||
ChartOfAccountsRepository,
|
||||
UnitBalanceRepository,
|
||||
InvoiceRepository,
|
||||
PaymentRepository,
|
||||
TransactionRepository,
|
||||
TransactionLineRepository,
|
||||
GeneralLedgerRepository,
|
||||
BudgetRepository,
|
||||
BudgetItemRepository,
|
||||
ExpenseRepository,
|
||||
RevenueRepository,
|
||||
BalanceHistoryRepository,
|
||||
UnitMonthlyFeeRepository,
|
||||
ReportRepository,
|
||||
TaxSettingsRepository,
|
||||
AnalyticsRepository,
|
||||
BankAccountRepository,
|
||||
BankStatementRepository,
|
||||
BankStatementTransactionRepository,
|
||||
BankReconciliationRepository,
|
||||
SpecialAssessmentRepository,
|
||||
|
||||
// Singleton Instances
|
||||
chartOfAccountsRepository,
|
||||
unitBalanceRepository,
|
||||
invoiceRepository,
|
||||
paymentRepository,
|
||||
transactionRepository,
|
||||
transactionLineRepository,
|
||||
generalLedgerRepository,
|
||||
budgetRepository,
|
||||
budgetItemRepository,
|
||||
expenseRepository,
|
||||
revenueRepository,
|
||||
balanceHistoryRepository,
|
||||
unitMonthlyFeeRepository,
|
||||
reportRepository,
|
||||
taxSettingsRepository,
|
||||
analyticsRepository,
|
||||
bankAccountRepository,
|
||||
bankStatementRepository,
|
||||
bankStatementTransactionRepository,
|
||||
bankReconciliationRepository,
|
||||
specialAssessmentRepository
|
||||
};
|
||||
|
||||
|
||||
320
routes/accounts.js
Normal file
320
routes/accounts.js
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { chartOfAccountsRepository, generalLedgerRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/accounts
|
||||
* Get all accounts for the site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
// Get accounts from database using repository
|
||||
const accounts = await chartOfAccountsRepository.findBySiteId(site_id, {
|
||||
orderBy: 'account_code',
|
||||
orderDirection: 'asc'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accounts,
|
||||
message: 'Chart of accounts retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get accounts:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve accounts',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/accounts/:id
|
||||
* Get account by ID
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
// Get account from database
|
||||
const account = await chartOfAccountsRepository.findById(id);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify account belongs to site
|
||||
if (account.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: account,
|
||||
message: 'Account details retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve account',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/accounts
|
||||
* Create new account
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, account_code, account_name, account_type, parent_account_id, description, is_active } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!site_id || !account_code || !account_name || !account_type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Site ID, account code, name, and type are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Require authenticated user for created_by
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
// Create account using repository
|
||||
const newAccount = await chartOfAccountsRepository.createAccount({
|
||||
site_id,
|
||||
account_code,
|
||||
account_name,
|
||||
account_type,
|
||||
parent_account_id,
|
||||
description,
|
||||
is_active,
|
||||
created_by: req.user.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: newAccount,
|
||||
message: 'Account created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create account:', error);
|
||||
|
||||
// Friendly handling of unique-violation or duplicate
|
||||
if (error && (error.code === '23505' || (typeof error.message === 'string' && /duplicate|exists/i.test(error.message)))) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Account code already exists',
|
||||
details: error.detail || error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure JSON response even for unexpected errors
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create account',
|
||||
details: error && (error.message || String(error))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/plugins/financials/accounts/:id
|
||||
* Update account
|
||||
*/
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id, account_name, description, is_active } = req.body;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing account
|
||||
const existingAccount = await chartOfAccountsRepository.findById(id);
|
||||
|
||||
if (!existingAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify account belongs to site
|
||||
if (existingAccount.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
// Update account
|
||||
const updatedAccount = await chartOfAccountsRepository.updateAccount(id, {
|
||||
account_name,
|
||||
description,
|
||||
is_active
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedAccount,
|
||||
message: 'Account updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update account',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/plugins/financials/accounts/:id
|
||||
* Delete account
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
// Get existing account
|
||||
const existingAccount = await chartOfAccountsRepository.findById(id);
|
||||
|
||||
if (!existingAccount) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify account belongs to site
|
||||
if (existingAccount.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if account has transactions
|
||||
const hasTransactions = await chartOfAccountsRepository.hasTransactions(id);
|
||||
|
||||
if (hasTransactions) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot delete account with existing transactions',
|
||||
message: 'Please deactivate the account instead'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete account
|
||||
await chartOfAccountsRepository.deleteById(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete account',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/accounts/:id/balance
|
||||
* Get account balance
|
||||
*/
|
||||
router.get('/:id/balance', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
// Get account
|
||||
const account = await chartOfAccountsRepository.findById(id);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate balance from transaction lines using General Ledger
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const balance = await generalLedgerRepository.getAccountBalance(id, site_id, options);
|
||||
balance.currency = 'USD';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: balance,
|
||||
message: 'Account balance retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get account balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve account balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
routes/analytics.js
Normal file
73
routes/analytics.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { analyticsRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/analytics
|
||||
* Get comprehensive financial analytics
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.start_date = start_date;
|
||||
if (end_date) options.end_date = end_date;
|
||||
|
||||
const analytics = await analyticsRepository.getFinancialAnalytics(site_id, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: analytics,
|
||||
message: 'Analytics retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get analytics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve analytics',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/analytics/hoa
|
||||
* Get HOA-specific metrics
|
||||
*/
|
||||
router.get('/hoa', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = await analyticsRepository.getHOAMetrics(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: metrics,
|
||||
message: 'HOA metrics retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get HOA metrics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve HOA metrics',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
81
routes/assessments.js
Normal file
81
routes/assessments.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { specialAssessmentRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/assessments
|
||||
* Get all special assessments for site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const assessments = await specialAssessmentRepository.findBySiteId(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: assessments,
|
||||
message: 'Special assessments retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get special assessments:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve special assessments',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/assessments
|
||||
* Create special assessment
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, assessment_name, description, total_amount, unit_count, due_date, project_type, created_by } = req.body;
|
||||
|
||||
if (!site_id || !assessment_name || !total_amount || !unit_count || !due_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: site_id, assessment_name, total_amount, unit_count, due_date'
|
||||
});
|
||||
}
|
||||
|
||||
const assessment = await specialAssessmentRepository.createAssessment({
|
||||
site_id,
|
||||
assessment_name,
|
||||
description: description || '',
|
||||
total_amount,
|
||||
unit_count,
|
||||
amount_per_unit: total_amount / unit_count,
|
||||
due_date,
|
||||
project_type: project_type || 'other',
|
||||
status: 'pending',
|
||||
created_by: created_by || 'system'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: assessment,
|
||||
message: 'Special assessment created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create special assessment:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create special assessment',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
505
routes/balances.js
Normal file
505
routes/balances.js
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
unitBalanceRepository,
|
||||
invoiceRepository,
|
||||
paymentRepository,
|
||||
expenseRepository,
|
||||
transactionRepository,
|
||||
transactionLineRepository
|
||||
} = require('../repositories');
|
||||
const SiteRepository = require('../../../src/repositories/siteRepository');
|
||||
const siteRepository = new SiteRepository();
|
||||
|
||||
/**
|
||||
* Calculate unit balance based on real data:
|
||||
* 1. Starting outstanding balance
|
||||
* 2. Monthly fees (generated automatically)
|
||||
* 3. Additional invoices (services, work orders, etc.)
|
||||
* 4. Minus any payments made
|
||||
*/
|
||||
async function calculateUnitBalance(unitId, siteId) {
|
||||
try {
|
||||
// Get opening balance transactions for this unit
|
||||
const openingBalanceTransactions = await transactionRepository.findByUnitIdAndType(
|
||||
unitId,
|
||||
'opening_balance'
|
||||
);
|
||||
const startingBalance = openingBalanceTransactions.reduce(
|
||||
(sum, t) => sum + parseFloat(t.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Get all adjustment transactions for this unit
|
||||
const adjustmentTransactions = await transactionRepository.findByUnitIdAndType(
|
||||
unitId,
|
||||
'adjustment'
|
||||
);
|
||||
const adjustments = adjustmentTransactions.reduce(
|
||||
(sum, t) => sum + parseFloat(t.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Get all invoices for this unit
|
||||
const invoices = await invoiceRepository.findByUnitId(unitId);
|
||||
|
||||
// Separate invoices by type
|
||||
let monthlyFees = 0;
|
||||
let serviceInvoices = 0;
|
||||
let workOrderInvoices = 0;
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const amount = parseFloat(invoice.amount || 0);
|
||||
if (invoice.invoice_type === 'monthly_fee') {
|
||||
monthlyFees += amount;
|
||||
} else if (invoice.invoice_type === 'service') {
|
||||
serviceInvoices += amount;
|
||||
} else if (invoice.invoice_type === 'work_order') {
|
||||
workOrderInvoices += amount;
|
||||
} else if (invoice.invoice_type === 'other') {
|
||||
serviceInvoices += amount; // Treat other invoices as service invoices
|
||||
}
|
||||
}
|
||||
|
||||
// Get all payments for this unit
|
||||
const payments = await paymentRepository.findByUnitId(unitId);
|
||||
const totalPayments = payments.reduce((sum, payment) => {
|
||||
return sum + parseFloat(payment.payment_amount || 0);
|
||||
}, 0);
|
||||
|
||||
// Calculate totals: opening balance + adjustments + invoices - payments
|
||||
const totalCharges = startingBalance + adjustments + monthlyFees + serviceInvoices + workOrderInvoices;
|
||||
const currentBalance = totalCharges - totalPayments;
|
||||
|
||||
return {
|
||||
unitId,
|
||||
siteId,
|
||||
// Balance components
|
||||
startingBalance,
|
||||
adjustments,
|
||||
monthlyFees,
|
||||
serviceInvoices,
|
||||
workOrderInvoices,
|
||||
totalCharges,
|
||||
totalPayments,
|
||||
currentBalance,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
// Indicate if opening balance transaction exists
|
||||
hasOpeningBalanceRecord: openingBalanceTransactions.length > 0,
|
||||
breakdown: {
|
||||
pendingInvoices: currentBalance > 0 ? Math.max(0, currentBalance) : 0,
|
||||
overdueAmount: currentBalance > 2000 ? Math.max(0, currentBalance - 2000) : 0,
|
||||
creditBalance: currentBalance < 0 ? Math.abs(currentBalance) : 0
|
||||
},
|
||||
// Summary for easy understanding
|
||||
summary: {
|
||||
totalOwed: Math.max(0, currentBalance),
|
||||
totalCredit: Math.abs(Math.min(0, currentBalance)),
|
||||
status: currentBalance > 0 ? 'outstanding' : currentBalance < 0 ? 'credit' : 'paid_up'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calculating unit balance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/balances/units/:unitId
|
||||
* Get single unit balance
|
||||
*/
|
||||
router.get('/units/:unitId', async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await calculateUnitBalance(unitId, site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: balance,
|
||||
message: 'Unit balance retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get unit balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve unit balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/balances/units/batch
|
||||
* Get multiple unit balances
|
||||
*/
|
||||
router.post('/units/batch', async (req, res) => {
|
||||
try {
|
||||
const { unit_ids, site_id } = req.body;
|
||||
|
||||
if (!unit_ids || !Array.isArray(unit_ids)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'unit_ids array is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
const balances = {};
|
||||
|
||||
// Calculate balance for each unit
|
||||
for (const unitId of unit_ids) {
|
||||
try {
|
||||
const balance = await calculateUnitBalance(unitId, site_id);
|
||||
balances[unitId] = balance.currentBalance;
|
||||
} catch (error) {
|
||||
console.error(`Error calculating balance for unit ${unitId}:`, error);
|
||||
balances[unitId] = 0; // Default to 0 on error
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: balances,
|
||||
message: 'Unit balances retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get unit balances:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve unit balances',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/balances/units/:unitId/starting-balance
|
||||
* Set starting balance for a unit (HA admin function)
|
||||
* Creates an opening_balance transaction instead of storing in starting_balance field
|
||||
*/
|
||||
router.post('/units/:unitId/starting-balance', async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { site_id, starting_balance, notes } = req.body;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof starting_balance !== 'number') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'starting_balance must be a number'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if opening balance transaction already exists for this unit
|
||||
const existingOpeningBalance = await transactionRepository.findByUnitIdAndType(
|
||||
unitId,
|
||||
'opening_balance'
|
||||
);
|
||||
|
||||
if (existingOpeningBalance.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Opening balance already set for this unit. Use adjustment transaction to modify balance.'
|
||||
});
|
||||
}
|
||||
|
||||
// Get site to retrieve currency from settings
|
||||
const site = await siteRepository.findById(site_id);
|
||||
const siteCurrency = site?.settings?.currency || 'USD';
|
||||
|
||||
// Create opening balance transaction
|
||||
// IMPORTANT: Preserve the sign - negative balances mean HOA owes unit (credit), positive means unit owes HOA (debit)
|
||||
const transaction = await transactionRepository.createTransaction({
|
||||
site_id,
|
||||
unit_id: unitId,
|
||||
transaction_date: new Date().toISOString().split('T')[0],
|
||||
description: `Opening balance${notes ? ': ' + notes : ''}`,
|
||||
transaction_type: 'opening_balance',
|
||||
amount: starting_balance, // Preserve sign - do NOT use Math.abs()
|
||||
currency: siteCurrency, // Use site's currency from settings
|
||||
status: 'posted',
|
||||
created_by: req.user?.id || 'system',
|
||||
notes: notes || 'Opening balance set'
|
||||
});
|
||||
|
||||
// Create or update unit balance record (for backward compatibility, but balance is calculated from transactions)
|
||||
const balance = await unitBalanceRepository.createOrUpdateUnitBalance({
|
||||
unit_id: unitId,
|
||||
site_id: site_id,
|
||||
current_balance: starting_balance,
|
||||
created_by: req.user?.id || 'system'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
unit_id: unitId,
|
||||
site_id,
|
||||
transaction_id: transaction.id,
|
||||
starting_balance,
|
||||
message: 'Starting balance set successfully'
|
||||
},
|
||||
message: 'Starting balance updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to set starting balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to set starting balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/balances/buildings/:buildingId
|
||||
* Get building-level financial summary
|
||||
*/
|
||||
router.get('/buildings/:buildingId', async (req, res) => {
|
||||
try {
|
||||
const { buildingId } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get units repository to find all units in this building
|
||||
const UnitsRepository = require('../../../src/repositories/unitsRepository');
|
||||
const unitsRepo = new UnitsRepository();
|
||||
|
||||
// Get all units for this building
|
||||
const units = await unitsRepo.findAll({
|
||||
building_id: buildingId,
|
||||
is_deleted: false
|
||||
});
|
||||
|
||||
if (!units || units.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
buildingId,
|
||||
siteId: site_id,
|
||||
totalUnits: 0,
|
||||
totalOutstanding: 0,
|
||||
totalCollected: 0,
|
||||
totalMonthlyFees: 0,
|
||||
averageBalance: 0
|
||||
},
|
||||
message: 'Building financial summary retrieved successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate financial summary for all units
|
||||
let totalOutstanding = 0; // Amount owed by units (opening balances are typically stored negative)
|
||||
let totalCollected = 0;
|
||||
let totalMonthlyFees = 0;
|
||||
let totalCharges = 0;
|
||||
let totalHOAOwes = 0; // HOA owes to units (negative balances/credits)
|
||||
const unitIds = units.map(unit => unit.id);
|
||||
|
||||
for (const unit of units) {
|
||||
try {
|
||||
const balance = await calculateUnitBalance(unit.id, site_id);
|
||||
const current = Number(balance.currentBalance || 0);
|
||||
const payments = Number(balance.totalPayments || 0);
|
||||
const creditToUnit = Math.max(0, current); // HOA owes unit
|
||||
const owedByUnit = Math.max(0, -current); // Unit owes HOA
|
||||
|
||||
totalOutstanding += owedByUnit; // negatives only
|
||||
totalHOAOwes += creditToUnit; // positives only
|
||||
totalCollected += payments + creditToUnit; // cash + prepaid
|
||||
totalMonthlyFees += balance.monthlyFees;
|
||||
totalCharges += balance.totalCharges;
|
||||
} catch (error) {
|
||||
console.error(`Error calculating balance for unit ${unit.id}:`, error);
|
||||
// Continue with next unit if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate HOA owes to vendors (pending expenses)
|
||||
let totalHOAOwesVendors = 0;
|
||||
try {
|
||||
const pendingExpenses = await expenseRepository.findByStatus(site_id, 'pending');
|
||||
totalHOAOwesVendors = pendingExpenses.reduce((sum, expense) => {
|
||||
return sum + parseFloat(expense.total_amount || 0);
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.error('Error calculating pending expenses:', error);
|
||||
}
|
||||
|
||||
// Total HOA owes = credits to units + pending vendor expenses
|
||||
const totalHOAOwesAll = totalHOAOwes + totalHOAOwesVendors;
|
||||
|
||||
// Average balance: total outstanding divided by number of units
|
||||
const averageBalance = unitIds.length > 0 ? totalOutstanding / unitIds.length : 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
buildingId,
|
||||
siteId: site_id,
|
||||
totalUnits: unitIds.length,
|
||||
totalOutstanding, // Others owe to HOA
|
||||
totalHOAOwesUnits: totalHOAOwes, // HOA owes to units (credits)
|
||||
totalHOAOwesVendors, // HOA owes to vendors (pending expenses)
|
||||
totalHOAOwes: totalHOAOwesAll, // Total HOA owes
|
||||
totalCollected,
|
||||
totalMonthlyFees,
|
||||
totalCharges,
|
||||
averageBalance,
|
||||
unitIds
|
||||
},
|
||||
message: 'Building financial summary retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get building financial summary:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve building financial summary',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/balances/site/:siteId
|
||||
* Get site-level financial summary (HOA owes vs owed)
|
||||
*/
|
||||
router.get('/site/:siteId', async (req, res) => {
|
||||
try {
|
||||
const { siteId } = req.params;
|
||||
|
||||
// Get all units for the site
|
||||
const UnitsRepository = require('../../../src/repositories/unitsRepository');
|
||||
const unitsRepo = new UnitsRepository();
|
||||
|
||||
const units = await unitsRepo.findAll({
|
||||
site_id: siteId,
|
||||
is_deleted: false
|
||||
});
|
||||
|
||||
let totalOwedToHOA = 0; // Others owe to HOA (positive balances = receivables)
|
||||
let totalHOAOwesUnits = 0; // HOA owes to units (negative balances/credits)
|
||||
let totalCollected = 0;
|
||||
let totalMonthlyFees = 0;
|
||||
|
||||
for (const unit of units || []) {
|
||||
try {
|
||||
const balance = await calculateUnitBalance(unit.id, siteId);
|
||||
const current = Number(balance.currentBalance || 0);
|
||||
const payments = Number(balance.totalPayments || 0);
|
||||
const creditToUnit = Math.max(0, current); // HOA owes unit
|
||||
const owedByUnit = Math.max(0, -current); // Unit owes HOA
|
||||
|
||||
totalOwedToHOA += owedByUnit; // negatives only
|
||||
totalHOAOwesUnits += creditToUnit; // positives only
|
||||
totalCollected += payments + creditToUnit; // cash + prepaid
|
||||
totalMonthlyFees += balance.monthlyFees;
|
||||
} catch (error) {
|
||||
console.error(`Error calculating balance for unit ${unit.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate HOA owes to vendors (pending expenses)
|
||||
let totalHOAOwesVendors = 0;
|
||||
try {
|
||||
const pendingExpenses = await expenseRepository.findByStatus(siteId, 'pending');
|
||||
totalHOAOwesVendors = pendingExpenses.reduce((sum, expense) => {
|
||||
return sum + parseFloat(expense.total_amount || 0);
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.error('Error calculating pending expenses:', error);
|
||||
}
|
||||
|
||||
// Total HOA owes = credits to units + pending vendor expenses
|
||||
const totalHOAOwes = totalHOAOwesUnits + totalHOAOwesVendors;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
siteId,
|
||||
totalOwedToHOA, // Others owe to HOA
|
||||
totalHOAOwesUnits, // HOA owes to units
|
||||
totalHOAOwesVendors, // HOA owes to vendors
|
||||
totalHOAOwes, // Total HOA owes
|
||||
totalCollected,
|
||||
totalMonthlyFees,
|
||||
totalUnits: units?.length || 0
|
||||
},
|
||||
message: 'Site financial summary retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get site financial summary:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve site financial summary',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/balances/generate-monthly-fees
|
||||
* Generate monthly fee invoices for all units (scheduled job)
|
||||
*/
|
||||
router.post('/generate-monthly-fees', async (req, res) => {
|
||||
try {
|
||||
const { site_id, month, year } = req.body;
|
||||
|
||||
if (!site_id || !month || !year) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id, month, and year are required'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement monthly fee generation from unit_monthly_fees table
|
||||
// For now, return success with 0 invoices
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
site_id,
|
||||
month,
|
||||
year,
|
||||
invoices_created: 0, // Will be implemented in future session
|
||||
message: 'Monthly fee invoices generated successfully'
|
||||
},
|
||||
message: 'Monthly fee invoices generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate monthly fees:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate monthly fees',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
240
routes/budgets.js
Normal file
240
routes/budgets.js
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { budgetRepository, budgetItemRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* Check if site has access to budgeting feature (premium)
|
||||
* Budgeting is available in Professional and Enterprise plans
|
||||
*/
|
||||
async function hasBudgetingAccess(site_id) {
|
||||
// TODO: Implement subscription check
|
||||
// For now, assume all sites have access for testing
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/budgets
|
||||
* Get all budgets for a site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check feature access
|
||||
if (!(await hasBudgetingAccess(site_id))) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Budgeting feature not available',
|
||||
message: 'Please upgrade to Professional or Enterprise plan to access budgeting features'
|
||||
});
|
||||
}
|
||||
|
||||
const budgets = await budgetRepository.findBySiteId(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: budgets,
|
||||
message: 'Budgets retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get budgets:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve budgets',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/budgets/:id
|
||||
* Get budget by ID
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check feature access
|
||||
if (!(await hasBudgetingAccess(site_id))) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Budgeting feature not available',
|
||||
message: 'Please upgrade to Professional or Enterprise plan to access budgeting features'
|
||||
});
|
||||
}
|
||||
|
||||
const budget = await budgetRepository.findById(id);
|
||||
|
||||
if (!budget) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Budget not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify budget belongs to site
|
||||
if (budget.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
// Get budget items
|
||||
const items = await budgetItemRepository.findByBudgetId(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...budget,
|
||||
items
|
||||
},
|
||||
message: 'Budget details retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get budget:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve budget',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/budgets
|
||||
* Create a new budget
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, name, description, fiscal_year, start_date, end_date, total_amount, status, items, created_by } = req.body;
|
||||
|
||||
if (!site_id || !name || !fiscal_year || !start_date || !end_date || !total_amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Site ID, name, fiscal year, start date, end date, and total amount are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check feature access
|
||||
if (!(await hasBudgetingAccess(site_id))) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Budgeting feature not available',
|
||||
message: 'Please upgrade to Professional or Enterprise plan to access budgeting features'
|
||||
});
|
||||
}
|
||||
|
||||
const budget = await budgetRepository.createBudget({
|
||||
site_id,
|
||||
name,
|
||||
description,
|
||||
fiscal_year,
|
||||
start_date,
|
||||
end_date,
|
||||
total_amount,
|
||||
status,
|
||||
created_by: created_by || req.user?.id || 'system'
|
||||
});
|
||||
|
||||
// Create budget items if provided
|
||||
if (items && Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
await budgetItemRepository.createItem({
|
||||
budget_id: budget.id,
|
||||
account_id: item.account_id,
|
||||
planned_amount: item.planned_amount,
|
||||
notes: item.notes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: budget,
|
||||
message: 'Budget created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create budget:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create budget',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/budgets/:id/items
|
||||
* Add budget item
|
||||
*/
|
||||
router.post('/:id/items', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { account_id, planned_amount, notes } = req.body;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!account_id || !planned_amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Account ID and planned amount are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check feature access
|
||||
if (!(await hasBudgetingAccess(site_id))) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Budgeting feature not available',
|
||||
message: 'Please upgrade to Professional or Enterprise plan to access budgeting features'
|
||||
});
|
||||
}
|
||||
|
||||
const item = await budgetItemRepository.createItem({
|
||||
budget_id: id,
|
||||
account_id,
|
||||
planned_amount,
|
||||
notes
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: item,
|
||||
message: 'Budget item added successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add budget item:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to add budget item',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
95
routes/expenses.js
Normal file
95
routes/expenses.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { expenseRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/expenses
|
||||
* Get all expenses for a site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, status, category } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
let expenses;
|
||||
|
||||
if (status) {
|
||||
expenses = await expenseRepository.findByStatus(site_id, status);
|
||||
} else {
|
||||
expenses = await expenseRepository.findBySiteId(site_id);
|
||||
}
|
||||
|
||||
// Filter by category if provided
|
||||
if (category) {
|
||||
expenses = expenses.filter(exp => exp.category === category);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: expenses,
|
||||
message: 'Expenses retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get expenses:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve expenses',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/expenses
|
||||
* Create a new expense
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, expense_date, vendor_name, vendor_id, category, description, amount, tax_amount, payment_method, receipt_url, approved_by, notes, created_by } = req.body;
|
||||
|
||||
if (!site_id || !expense_date || !category || !description || !amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Site ID, expense date, category, description, and amount are required'
|
||||
});
|
||||
}
|
||||
|
||||
const expense = await expenseRepository.createExpense({
|
||||
site_id,
|
||||
expense_date,
|
||||
vendor_name,
|
||||
vendor_id,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
tax_amount,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
approved_by,
|
||||
notes,
|
||||
created_by: created_by || req.user?.id || 'system'
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: expense,
|
||||
message: 'Expense created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create expense:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create expense',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
90
routes/index.js
Normal file
90
routes/index.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Import middleware
|
||||
const auth = require('../../../src/middleware/auth');
|
||||
const pluginAuth = require('../../../src/middleware/pluginAuth');
|
||||
|
||||
// Import sub-routers
|
||||
const accountsRoutes = require('./accounts');
|
||||
const budgetsRoutes = require('./budgets');
|
||||
const expensesRoutes = require('./expenses');
|
||||
const revenueRoutes = require('./revenue');
|
||||
const reportsRoutes = require('./reports');
|
||||
const analyticsRoutes = require('./analytics');
|
||||
const transactionsRoutes = require('./transactions');
|
||||
const taxRoutes = require('./tax');
|
||||
const balancesRoutes = require('./balances');
|
||||
const invoicesRoutes = require('./invoices');
|
||||
const ledgerRoutes = require('./ledger');
|
||||
const reconciliationRoutes = require('./reconciliation');
|
||||
const assessmentsRoutes = require('./assessments');
|
||||
|
||||
// Apply common middleware to all plugin routes
|
||||
// Standard auth to populate req.user (required for created_by fields)
|
||||
router.use(auth.authenticateToken);
|
||||
// Lightweight plugin checks (permit-all for now but ensures future extensibility)
|
||||
// Note: We intentionally avoid rate-limiting until configured
|
||||
router.use(pluginAuth.checkPluginSubscription('financials'));
|
||||
|
||||
// Health and info endpoints
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
plugin: 'financials',
|
||||
version: '1.0.0',
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Financials plugin is working!'
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/info', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
plugin: {
|
||||
name: 'financials',
|
||||
displayName: 'Financials Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Comprehensive financial management including accounting, budgeting, expense tracking, and financial reporting',
|
||||
permissions: [
|
||||
'view_financials',
|
||||
'manage_accounts',
|
||||
'create_budgets',
|
||||
'generate_reports',
|
||||
'manage_expenses',
|
||||
'manage_revenue',
|
||||
'view_analytics',
|
||||
'manage_tax_settings',
|
||||
'approve_transactions'
|
||||
],
|
||||
features: [
|
||||
'Chart of Accounts',
|
||||
'Budget Management',
|
||||
'Expense Tracking',
|
||||
'Revenue Management',
|
||||
'Financial Reports',
|
||||
'Analytics Dashboard',
|
||||
'Transaction Management',
|
||||
'Tax Management'
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mount sub-routers
|
||||
router.use('/accounts', accountsRoutes);
|
||||
router.use('/budgets', budgetsRoutes);
|
||||
router.use('/expenses', expensesRoutes);
|
||||
router.use('/revenue', revenueRoutes);
|
||||
router.use('/reports', reportsRoutes);
|
||||
router.use('/analytics', analyticsRoutes);
|
||||
router.use('/transactions', transactionsRoutes);
|
||||
router.use('/tax', taxRoutes);
|
||||
router.use('/balances', balancesRoutes);
|
||||
router.use('/invoices', invoicesRoutes);
|
||||
router.use('/ledger', ledgerRoutes);
|
||||
router.use('/reconciliation', reconciliationRoutes);
|
||||
router.use('/assessments', assessmentsRoutes);
|
||||
|
||||
module.exports = router;
|
||||
299
routes/invoices.js
Normal file
299
routes/invoices.js
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { invoiceRepository, paymentRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/invoices
|
||||
* Get all invoices for a site or unit
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, unit_id, status, invoice_type } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
let invoices;
|
||||
|
||||
// Filter by unit_id if provided
|
||||
if (unit_id) {
|
||||
invoices = await invoiceRepository.findByUnitId(unit_id);
|
||||
} else {
|
||||
invoices = await invoiceRepository.findBySiteId(site_id);
|
||||
}
|
||||
|
||||
// Filter by status if provided
|
||||
if (status) {
|
||||
invoices = invoices.filter(inv => inv.status === status);
|
||||
}
|
||||
|
||||
// Filter by invoice_type if provided
|
||||
if (invoice_type) {
|
||||
invoices = invoices.filter(inv => inv.invoice_type === invoice_type);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: invoices,
|
||||
message: 'Invoices retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get invoices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve invoices',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/invoices/:id
|
||||
* Get invoice by ID
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await invoiceRepository.findById(id);
|
||||
|
||||
if (!invoice) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invoice not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify invoice belongs to site
|
||||
if (invoice.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: invoice,
|
||||
message: 'Invoice details retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get invoice:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve invoice',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/invoices
|
||||
* Create a new invoice
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, unit_id, invoice_number, invoice_type, description, amount, due_date, work_order_id, notes, created_by } = req.body;
|
||||
|
||||
if (!site_id || !unit_id || !invoice_type || !description || !amount || !due_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Site ID, unit ID, invoice type, description, amount, and due date are required'
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await invoiceRepository.createInvoice({
|
||||
site_id,
|
||||
unit_id,
|
||||
invoice_number,
|
||||
invoice_type,
|
||||
description,
|
||||
amount,
|
||||
due_date,
|
||||
work_order_id,
|
||||
notes,
|
||||
created_by: created_by || req.user?.id || 'system'
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: invoice,
|
||||
message: 'Invoice created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create invoice:', error);
|
||||
|
||||
if (error.message.includes('already exists')) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Invoice number already exists',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create invoice',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/plugins/financials/invoices/:id/pay
|
||||
* Mark invoice as paid
|
||||
*/
|
||||
router.put('/:id/pay', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { payment_date, payment_method, payment_reference } = req.body;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get invoice first to verify it exists
|
||||
const invoice = await invoiceRepository.findById(id);
|
||||
|
||||
if (!invoice) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invoice not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedInvoice = await invoiceRepository.markAsPaid(id, {
|
||||
payment_date,
|
||||
payment_method,
|
||||
payment_reference
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedInvoice,
|
||||
message: 'Invoice marked as paid successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to mark invoice as paid:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to mark invoice as paid',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/plugins/financials/invoices/:id
|
||||
* Update invoice
|
||||
*/
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id, description, amount, due_date, status, notes } = req.body;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get invoice first
|
||||
const invoice = await invoiceRepository.findById(id);
|
||||
|
||||
if (!invoice) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invoice not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (invoice.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedInvoice = await invoiceRepository.updateById(id, {
|
||||
description,
|
||||
amount,
|
||||
due_date,
|
||||
status,
|
||||
notes,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedInvoice,
|
||||
message: 'Invoice updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update invoice:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update invoice',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/invoices/overdue
|
||||
* Get overdue invoices
|
||||
*/
|
||||
router.get('/overdue', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
const overdueInvoices = await invoiceRepository.findOverdue(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: overdueInvoices,
|
||||
message: 'Overdue invoices retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get overdue invoices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve overdue invoices',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
156
routes/ledger.js
Normal file
156
routes/ledger.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { generalLedgerRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/ledger/general
|
||||
* Get general ledger for all accounts with balances
|
||||
* Query params: site_id (required), start_date, end_date
|
||||
*/
|
||||
router.get('/general', async (req, res) => {
|
||||
try {
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const generalLedger = await generalLedgerRepository.getGeneralLedger(site_id, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: generalLedger,
|
||||
message: 'General ledger retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get general ledger:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve general ledger',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/ledger/account/:accountId
|
||||
* Get ledger entries for a specific account with running balance
|
||||
* Query params: site_id (required), start_date, end_date
|
||||
*/
|
||||
router.get('/account/:accountId', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const ledgerEntries = await generalLedgerRepository.getAccountLedger(accountId, site_id, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: ledgerEntries,
|
||||
message: 'Account ledger retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get account ledger:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve account ledger',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/ledger/account/:accountId/balance
|
||||
* Get account balance summary
|
||||
* Query params: site_id (required), start_date, end_date
|
||||
*/
|
||||
router.get('/account/:accountId/balance', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const balance = await generalLedgerRepository.getAccountBalance(accountId, site_id, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: balance,
|
||||
message: 'Account balance retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get account balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve account balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/ledger/trial-balance
|
||||
* Get trial balance report
|
||||
* Query params: site_id (required), start_date, end_date
|
||||
*/
|
||||
router.get('/trial-balance', async (req, res) => {
|
||||
try {
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const trialBalance = await generalLedgerRepository.getTrialBalance(site_id, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trialBalance,
|
||||
message: 'Trial balance retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get trial balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve trial balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
118
routes/reconciliation.js
Normal file
118
routes/reconciliation.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
bankAccountRepository,
|
||||
bankStatementRepository,
|
||||
bankStatementTransactionRepository,
|
||||
bankReconciliationRepository
|
||||
} = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reconciliation/accounts
|
||||
* Get all bank accounts for site
|
||||
*/
|
||||
router.get('/accounts', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const accounts = await bankAccountRepository.findBySiteId(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accounts,
|
||||
message: 'Bank accounts retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get bank accounts:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve bank accounts',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reconciliation/accounts/:accountId/statements
|
||||
* Get bank statements for an account
|
||||
*/
|
||||
router.get('/accounts/:accountId/statements', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const statements = await bankStatementRepository.findByBankAccountId(accountId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statements,
|
||||
message: 'Bank statements retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get bank statements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve bank statements',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reconciliation/statements/:statementId/transactions
|
||||
* Get transactions for a statement
|
||||
*/
|
||||
router.get('/statements/:statementId/transactions', async (req, res) => {
|
||||
try {
|
||||
const { statementId } = req.params;
|
||||
const transactions = await bankStatementTransactionRepository.findByStatementId(statementId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: transactions,
|
||||
message: 'Statement transactions retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get statement transactions:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve statement transactions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reconciliation/accounts/:accountId/metrics
|
||||
* Get reconciliation metrics for an account
|
||||
*/
|
||||
router.get('/accounts/:accountId/metrics', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { as_of_date } = req.query;
|
||||
|
||||
const asOfDate = as_of_date || new Date().toISOString().split('T')[0];
|
||||
const metrics = await bankReconciliationRepository.calculateReconciliationMetrics(accountId, asOfDate);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: metrics,
|
||||
message: 'Reconciliation metrics retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get reconciliation metrics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve reconciliation metrics',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
174
routes/reports.js
Normal file
174
routes/reports.js
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { reportRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reports
|
||||
* Get all generated reports for site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const reports = await reportRepository.findBySiteId(site_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: reports,
|
||||
message: 'Reports retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get reports:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve reports',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reports/trial-balance
|
||||
* Generate trial balance report
|
||||
*/
|
||||
router.get('/trial-balance', async (req, res) => {
|
||||
try {
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const trialBalance = await reportRepository.generateTrialBalance(site_id, options);
|
||||
|
||||
// Store report in database
|
||||
await reportRepository.createReport({
|
||||
site_id,
|
||||
report_type: 'trial_balance',
|
||||
report_name: `Trial Balance - ${options.endDate || 'All Time'}`,
|
||||
report_date: options.endDate || new Date().toISOString().split('T')[0],
|
||||
parameters: { ...options },
|
||||
generated_by: 'system'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trialBalance,
|
||||
message: 'Trial balance generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate trial balance:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate trial balance',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reports/balance-sheet
|
||||
* Generate balance sheet
|
||||
*/
|
||||
router.get('/balance-sheet', async (req, res) => {
|
||||
try {
|
||||
const { site_id, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const balanceSheet = await reportRepository.generateBalanceSheet(site_id, options);
|
||||
|
||||
// Store report in database
|
||||
await reportRepository.createReport({
|
||||
site_id,
|
||||
report_type: 'balance_sheet',
|
||||
report_name: `Balance Sheet - ${options.endDate || 'As of Today'}`,
|
||||
report_date: options.endDate || new Date().toISOString().split('T')[0],
|
||||
parameters: { ...options },
|
||||
generated_by: 'system'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: balanceSheet,
|
||||
message: 'Balance sheet generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate balance sheet:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate balance sheet',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/reports/income-statement
|
||||
* Generate income statement
|
||||
*/
|
||||
router.get('/income-statement', async (req, res) => {
|
||||
try {
|
||||
const { site_id, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required parameter: site_id'
|
||||
});
|
||||
}
|
||||
|
||||
const options = {};
|
||||
if (start_date) options.startDate = start_date;
|
||||
if (end_date) options.endDate = end_date;
|
||||
|
||||
const incomeStatement = await reportRepository.generateIncomeStatement(site_id, options);
|
||||
|
||||
// Store report in database
|
||||
await reportRepository.createReport({
|
||||
site_id,
|
||||
report_type: 'income_statement',
|
||||
report_name: `Income Statement - ${options.startDate} to ${options.endDate}`,
|
||||
report_date: options.end_date || new Date().toISOString().split('T')[0],
|
||||
parameters: { ...options },
|
||||
generated_by: 'system'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: incomeStatement,
|
||||
message: 'Income statement generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate income statement:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate income statement',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
98
routes/revenue.js
Normal file
98
routes/revenue.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { revenueRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/revenue
|
||||
* Get all revenue for a site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, unit_id, source, payment_status } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
let revenue;
|
||||
|
||||
if (source) {
|
||||
revenue = await revenueRepository.findBySource(site_id, source);
|
||||
} else {
|
||||
revenue = await revenueRepository.findBySiteId(site_id);
|
||||
}
|
||||
|
||||
// Filter by unit_id if provided
|
||||
if (unit_id) {
|
||||
revenue = revenue.filter(rev => rev.unit_id === unit_id);
|
||||
}
|
||||
|
||||
// Filter by payment_status if provided
|
||||
if (payment_status) {
|
||||
revenue = revenue.filter(rev => rev.payment_status === payment_status);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: revenue,
|
||||
message: 'Revenue retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get revenue:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve revenue',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/revenue
|
||||
* Create a new revenue entry
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, revenue_date, source, description, amount, unit_id, resident_id, payment_method, receipt_url, notes, created_by } = req.body;
|
||||
|
||||
if (!site_id || !revenue_date || !source || !description || !amount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields',
|
||||
message: 'Site ID, revenue date, source, description, and amount are required'
|
||||
});
|
||||
}
|
||||
|
||||
const revenue = await revenueRepository.createRevenue({
|
||||
site_id,
|
||||
revenue_date,
|
||||
source,
|
||||
description,
|
||||
amount,
|
||||
unit_id,
|
||||
resident_id,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
notes,
|
||||
created_by: created_by || req.user?.id || 'system'
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: revenue,
|
||||
message: 'Revenue created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create revenue:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create revenue',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
30
routes/tax.js
Normal file
30
routes/tax.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const taxSettings = [
|
||||
{
|
||||
id: '1',
|
||||
tax_name: 'Sales Tax',
|
||||
tax_rate: 0.085,
|
||||
tax_type: 'percentage',
|
||||
is_active: true
|
||||
}
|
||||
];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: taxSettings,
|
||||
message: 'Tax settings retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve tax settings',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
269
routes/transactions.js
Normal file
269
routes/transactions.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { transactionRepository, transactionLineRepository } = require('../repositories');
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/transactions
|
||||
* Get all transactions for a site
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, type, status, start_date, end_date } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
let transactions;
|
||||
|
||||
// Filter by type if provided
|
||||
if (type) {
|
||||
transactions = await transactionRepository.findByType(site_id, type);
|
||||
}
|
||||
// Filter by date range if provided
|
||||
else if (start_date && end_date) {
|
||||
transactions = await transactionRepository.findByDateRange(site_id, start_date, end_date);
|
||||
}
|
||||
// Filter by status if provided
|
||||
else if (status) {
|
||||
transactions = await transactionRepository.findByStatus(site_id, status);
|
||||
}
|
||||
// Get all transactions
|
||||
else {
|
||||
transactions = await transactionRepository.findBySiteId(site_id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: transactions,
|
||||
message: 'Transactions retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get transactions:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve transactions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/financials/transactions/:id
|
||||
* Get transaction by ID
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = await transactionRepository.findById(id);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Transaction not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify transaction belongs to site
|
||||
if (transaction.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
// Get transaction lines for double-entry view
|
||||
const lines = await transactionLineRepository.findByTransactionId(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...transaction,
|
||||
lines
|
||||
},
|
||||
message: 'Transaction details retrieved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get transaction:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve transaction',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/plugins/financials/transactions
|
||||
* Create a new transaction with double-entry validation
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { site_id, transaction_date, reference_number, description, transaction_type, amount, currency, status, created_by, lines } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!site_id || !transaction_date || !description || !transaction_type || amount === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: site_id, transaction_date, description, transaction_type, amount'
|
||||
});
|
||||
}
|
||||
|
||||
// If lines provided, validate double-entry balance
|
||||
if (lines && Array.isArray(lines)) {
|
||||
const totalDebits = lines.reduce((sum, line) => sum + parseFloat(line.debit_amount || 0), 0);
|
||||
const totalCredits = lines.reduce((sum, line) => sum + parseFloat(line.credit_amount || 0), 0);
|
||||
|
||||
if (Math.abs(totalDebits - totalCredits) > 0.01) { // Allow small floating point differences
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Debits must equal credits',
|
||||
message: `Total debits: ${totalDebits}, Total credits: ${totalCredits}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create transaction
|
||||
const transaction = await transactionRepository.createTransaction({
|
||||
site_id,
|
||||
transaction_date,
|
||||
reference_number,
|
||||
description,
|
||||
transaction_type,
|
||||
amount,
|
||||
currency: currency || 'USD',
|
||||
status: status || 'pending',
|
||||
created_by: created_by || req.user?.id || 'system'
|
||||
});
|
||||
|
||||
// Create transaction lines if provided
|
||||
if (lines && Array.isArray(lines)) {
|
||||
for (const line of lines) {
|
||||
await transactionLineRepository.create({
|
||||
transaction_id: transaction.id,
|
||||
account_id: line.account_id,
|
||||
debit_amount: line.debit_amount || 0,
|
||||
credit_amount: line.credit_amount || 0,
|
||||
description: line.description,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get the created lines to return with transaction
|
||||
const createdLines = await transactionLineRepository.findByTransactionId(transaction.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
...transaction,
|
||||
lines: createdLines
|
||||
},
|
||||
message: 'Transaction created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create transaction:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create transaction',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/plugins/financials/transactions/:id/approve
|
||||
* Approve a transaction
|
||||
*/
|
||||
router.put('/:id/approve', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { approved_by } = req.body;
|
||||
|
||||
if (!approved_by) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'approved_by is required'
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = await transactionRepository.approveTransaction(id, approved_by);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: transaction,
|
||||
message: 'Transaction approved successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to approve transaction:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to approve transaction',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/plugins/financials/transactions/:id
|
||||
* Delete a transaction
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { site_id } = req.query;
|
||||
|
||||
if (!site_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'site_id is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get transaction to verify it exists and belongs to site
|
||||
const transaction = await transactionRepository.findById(id);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Transaction not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (transaction.site_id !== site_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete transaction (lines will be deleted via CASCADE)
|
||||
await transactionRepository.deleteById(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Transaction deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete transaction',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
71
services/accountSeedingService.js
Normal file
71
services/accountSeedingService.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
const { ChartOfAccountsRepository } = require('../repositories');
|
||||
const logger = require('../../../src/utils/logger');
|
||||
|
||||
// Define the 7 default accounts to seed for every new HOA site
|
||||
const DEFAULT_ACCOUNTS = [
|
||||
{ code: '1000', name: 'Checking Account', type: 'asset', description: 'Holds all cash; receives monthly fees, pays all bills.' },
|
||||
{ code: '1100', name: 'Accounts Receivable – Owners', type: 'asset', description: 'Tracks unpaid monthly service fees (who owes what).' },
|
||||
{ code: '2000', name: 'Accounts Payable – Vendors', type: 'liability', description: 'Tracks unpaid bills (what the HOA owes).' },
|
||||
{ code: '2100', name: 'Prepaid Assessments', type: 'liability', description: 'Records fees paid in advance (liability until earned).' },
|
||||
{ code: '3100', name: 'HOA Fund Balance', type: 'equity', description: 'Starting/ending equity; required for balance sheet.' },
|
||||
{ code: '4100', name: 'Monthly Service Fees', type: 'income', description: 'All regular income comes here.' },
|
||||
{ code: '5100', name: 'Operating Expenses', type: 'expense', description: 'All spending (utilities, repairs, insurance, etc.).' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed default financial accounts for a given site
|
||||
* This function is idempotent per account code per site: it skips any that already exist.
|
||||
* @param {string} siteId
|
||||
* @param {string|null} createdByUserId
|
||||
*/
|
||||
async function seedDefaultAccounts(siteId, createdByUserId = null) {
|
||||
const chartOfAccountsRepository = new ChartOfAccountsRepository();
|
||||
|
||||
const createdAccountIds = [];
|
||||
try {
|
||||
for (const acc of DEFAULT_ACCOUNTS) {
|
||||
// Skip if exists
|
||||
const exists = await chartOfAccountsRepository.findByCode(siteId, acc.code);
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newAccount = await chartOfAccountsRepository.createAccount({
|
||||
site_id: siteId,
|
||||
account_code: acc.code,
|
||||
account_name: acc.name,
|
||||
account_type: acc.type,
|
||||
description: acc.description,
|
||||
is_active: true,
|
||||
// created_by is optional in repository
|
||||
...(createdByUserId ? { created_by: createdByUserId } : {})
|
||||
});
|
||||
|
||||
if (newAccount && newAccount.id) {
|
||||
createdAccountIds.push(newAccount.id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Seeded default financial accounts for site ${siteId}: ${createdAccountIds.length} created`);
|
||||
return { success: true, created: createdAccountIds.length };
|
||||
} catch (error) {
|
||||
// Best-effort rollback of any accounts created in this run
|
||||
try {
|
||||
for (const id of createdAccountIds) {
|
||||
await chartOfAccountsRepository.deleteById(id);
|
||||
}
|
||||
logger.warn(`Rolled back ${createdAccountIds.length} seeded accounts for site ${siteId} due to error.`);
|
||||
} catch (rollbackError) {
|
||||
logger.error('Failed to rollback seeded accounts:', rollbackError);
|
||||
}
|
||||
logger.error('Failed to seed default accounts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedDefaultAccounts,
|
||||
DEFAULT_ACCOUNTS
|
||||
};
|
||||
|
||||
|
||||
51
version.txt
Normal file
51
version.txt
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Financials Plugin Development Notes
|
||||
|
||||
This file tracks only recent development activities. For complete historical changelog, see `CHANGELOG.md`.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-02
|
||||
**Current Version:** 1.0.0
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes (Current Development Cycle)
|
||||
|
||||
### 2025-11-02: Plugin Structure Enhancement (v1.0.0)
|
||||
- **Enhanced plugin.json**: Added coreApiVersion, build configuration, frontend configuration, and dependencies
|
||||
- **Core API Compatibility**: Declared compatibility with Core API v2.3.0+ (max v3.0.0)
|
||||
- **Build Configuration**: Added build.exclude and build.include for packaging
|
||||
- **Frontend Support**: Enabled frontend bundle configuration
|
||||
- **Migration Support**: Added database migrations path configuration
|
||||
- **Modified**: `plugin.json`
|
||||
|
||||
---
|
||||
|
||||
## Active Development Focus
|
||||
|
||||
### Core Features
|
||||
- Accounting and chart of accounts
|
||||
- Budget management (monthly, quarterly, annual)
|
||||
- Expense tracking and reporting
|
||||
- Revenue management
|
||||
- Financial reporting and analytics
|
||||
- Bank reconciliation
|
||||
- General ledger
|
||||
- Tax settings and management
|
||||
- Special assessments
|
||||
- Unit balance tracking
|
||||
- Transaction management
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Plugin separation and independent repository
|
||||
- Build script for zip packaging
|
||||
- Frontend bundle creation
|
||||
- Migration scripts for database updates
|
||||
|
||||
---
|
||||
|
||||
**Note**: This plugin is ready for separation into independent repository and zip-based deployment.
|
||||
|
||||
Loading…
Reference in a new issue