diff --git a/migration/1768841352156-AddBankYearlyBalances.js b/migration/1768841352156-AddBankYearlyBalances.js new file mode 100644 index 0000000000..5e3b2a3a50 --- /dev/null +++ b/migration/1768841352156-AddBankYearlyBalances.js @@ -0,0 +1,102 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Add yearlyBalances column to bank table and populate with historical balances. + * + * Format: { "2024": 2437.57, "2025": 0 } + * - Each year stores the closing balance (Endbestand) for that year + * - Opening balance (Anfangsbestand) is calculated as previous year's closing balance + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBankYearlyBalances1768841352156 { + name = 'AddBankYearlyBalances1768841352156' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Add yearlyBalances column + await queryRunner.query(` + ALTER TABLE "dbo"."bank" ADD "yearlyBalances" nvarchar(MAX) NULL + `); + + // Update each bank with their yearly balances (closing balances per year) + // Format: { "YYYY": closingBalance } - opening is previous year's closing + // Data source: bank anfangsbestände per 1.1.xxxx - Endbestaende.csv + const bankBalances = [ + // Bank Frick EUR (ID: 1) + { + id: 1, + balances: { "2022": 11407.01, "2023": 0, "2024": 0, "2025": 0 } + }, + // Bank Frick CHF (ID: 2) + { + id: 2, + balances: { "2022": 116.54, "2023": 0, "2024": 0, "2025": 0 } + }, + // Bank Frick USD (ID: 3) + { + id: 3, + balances: { "2022": 6670.51, "2023": 0, "2024": 0, "2025": 0 } + }, + // Olkypay EUR (ID: 4) + { + id: 4, + balances: { "2022": 15702.24, "2023": 35581.94, "2024": 11219.32, "2025": 21814.76 } + }, + // Maerki Baumann EUR (ID: 5) + { + id: 5, + balances: { "2022": 67230.42, "2023": 26327.80, "2024": 3312.22, "2025": 0 } + }, + // Maerki Baumann CHF (ID: 6) + { + id: 6, + balances: { "2022": 30549.23, "2023": 8011.98, "2024": 2437.57, "2025": 0 } + }, + // Revolut EUR (ID: 7) + { + id: 7, + balances: { "2022": 8687.49, "2023": 3303.60, "2024": 0, "2025": 0 } + }, + // Raiffeisen CHF (ID: 13) + { + id: 13, + balances: { "2022": 0, "2023": 0, "2024": 0, "2025": 1161.67 } + }, + // Yapeal CHF (ID: 15) + { + id: 15, + balances: { "2022": 0, "2023": 0, "2024": 0, "2025": 1557.73 } + }, + // Yapeal EUR (ID: 16) + { + id: 16, + balances: { "2022": 0, "2023": 0, "2024": 0, "2025": 2568.79 } + }, + ]; + + for (const bank of bankBalances) { + const jsonValue = JSON.stringify(bank.balances).replace(/'/g, "''"); + await queryRunner.query(` + UPDATE "dbo"."bank" + SET "yearlyBalances" = '${jsonValue}' + WHERE "id" = ${bank.id} + `); + } + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "dbo"."bank" DROP COLUMN "yearlyBalances" + `); + } +} diff --git a/scripts/migrate-yearly-balances.js b/scripts/migrate-yearly-balances.js new file mode 100644 index 0000000000..9dbb5ae3f5 --- /dev/null +++ b/scripts/migrate-yearly-balances.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * Migrate yearlyBalances from old format to new format + * + * Old format: { "2025": { "opening": 2437.57, "closing": 0 } } + * New format: { "2024": 2437.57, "2025": 0 } + * + * Opening balance is now calculated as previous year's closing balance + */ + +const mssql = require('mssql'); +const fs = require('fs'); +const path = require('path'); + +const mainEnvFile = path.join(__dirname, '..', '.env'); +const mainEnv = {}; +fs.readFileSync(mainEnvFile, 'utf-8').split('\n').forEach(line => { + const match = line.match(/^([^#=]+)=(.*)$/); + if (match) mainEnv[match[1].trim()] = match[2].trim(); +}); + +const config = { + server: mainEnv.SQL_HOST || 'localhost', + port: parseInt(mainEnv.SQL_PORT || '1433'), + user: mainEnv.SQL_USERNAME || 'sa', + password: mainEnv.SQL_PASSWORD, + database: mainEnv.SQL_DB || 'dfx', + options: { encrypt: false, trustServerCertificate: true }, +}; + +async function migrateBalances() { + const pool = await mssql.connect(config); + + const result = await pool.request().query('SELECT id, name, iban, yearlyBalances FROM bank WHERE yearlyBalances IS NOT NULL'); + + console.log('Migrating yearlyBalances to new format...'); + console.log('Old format: {"2025": {"opening": X, "closing": Y}}'); + console.log('New format: {"2024": X, "2025": Y} (opening = previous year closing)\n'); + + for (const bank of result.recordset) { + const oldBalances = JSON.parse(bank.yearlyBalances); + const newBalances = {}; + + for (const [year, data] of Object.entries(oldBalances)) { + if (typeof data === 'object' && data !== null) { + // Old format: { opening: X, closing: Y } + const { opening, closing } = data; + + // Store closing for this year + if (closing !== undefined) { + newBalances[year] = closing; + } + + // Store opening as previous year's closing + if (opening && opening !== 0) { + const prevYear = (parseInt(year) - 1).toString(); + newBalances[prevYear] = opening; + } + } else { + // Already in new format (just a number) + newBalances[year] = data; + } + } + + const newJson = JSON.stringify(newBalances); + + console.log(bank.name + ' (' + bank.iban + '):'); + console.log(' Old: ' + bank.yearlyBalances); + console.log(' New: ' + newJson); + + await pool.request() + .input('id', mssql.Int, bank.id) + .input('balances', mssql.NVarChar, newJson) + .query('UPDATE bank SET yearlyBalances = @balances WHERE id = @id'); + } + + console.log('\nMigration complete!'); + + const verify = await pool.request().query('SELECT name, iban, yearlyBalances FROM bank WHERE yearlyBalances IS NOT NULL'); + console.log('\nVerification:'); + verify.recordset.forEach(b => console.log(' ' + b.name + ': ' + b.yearlyBalances)); + + await pool.close(); +} + +migrateBalances().catch(e => { + console.error('Error:', e.message); + process.exit(1); +}); diff --git a/scripts/sync-bank-tx-iban.js b/scripts/sync-bank-tx-iban.js new file mode 100644 index 0000000000..c8bc587028 --- /dev/null +++ b/scripts/sync-bank-tx-iban.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +/** + * Sync bank_tx.accountIban from Production to Local DB + * + * This script: + * 1. Fetches accountIban values from production DB via debug endpoint (SELECT only) + * 2. Updates the local DB directly via mssql connection + * + * Usage: + * node scripts/sync-bank-tx-iban.js + * + * Requirements: + * - .env.db-debug with DEBUG_ADDRESS and DEBUG_SIGNATURE + * - Local SQL Server running with credentials from .env + * - Production API accessible + */ + +const fs = require('fs'); +const path = require('path'); +const mssql = require('mssql'); + +// Load db-debug environment +const dbDebugEnvFile = path.join(__dirname, '.env.db-debug'); +if (!fs.existsSync(dbDebugEnvFile)) { + console.error('Error: .env.db-debug not found'); + process.exit(1); +} + +const dbDebugEnv = {}; +fs.readFileSync(dbDebugEnvFile, 'utf-8').split('\n').forEach(line => { + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length) { + dbDebugEnv[key.trim()] = valueParts.join('=').trim(); + } +}); + +// Load main .env for local DB credentials +const mainEnvFile = path.join(__dirname, '..', '.env'); +if (!fs.existsSync(mainEnvFile)) { + console.error('Error: .env not found'); + process.exit(1); +} + +const mainEnv = {}; +fs.readFileSync(mainEnvFile, 'utf-8').split('\n').forEach(line => { + const match = line.match(/^([^#=]+)=(.*)$/); + if (match) { + mainEnv[match[1].trim()] = match[2].trim(); + } +}); + +const PROD_API_URL = dbDebugEnv.DEBUG_API_URL || 'https://api.dfx.swiss/v1'; +const DEBUG_ADDRESS = dbDebugEnv.DEBUG_ADDRESS; +const DEBUG_SIGNATURE = dbDebugEnv.DEBUG_SIGNATURE; + +// Local DB config +const localDbConfig = { + server: mainEnv.SQL_HOST || 'localhost', + port: parseInt(mainEnv.SQL_PORT || '1433'), + user: mainEnv.SQL_USERNAME || 'sa', + password: mainEnv.SQL_PASSWORD, + database: mainEnv.SQL_DB || 'dfx', + options: { + encrypt: false, + trustServerCertificate: true, + }, +}; + +async function getToken(apiUrl, address, signature) { + const response = await fetch(`${apiUrl}/auth/signIn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, signature }), + }); + const data = await response.json(); + if (!data.accessToken) { + throw new Error(`Auth failed for ${apiUrl}: ${JSON.stringify(data)}`); + } + return data.accessToken; +} + +async function executeQuery(apiUrl, token, sql) { + const response = await fetch(`${apiUrl}/gs/debug`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ sql }), + }); + const data = await response.json(); + if (data.statusCode && data.statusCode >= 400) { + throw new Error(`Query failed: ${data.message}`); + } + return data; +} + +async function main() { + console.log('=== Sync bank_tx.accountIban from Production to Local ===\n'); + + // Connect to local DB + console.log('Connecting to local database...'); + console.log(` Server: ${localDbConfig.server}:${localDbConfig.port}`); + console.log(` Database: ${localDbConfig.database}`); + + let pool; + try { + pool = await mssql.connect(localDbConfig); + console.log('✓ Local database connected\n'); + } catch (e) { + console.error('Failed to connect to local database:', e.message); + process.exit(1); + } + + // Get production token + console.log('Authenticating to Production...'); + const prodToken = await getToken(PROD_API_URL, DEBUG_ADDRESS, DEBUG_SIGNATURE); + console.log('✓ Production authenticated\n'); + + // Get distinct accountIbans from production with their IDs + console.log('Fetching accountIban data from Production...'); + + const BATCH_SIZE = 1000; + let lastId = 0; + let totalUpdated = 0; + let hasMore = true; + + while (hasMore) { + console.log(`\nFetching batch after id ${lastId}...`); + + // Get batch of IDs with their accountIban from production (using TOP + WHERE id > lastId) + const prodData = await executeQuery( + PROD_API_URL, + prodToken, + `SELECT TOP ${BATCH_SIZE} id, accountIban FROM bank_tx WHERE accountIban IS NOT NULL AND id > ${lastId} ORDER BY id` + ); + + if (!Array.isArray(prodData) || prodData.length === 0) { + console.log('No more data to fetch.'); + hasMore = false; + break; + } + + console.log(`Fetched ${prodData.length} records from production.`); + + // Group by accountIban to minimize updates + const byIban = {}; + for (const row of prodData) { + if (!row.accountIban) continue; + if (!byIban[row.accountIban]) { + byIban[row.accountIban] = []; + } + byIban[row.accountIban].push(row.id); + if (row.id > lastId) lastId = row.id; + } + + // Update local DB directly + for (const [iban, ids] of Object.entries(byIban)) { + const idList = ids.join(','); + + try { + const request = pool.request(); + request.input('iban', mssql.NVarChar, iban); + await request.query(`UPDATE bank_tx SET accountIban = @iban WHERE id IN (${idList})`); + totalUpdated += ids.length; + process.stdout.write(`\r Updated ${totalUpdated} records...`); + } catch (e) { + console.error(`\nFailed to update IDs for IBAN ${iban}: ${e.message}`); + } + } + + if (prodData.length < BATCH_SIZE) { + hasMore = false; + } + } + + // Close connection + await pool.close(); + + console.log(`\n\n=== Sync Complete ===`); + console.log(`Total records updated: ${totalUpdated}`); + + // Verify the result + console.log('\n=== Verification ==='); + console.log('Run this to check Maerki Baumann CHF 2025:'); + console.log(' curl -s "http://localhost:3000/v1/accounting/balance-sheet/CH3408573177975200001/2025" -H "Authorization: Bearer $TOKEN" | jq .'); +} + +main().catch(e => { + console.error('Error:', e.message); + process.exit(1); +}); diff --git a/scripts/sync-bank-tx.js b/scripts/sync-bank-tx.js new file mode 100644 index 0000000000..bf912e0ec5 --- /dev/null +++ b/scripts/sync-bank-tx.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +/** + * Script to sync bank_tx data from production to local database + * Usage: node scripts/sync-bank-tx.js + */ + +const https = require('https'); +const { execSync } = require('child_process'); +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env.db-debug') }); + +const API_URL = process.env.DEBUG_API_URL || 'https://api.dfx.swiss/v1'; +const DEBUG_ADDRESS = process.env.DEBUG_ADDRESS; +const DEBUG_SIGNATURE = process.env.DEBUG_SIGNATURE; + +const BATCH_SIZE = 500; +const DATE_FROM = '2025-01-01T00:00:00.000Z'; +const DATE_TO = '2025-12-31T23:59:59.999Z'; + +// Non-restricted columns for bank_tx (excluding FK columns: transactionId, batchId) +const COLUMNS = [ + 'id', 'accountServiceRef', 'bookingDate', 'valueDate', 'txCount', 'endToEndId', + 'instructionId', 'txId', 'amount', 'currency', 'creditDebitIndicator', + 'instructedAmount', 'instructedCurrency', 'txAmount', 'txCurrency', + 'exchangeSourceCurrency', 'exchangeTargetCurrency', 'exchangeRate', + 'clearingSystemId', 'memberId', 'bankName', 'chargeAmount', 'chargeCurrency', + 'type', 'aba', 'accountingAmountBeforeFee', 'accountingFeeAmount', + 'accountingFeePercent', 'accountingAmountAfterFee', 'accountingAmountBeforeFeeChf', + 'accountingAmountAfterFeeChf', 'highRisk', 'chargeAmountChf', + 'senderChargeAmount', 'senderChargeCurrency', 'bankReleaseDate', + 'domainCode', 'familyCode', 'subFamilyCode', 'updated', 'created' +]; + +async function httpRequest(options, postData = null) { + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error(`Failed to parse response: ${data}`)); + } + }); + }); + req.on('error', reject); + if (postData) req.write(postData); + req.end(); + }); +} + +async function authenticate() { + console.log('Authenticating...'); + const url = new URL(`${API_URL}/auth/signIn`); + const response = await httpRequest({ + hostname: url.hostname, + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }, JSON.stringify({ address: DEBUG_ADDRESS, signature: DEBUG_SIGNATURE })); + + if (!response.accessToken) { + throw new Error('Authentication failed: ' + JSON.stringify(response)); + } + console.log('Authenticated successfully'); + return response.accessToken; +} + +async function executeQuery(token, sql) { + const url = new URL(`${API_URL}/gs/debug`); + return httpRequest({ + hostname: url.hostname, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }, JSON.stringify({ sql })); +} + +function escapeValue(val) { + if (val === null || val === undefined) return 'NULL'; + if (typeof val === 'boolean') return val ? '1' : '0'; + if (typeof val === 'number') return val.toString(); + // Escape single quotes + return `'${String(val).replace(/'/g, "''")}'`; +} + +function executeSql(sql) { + try { + execSync(`docker exec dfx-mssql /opt/mssql-tools18/bin/sqlcmd -U sa -P 'LocalDev2026@SQL' -d dfx -C -Q "${sql.replace(/"/g, '\\"')}"`, { + stdio: 'pipe', + maxBuffer: 50 * 1024 * 1024 + }); + return true; + } catch (e) { + console.error('SQL Error:', e.message); + return false; + } +} + +async function main() { + try { + const token = await authenticate(); + + // Get count + console.log('\nCounting records...'); + const countResult = await executeQuery(token, + `SELECT COUNT(*) as count FROM bank_tx WHERE valueDate >= '${DATE_FROM}' AND valueDate <= '${DATE_TO}'` + ); + const totalCount = countResult[0].count; + console.log(`Total records to sync: ${totalCount}`); + + // Get ID range + const rangeResult = await executeQuery(token, + `SELECT MIN(id) as minId, MAX(id) as maxId FROM bank_tx WHERE valueDate >= '${DATE_FROM}' AND valueDate <= '${DATE_TO}'` + ); + const minId = rangeResult[0].minId; + const maxId = rangeResult[0].maxId; + console.log(`ID range: ${minId} - ${maxId}`); + + // Clear local table + console.log('\nClearing local bank_tx table...'); + executeSql('SET QUOTED_IDENTIFIER ON; DELETE FROM bank_tx;'); + + // Fetch and insert in batches + let currentId = minId; + let totalInserted = 0; + + while (currentId <= maxId) { + const sql = `SELECT ${COLUMNS.join(', ')} FROM bank_tx WHERE id >= ${currentId} AND id < ${currentId + BATCH_SIZE} AND valueDate >= '${DATE_FROM}' AND valueDate <= '${DATE_TO}' ORDER BY id`; + + console.log(`\nFetching batch starting at ID ${currentId}...`); + const rows = await executeQuery(token, sql); + + if (rows.length === 0) { + currentId += BATCH_SIZE; + continue; + } + + console.log(`Got ${rows.length} rows, inserting...`); + + // Insert in smaller chunks + const chunkSize = 50; + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const values = chunk.map(row => { + const vals = COLUMNS.map(col => escapeValue(row[col])); + return `(${vals.join(', ')})`; + }).join(',\n'); + + const insertSql = `SET QUOTED_IDENTIFIER ON; SET IDENTITY_INSERT bank_tx ON; INSERT INTO bank_tx (${COLUMNS.join(', ')}) VALUES ${values}; SET IDENTITY_INSERT bank_tx OFF;`; + + if (executeSql(insertSql)) { + totalInserted += chunk.length; + } + } + + console.log(`Progress: ${totalInserted} / ${totalCount} (${Math.round(totalInserted/totalCount*100)}%)`); + currentId += BATCH_SIZE; + } + + console.log(`\n✅ Done! Inserted ${totalInserted} records.`); + + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); diff --git a/src/subdomains/core/accounting/accounting.module.ts b/src/subdomains/core/accounting/accounting.module.ts new file mode 100644 index 0000000000..5de709e248 --- /dev/null +++ b/src/subdomains/core/accounting/accounting.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SharedModule } from 'src/shared/shared.module'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { AccountingController } from './controllers/accounting.controller'; +import { AccountingService } from './services/accounting.service'; + +@Module({ + imports: [SharedModule, TypeOrmModule.forFeature([Bank, BankTx])], + controllers: [AccountingController], + providers: [AccountingService], + exports: [], +}) +export class AccountingModule {} diff --git a/src/subdomains/core/accounting/controllers/accounting.controller.ts b/src/subdomains/core/accounting/controllers/accounting.controller.ts new file mode 100644 index 0000000000..7f705da80f --- /dev/null +++ b/src/subdomains/core/accounting/controllers/accounting.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { AccountingService } from '../services/accounting.service'; +import { BankBalanceSheetDto, DetailedBalanceSheetDto } from '../dto/accounting-report.dto'; + +@ApiTags('Accounting') +@Controller('accounting') +export class AccountingController { + constructor(private readonly accountingService: AccountingService) {} + + @Get('balance-sheet/:iban/:year') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + @ApiOkResponse({ type: BankBalanceSheetDto }) + async getBankBalanceSheet(@Param('iban') iban: string, @Param('year') year: string): Promise { + return this.accountingService.getBankBalanceSheet(iban, parseInt(year, 10)); + } + + @Get('balance-sheet/:iban/:year/detailed') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.COMPLIANCE), UserActiveGuard()) + @ApiOkResponse({ type: DetailedBalanceSheetDto }) + async getDetailedBalanceSheet( + @Param('iban') iban: string, + @Param('year') year: string, + ): Promise { + return this.accountingService.getDetailedBalanceSheet(iban, parseInt(year, 10)); + } +} diff --git a/src/subdomains/core/accounting/dto/accounting-report.dto.ts b/src/subdomains/core/accounting/dto/accounting-report.dto.ts new file mode 100644 index 0000000000..31205304b1 --- /dev/null +++ b/src/subdomains/core/accounting/dto/accounting-report.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TypeBreakdownDto { + @ApiProperty({ description: 'Transaction type (e.g., BuyCrypto, BuyFiat)' }) + type: string; + + @ApiProperty({ description: 'Total amount for this type' }) + amount: number; + + @ApiProperty({ description: 'Number of transactions' }) + count: number; +} + +export class DetailedBalanceSheetDto { + @ApiProperty({ description: 'Bank name' }) + bankName: string; + + @ApiProperty({ description: 'Currency (CHF, EUR, etc.)' }) + currency: string; + + @ApiProperty({ description: 'IBAN' }) + iban: string; + + @ApiProperty({ description: 'Year' }) + year: number; + + @ApiProperty({ description: 'Opening balance (from DB or 0)' }) + openingBalance: number; + + @ApiProperty({ description: 'Income breakdown by type', type: [TypeBreakdownDto] }) + incomeByType: TypeBreakdownDto[]; + + @ApiProperty({ description: 'Expenses breakdown by type', type: [TypeBreakdownDto] }) + expensesByType: TypeBreakdownDto[]; + + @ApiProperty({ description: 'Total income (CRDT transactions)' }) + totalIncome: number; + + @ApiProperty({ description: 'Total expenses (DBIT transactions)' }) + totalExpenses: number; + + @ApiProperty({ description: 'Calculated closing balance (opening + income - expenses)' }) + calculatedClosingBalance: number; + + @ApiPropertyOptional({ description: 'Defined closing balance from DB (if exists)' }) + definedClosingBalance?: number; + + @ApiProperty({ description: 'Whether calculated matches defined closing balance' }) + balanceMatches: boolean; + + @ApiProperty({ description: 'Whether defined closing balance exists in DB' }) + hasDefinedClosingBalance: boolean; +} + +export class BankBalanceSheetDto { + @ApiProperty({ description: 'Bank name' }) + bankName: string; + + @ApiProperty({ description: 'Currency (CHF, EUR, etc.)' }) + currency: string; + + @ApiProperty({ description: 'IBAN' }) + iban: string; + + @ApiProperty({ description: 'Year' }) + year: number; + + @ApiProperty({ description: 'Opening balance (from DB or 0)' }) + openingBalance: number; + + @ApiProperty({ description: 'Total income (CRDT transactions)' }) + totalIncome: number; + + @ApiProperty({ description: 'Total expenses (DBIT transactions)' }) + totalExpenses: number; + + @ApiProperty({ description: 'Calculated closing balance (opening + income - expenses)' }) + calculatedClosingBalance: number; + + @ApiPropertyOptional({ description: 'Defined closing balance from DB (if exists)' }) + definedClosingBalance?: number; + + @ApiProperty({ description: 'Whether calculated matches defined closing balance' }) + balanceMatches: boolean; + + @ApiProperty({ description: 'Whether defined closing balance exists in DB' }) + hasDefinedClosingBalance: boolean; +} diff --git a/src/subdomains/core/accounting/services/accounting.service.ts b/src/subdomains/core/accounting/services/accounting.service.ts new file mode 100644 index 0000000000..43d4bede4b --- /dev/null +++ b/src/subdomains/core/accounting/services/accounting.service.ts @@ -0,0 +1,170 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Util } from 'src/shared/utils/util'; +import { Bank } from 'src/subdomains/supporting/bank/bank/bank.entity'; +import { BankTx, BankTxIndicator } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity'; +import { BankBalanceSheetDto, DetailedBalanceSheetDto, TypeBreakdownDto } from '../dto/accounting-report.dto'; + +@Injectable() +export class AccountingService { + constructor( + @InjectRepository(Bank) private readonly bankRepo: Repository, + @InjectRepository(BankTx) private readonly bankTxRepo: Repository, + ) {} + + async getBankBalanceSheet(iban: string, year: number): Promise { + // Find the bank by IBAN + const bank = await this.bankRepo.findOne({ where: { iban } }); + if (!bank) { + throw new NotFoundException(`Bank with IBAN ${iban} not found`); + } + + // Parse yearly balances from JSON: { "2024": 1234.56, "2025": 0 } + // Each year stores only the closing balance + const yearlyBalances: Record = bank.yearlyBalances ? JSON.parse(bank.yearlyBalances) : {}; + + // Opening balance = previous year's closing balance + const previousYear = (year - 1).toString(); + const openingBalance = yearlyBalances[previousYear] ?? 0; + + // Date range for the year + const from = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); // January 1st 00:00:00.000 UTC + const to = new Date(Date.UTC(year, 11, 31, 23, 59, 59, 999)); // December 31st 23:59:59.999 UTC + + // Get total income (CRDT transactions) + const incomeResult = await this.bankTxRepo + .createQueryBuilder('tx') + .select('COALESCE(SUM(tx.amount), 0)', 'total') + .where('tx.accountIban = :iban', { iban }) + .andWhere('tx.valueDate >= :from', { from }) + .andWhere('tx.valueDate <= :to', { to }) + .andWhere('tx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.CREDIT }) + .getRawOne(); + + // Get total expenses (DBIT transactions) + const expensesResult = await this.bankTxRepo + .createQueryBuilder('tx') + .select('COALESCE(SUM(tx.amount), 0)', 'total') + .where('tx.accountIban = :iban', { iban }) + .andWhere('tx.valueDate >= :from', { from }) + .andWhere('tx.valueDate <= :to', { to }) + .andWhere('tx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.DEBIT }) + .getRawOne(); + + const totalIncome = parseFloat(incomeResult?.total ?? '0'); + const totalExpenses = parseFloat(expensesResult?.total ?? '0'); + + // Calculate closing balance: opening + income - expenses + const calculatedClosingBalance = Util.round(openingBalance + totalIncome - totalExpenses, 2); + + // Check if defined closing balance exists and matches + const definedClosingBalance = yearlyBalances[year.toString()]; + const hasDefinedClosingBalance = definedClosingBalance !== undefined; + const balanceMatches = hasDefinedClosingBalance + ? Math.abs(calculatedClosingBalance - definedClosingBalance) < 0.01 + : true; + + return { + bankName: bank.name, + currency: bank.currency, + iban: bank.iban, + year, + openingBalance: Util.round(openingBalance, 2), + totalIncome: Util.round(totalIncome, 2), + totalExpenses: Util.round(totalExpenses, 2), + calculatedClosingBalance, + definedClosingBalance: hasDefinedClosingBalance ? Util.round(definedClosingBalance, 2) : undefined, + balanceMatches, + hasDefinedClosingBalance, + }; + } + + async getDetailedBalanceSheet(iban: string, year: number): Promise { + // Find the bank by IBAN + const bank = await this.bankRepo.findOne({ where: { iban } }); + if (!bank) { + throw new NotFoundException(`Bank with IBAN ${iban} not found`); + } + + // Parse yearly balances + const yearlyBalances: Record = bank.yearlyBalances ? JSON.parse(bank.yearlyBalances) : {}; + + // Opening balance = previous year's closing balance + const previousYear = (year - 1).toString(); + const openingBalance = yearlyBalances[previousYear] ?? 0; + + // Date range for the year + const from = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0)); + const to = new Date(Date.UTC(year, 11, 31, 23, 59, 59, 999)); + + // Get income breakdown by type (CRDT transactions) + const incomeByTypeResult = await this.bankTxRepo + .createQueryBuilder('tx') + .select("COALESCE(tx.type, 'Unknown')", 'type') + .addSelect('COALESCE(SUM(tx.amount), 0)', 'amount') + .addSelect('COUNT(*)', 'count') + .where('tx.accountIban = :iban', { iban }) + .andWhere('tx.valueDate >= :from', { from }) + .andWhere('tx.valueDate <= :to', { to }) + .andWhere('tx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.CREDIT }) + .groupBy('tx.type') + .orderBy('amount', 'DESC') + .getRawMany(); + + // Get expenses breakdown by type (DBIT transactions) + const expensesByTypeResult = await this.bankTxRepo + .createQueryBuilder('tx') + .select("COALESCE(tx.type, 'Unknown')", 'type') + .addSelect('COALESCE(SUM(tx.amount), 0)', 'amount') + .addSelect('COUNT(*)', 'count') + .where('tx.accountIban = :iban', { iban }) + .andWhere('tx.valueDate >= :from', { from }) + .andWhere('tx.valueDate <= :to', { to }) + .andWhere('tx.creditDebitIndicator = :indicator', { indicator: BankTxIndicator.DEBIT }) + .groupBy('tx.type') + .orderBy('amount', 'DESC') + .getRawMany(); + + // Map results to DTOs + const incomeByType: TypeBreakdownDto[] = incomeByTypeResult.map((r) => ({ + type: r.type || 'Unknown', + amount: Util.round(parseFloat(r.amount), 2), + count: parseInt(r.count, 10), + })); + + const expensesByType: TypeBreakdownDto[] = expensesByTypeResult.map((r) => ({ + type: r.type || 'Unknown', + amount: Util.round(parseFloat(r.amount), 2), + count: parseInt(r.count, 10), + })); + + // Calculate totals + const totalIncome = incomeByType.reduce((sum, t) => sum + t.amount, 0); + const totalExpenses = expensesByType.reduce((sum, t) => sum + t.amount, 0); + const calculatedClosingBalance = Util.round(openingBalance + totalIncome - totalExpenses, 2); + + // Check defined closing balance + const definedClosingBalance = yearlyBalances[year.toString()]; + const hasDefinedClosingBalance = definedClosingBalance !== undefined; + const balanceMatches = hasDefinedClosingBalance + ? Math.abs(calculatedClosingBalance - definedClosingBalance) < 0.01 + : true; + + return { + bankName: bank.name, + currency: bank.currency, + iban: bank.iban, + year, + openingBalance: Util.round(openingBalance, 2), + incomeByType, + expensesByType, + totalIncome: Util.round(totalIncome, 2), + totalExpenses: Util.round(totalExpenses, 2), + calculatedClosingBalance, + definedClosingBalance: hasDefinedClosingBalance ? Util.round(definedClosingBalance, 2) : undefined, + balanceMatches, + hasDefinedClosingBalance, + }; + } +} diff --git a/src/subdomains/core/core.module.ts b/src/subdomains/core/core.module.ts index dc25b9a779..009e06519c 100644 --- a/src/subdomains/core/core.module.ts +++ b/src/subdomains/core/core.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountingModule } from './accounting/accounting.module'; import { BuyCryptoModule } from './buy-crypto/buy-crypto.module'; import { CustodyModule } from './custody/custody.module'; import { FaucetRequestModule } from './faucet-request/faucet-request.module'; @@ -15,6 +16,7 @@ import { TransactionUtilModule } from './transaction/transaction-util.module'; @Module({ imports: [ + AccountingModule, BuyCryptoModule, HistoryModule, MonitoringModule, diff --git a/src/subdomains/supporting/bank/bank/bank.entity.ts b/src/subdomains/supporting/bank/bank/bank.entity.ts index 61e9fbbfe1..caa9dbcf22 100644 --- a/src/subdomains/supporting/bank/bank/bank.entity.ts +++ b/src/subdomains/supporting/bank/bank/bank.entity.ts @@ -31,6 +31,9 @@ export class Bank extends IEntity { @Column({ default: true }) amlEnabled: boolean; + @Column({ type: 'nvarchar', length: 'MAX', nullable: true }) + yearlyBalances?: string; // JSON: { "2024": { "opening": 1000.00, "closing": 2500.00 }, ... } + @OneToOne(() => Asset, (asset) => asset.bank, { nullable: true }) @JoinColumn() asset: Asset; diff --git a/src/subdomains/supporting/bank/bank/dto/bank.dto.ts b/src/subdomains/supporting/bank/bank/dto/bank.dto.ts index c0eff73e9d..7b07e2c961 100644 --- a/src/subdomains/supporting/bank/bank/dto/bank.dto.ts +++ b/src/subdomains/supporting/bank/bank/dto/bank.dto.ts @@ -1,4 +1,9 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export interface YearlyBalance { + opening: number; + closing: number; +} export class BankDto { @ApiProperty() @@ -12,6 +17,9 @@ export class BankDto { @ApiProperty() currency: string; + + @ApiPropertyOptional({ description: 'Yearly balances per year' }) + yearlyBalances?: Record; } export enum IbanBankName { diff --git a/src/subdomains/supporting/bank/bank/dto/bank.mapper.ts b/src/subdomains/supporting/bank/bank/dto/bank.mapper.ts index ae9076b28a..f73d9a9ebf 100644 --- a/src/subdomains/supporting/bank/bank/dto/bank.mapper.ts +++ b/src/subdomains/supporting/bank/bank/dto/bank.mapper.ts @@ -8,6 +8,7 @@ export class BankMapper { iban: bank.iban, bic: bank.bic, currency: bank.currency, + yearlyBalances: bank.yearlyBalances ? JSON.parse(bank.yearlyBalances) : undefined, }; return Object.assign(new BankDto(), dto); diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file