Skip to content

Commit b4e1987

Browse files
committed
Add accounting module with bank balance sheet endpoint
- Add AccountingModule with balance sheet calculation - Add /accounting/balance-sheet/:iban/:year endpoint - Add yearlyBalances field to Bank entity for opening/closing balances - Calculate income (CRDT) and expenses (DBIT) from bank_tx - Validate calculated closing against defined closing balance - Add sync scripts for local development
1 parent 75fe55b commit b4e1987

12 files changed

Lines changed: 718 additions & 1 deletion

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
3+
* @typedef {import('typeorm').QueryRunner} QueryRunner
4+
*/
5+
6+
/**
7+
* Add yearlyBalances column to bank table and populate with historical balances.
8+
*
9+
* Format: { "2024": { "opening": 1000.00, "closing": 2500.00 }, ... }
10+
* - opening: Balance per 01.01.YYYY 00:00:00.000
11+
* - closing: Balance per 31.12.YYYY 23:59:59.999
12+
*
13+
* @class
14+
* @implements {MigrationInterface}
15+
*/
16+
module.exports = class AddBankYearlyBalances1768841352156 {
17+
name = 'AddBankYearlyBalances1768841352156'
18+
19+
/**
20+
* @param {QueryRunner} queryRunner
21+
*/
22+
async up(queryRunner) {
23+
// Add yearlyBalances column
24+
await queryRunner.query(`
25+
ALTER TABLE "dbo"."bank" ADD "yearlyBalances" nvarchar(MAX) NULL
26+
`);
27+
28+
// Update each bank with their yearly balances
29+
const bankBalances = [
30+
// Maerki Baumann EUR (ID: 5)
31+
{
32+
id: 5,
33+
balances: {
34+
"2025": { opening: 3617.58, closing: 0 },
35+
}
36+
},
37+
// Maerki Baumann CHF (ID: 6)
38+
{
39+
id: 6,
40+
balances: {
41+
"2025": { opening: 2437.57, closing: 0 },
42+
}
43+
},
44+
// Raiffeisen CHF (ID: 13)
45+
{
46+
id: 13,
47+
balances: {
48+
"2025": { opening: 0, closing: 1161.67 },
49+
}
50+
},
51+
// Yapeal CHF (ID: 15)
52+
{
53+
id: 15,
54+
balances: {
55+
"2025": { opening: 0, closing: 1557.73 },
56+
}
57+
},
58+
// Yapeal EUR (ID: 16)
59+
{
60+
id: 16,
61+
balances: {
62+
"2025": { opening: 0, closing: 2568.79 },
63+
}
64+
},
65+
];
66+
67+
for (const bank of bankBalances) {
68+
const jsonValue = JSON.stringify(bank.balances).replace(/'/g, "''");
69+
await queryRunner.query(`
70+
UPDATE "dbo"."bank"
71+
SET "yearlyBalances" = '${jsonValue}'
72+
WHERE "id" = ${bank.id}
73+
`);
74+
}
75+
}
76+
77+
/**
78+
* @param {QueryRunner} queryRunner
79+
*/
80+
async down(queryRunner) {
81+
await queryRunner.query(`
82+
ALTER TABLE "dbo"."bank" DROP COLUMN "yearlyBalances"
83+
`);
84+
}
85+
}

scripts/migrate-yearly-balances.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Migrate yearlyBalances from old format to new format
5+
*
6+
* Old format: { "2025": { "opening": 2437.57, "closing": 0 } }
7+
* New format: { "2024": 2437.57, "2025": 0 }
8+
*
9+
* Opening balance is now calculated as previous year's closing balance
10+
*/
11+
12+
const mssql = require('mssql');
13+
const fs = require('fs');
14+
const path = require('path');
15+
16+
const mainEnvFile = path.join(__dirname, '..', '.env');
17+
const mainEnv = {};
18+
fs.readFileSync(mainEnvFile, 'utf-8').split('\n').forEach(line => {
19+
const match = line.match(/^([^#=]+)=(.*)$/);
20+
if (match) mainEnv[match[1].trim()] = match[2].trim();
21+
});
22+
23+
const config = {
24+
server: mainEnv.SQL_HOST || 'localhost',
25+
port: parseInt(mainEnv.SQL_PORT || '1433'),
26+
user: mainEnv.SQL_USERNAME || 'sa',
27+
password: mainEnv.SQL_PASSWORD,
28+
database: mainEnv.SQL_DB || 'dfx',
29+
options: { encrypt: false, trustServerCertificate: true },
30+
};
31+
32+
async function migrateBalances() {
33+
const pool = await mssql.connect(config);
34+
35+
const result = await pool.request().query('SELECT id, name, iban, yearlyBalances FROM bank WHERE yearlyBalances IS NOT NULL');
36+
37+
console.log('Migrating yearlyBalances to new format...');
38+
console.log('Old format: {"2025": {"opening": X, "closing": Y}}');
39+
console.log('New format: {"2024": X, "2025": Y} (opening = previous year closing)\n');
40+
41+
for (const bank of result.recordset) {
42+
const oldBalances = JSON.parse(bank.yearlyBalances);
43+
const newBalances = {};
44+
45+
for (const [year, data] of Object.entries(oldBalances)) {
46+
if (typeof data === 'object' && data !== null) {
47+
// Old format: { opening: X, closing: Y }
48+
const { opening, closing } = data;
49+
50+
// Store closing for this year
51+
if (closing !== undefined) {
52+
newBalances[year] = closing;
53+
}
54+
55+
// Store opening as previous year's closing
56+
if (opening && opening !== 0) {
57+
const prevYear = (parseInt(year) - 1).toString();
58+
newBalances[prevYear] = opening;
59+
}
60+
} else {
61+
// Already in new format (just a number)
62+
newBalances[year] = data;
63+
}
64+
}
65+
66+
const newJson = JSON.stringify(newBalances);
67+
68+
console.log(bank.name + ' (' + bank.iban + '):');
69+
console.log(' Old: ' + bank.yearlyBalances);
70+
console.log(' New: ' + newJson);
71+
72+
await pool.request()
73+
.input('id', mssql.Int, bank.id)
74+
.input('balances', mssql.NVarChar, newJson)
75+
.query('UPDATE bank SET yearlyBalances = @balances WHERE id = @id');
76+
}
77+
78+
console.log('\nMigration complete!');
79+
80+
const verify = await pool.request().query('SELECT name, iban, yearlyBalances FROM bank WHERE yearlyBalances IS NOT NULL');
81+
console.log('\nVerification:');
82+
verify.recordset.forEach(b => console.log(' ' + b.name + ': ' + b.yearlyBalances));
83+
84+
await pool.close();
85+
}
86+
87+
migrateBalances().catch(e => {
88+
console.error('Error:', e.message);
89+
process.exit(1);
90+
});

scripts/sync-bank-tx-iban.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Sync bank_tx.accountIban from Production to Local DB
5+
*
6+
* This script:
7+
* 1. Fetches accountIban values from production DB via debug endpoint (SELECT only)
8+
* 2. Updates the local DB directly via mssql connection
9+
*
10+
* Usage:
11+
* node scripts/sync-bank-tx-iban.js
12+
*
13+
* Requirements:
14+
* - .env.db-debug with DEBUG_ADDRESS and DEBUG_SIGNATURE
15+
* - Local SQL Server running with credentials from .env
16+
* - Production API accessible
17+
*/
18+
19+
const fs = require('fs');
20+
const path = require('path');
21+
const mssql = require('mssql');
22+
23+
// Load db-debug environment
24+
const dbDebugEnvFile = path.join(__dirname, '.env.db-debug');
25+
if (!fs.existsSync(dbDebugEnvFile)) {
26+
console.error('Error: .env.db-debug not found');
27+
process.exit(1);
28+
}
29+
30+
const dbDebugEnv = {};
31+
fs.readFileSync(dbDebugEnvFile, 'utf-8').split('\n').forEach(line => {
32+
const [key, ...valueParts] = line.split('=');
33+
if (key && valueParts.length) {
34+
dbDebugEnv[key.trim()] = valueParts.join('=').trim();
35+
}
36+
});
37+
38+
// Load main .env for local DB credentials
39+
const mainEnvFile = path.join(__dirname, '..', '.env');
40+
if (!fs.existsSync(mainEnvFile)) {
41+
console.error('Error: .env not found');
42+
process.exit(1);
43+
}
44+
45+
const mainEnv = {};
46+
fs.readFileSync(mainEnvFile, 'utf-8').split('\n').forEach(line => {
47+
const match = line.match(/^([^#=]+)=(.*)$/);
48+
if (match) {
49+
mainEnv[match[1].trim()] = match[2].trim();
50+
}
51+
});
52+
53+
const PROD_API_URL = dbDebugEnv.DEBUG_API_URL || 'https://api.dfx.swiss/v1';
54+
const DEBUG_ADDRESS = dbDebugEnv.DEBUG_ADDRESS;
55+
const DEBUG_SIGNATURE = dbDebugEnv.DEBUG_SIGNATURE;
56+
57+
// Local DB config
58+
const localDbConfig = {
59+
server: mainEnv.SQL_HOST || 'localhost',
60+
port: parseInt(mainEnv.SQL_PORT || '1433'),
61+
user: mainEnv.SQL_USERNAME || 'sa',
62+
password: mainEnv.SQL_PASSWORD,
63+
database: mainEnv.SQL_DB || 'dfx',
64+
options: {
65+
encrypt: false,
66+
trustServerCertificate: true,
67+
},
68+
};
69+
70+
async function getToken(apiUrl, address, signature) {
71+
const response = await fetch(`${apiUrl}/auth/signIn`, {
72+
method: 'POST',
73+
headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify({ address, signature }),
75+
});
76+
const data = await response.json();
77+
if (!data.accessToken) {
78+
throw new Error(`Auth failed for ${apiUrl}: ${JSON.stringify(data)}`);
79+
}
80+
return data.accessToken;
81+
}
82+
83+
async function executeQuery(apiUrl, token, sql) {
84+
const response = await fetch(`${apiUrl}/gs/debug`, {
85+
method: 'POST',
86+
headers: {
87+
'Content-Type': 'application/json',
88+
'Authorization': `Bearer ${token}`,
89+
},
90+
body: JSON.stringify({ sql }),
91+
});
92+
const data = await response.json();
93+
if (data.statusCode && data.statusCode >= 400) {
94+
throw new Error(`Query failed: ${data.message}`);
95+
}
96+
return data;
97+
}
98+
99+
async function main() {
100+
console.log('=== Sync bank_tx.accountIban from Production to Local ===\n');
101+
102+
// Connect to local DB
103+
console.log('Connecting to local database...');
104+
console.log(` Server: ${localDbConfig.server}:${localDbConfig.port}`);
105+
console.log(` Database: ${localDbConfig.database}`);
106+
107+
let pool;
108+
try {
109+
pool = await mssql.connect(localDbConfig);
110+
console.log('✓ Local database connected\n');
111+
} catch (e) {
112+
console.error('Failed to connect to local database:', e.message);
113+
process.exit(1);
114+
}
115+
116+
// Get production token
117+
console.log('Authenticating to Production...');
118+
const prodToken = await getToken(PROD_API_URL, DEBUG_ADDRESS, DEBUG_SIGNATURE);
119+
console.log('✓ Production authenticated\n');
120+
121+
// Get distinct accountIbans from production with their IDs
122+
console.log('Fetching accountIban data from Production...');
123+
124+
const BATCH_SIZE = 1000;
125+
let lastId = 0;
126+
let totalUpdated = 0;
127+
let hasMore = true;
128+
129+
while (hasMore) {
130+
console.log(`\nFetching batch after id ${lastId}...`);
131+
132+
// Get batch of IDs with their accountIban from production (using TOP + WHERE id > lastId)
133+
const prodData = await executeQuery(
134+
PROD_API_URL,
135+
prodToken,
136+
`SELECT TOP ${BATCH_SIZE} id, accountIban FROM bank_tx WHERE accountIban IS NOT NULL AND id > ${lastId} ORDER BY id`
137+
);
138+
139+
if (!Array.isArray(prodData) || prodData.length === 0) {
140+
console.log('No more data to fetch.');
141+
hasMore = false;
142+
break;
143+
}
144+
145+
console.log(`Fetched ${prodData.length} records from production.`);
146+
147+
// Group by accountIban to minimize updates
148+
const byIban = {};
149+
for (const row of prodData) {
150+
if (!row.accountIban) continue;
151+
if (!byIban[row.accountIban]) {
152+
byIban[row.accountIban] = [];
153+
}
154+
byIban[row.accountIban].push(row.id);
155+
if (row.id > lastId) lastId = row.id;
156+
}
157+
158+
// Update local DB directly
159+
for (const [iban, ids] of Object.entries(byIban)) {
160+
const idList = ids.join(',');
161+
162+
try {
163+
const request = pool.request();
164+
request.input('iban', mssql.NVarChar, iban);
165+
await request.query(`UPDATE bank_tx SET accountIban = @iban WHERE id IN (${idList})`);
166+
totalUpdated += ids.length;
167+
process.stdout.write(`\r Updated ${totalUpdated} records...`);
168+
} catch (e) {
169+
console.error(`\nFailed to update IDs for IBAN ${iban}: ${e.message}`);
170+
}
171+
}
172+
173+
if (prodData.length < BATCH_SIZE) {
174+
hasMore = false;
175+
}
176+
}
177+
178+
// Close connection
179+
await pool.close();
180+
181+
console.log(`\n\n=== Sync Complete ===`);
182+
console.log(`Total records updated: ${totalUpdated}`);
183+
184+
// Verify the result
185+
console.log('\n=== Verification ===');
186+
console.log('Run this to check Maerki Baumann CHF 2025:');
187+
console.log(' curl -s "http://localhost:3000/v1/accounting/balance-sheet/CH3408573177975200001/2025" -H "Authorization: Bearer $TOKEN" | jq .');
188+
}
189+
190+
main().catch(e => {
191+
console.error('Error:', e.message);
192+
process.exit(1);
193+
});

0 commit comments

Comments
 (0)