feat: added taxes

This commit is contained in:
DeathKaioken 2025-12-06 11:14:55 +01:00
parent c4da868a96
commit f862097417
11 changed files with 423 additions and 121 deletions

View File

@ -1,17 +1,46 @@
const service = require('../../services/tax/taxService'); const taxService = require('../../services/tax/taxService');
async function listVatRates(req, res) { function resolveActorUserId(req) {
// Admin-only check const raw = req.user?.id ?? req.user?.user_id ?? req.body?.userId ?? req.body?.user_id;
if (!req.user || req.user.role !== 'admin') { if (raw == null) return null;
return res.status(403).json({ success: false, message: 'Forbidden: Admins only.' }); const num = Number(raw);
} return Number.isNaN(num) ? null : num;
try {
const data = await service.getAllVatRates();
res.json({ success: true, data });
} catch (e) {
res.status(500).json({ success: false, message: 'Failed to fetch VAT rates', error: e.message });
}
} }
module.exports = { listVatRates }; module.exports = {
async getAllVatRates(req, res) {
try {
const data = await taxService.listCurrentRates();
return res.json({ success: true, data });
} catch (err) {
console.error('[GET VAT RATES]', err);
return res.status(500).json({ success: false, message: err.message });
}
},
async importVatRatesCsv(req, res) {
try {
if (!req.file || !req.file.buffer) {
return res.status(400).json({ success: false, message: 'CSV file required' });
}
const entries = taxService.parseCsv(req.file.buffer);
const actorUserId = resolveActorUserId(req);
const summary = await taxService.importRates(entries, actorUserId);
return res.json({ success: true, summary });
} catch (err) {
console.error('[IMPORT VAT RATES]', err);
return res.status(400).json({ success: false, message: err.message });
}
},
async getVatHistory(req, res) {
try {
const countryCode = req.params.countryCode.toUpperCase();
const result = await taxService.getHistory(countryCode);
return res.json({ success: true, data: result });
} catch (err) {
console.error('[GET VAT HISTORY]', err);
return res.status(400).json({ success: false, message: err.message });
}
}
};

View File

@ -73,6 +73,13 @@ class UnitOfWork {
getRepository(name) { getRepository(name) {
return this.repositories[name]; return this.repositories[name];
} }
getConnection() {
if (!this.inTransaction || !this.connection) {
throw new Error('UnitOfWork connection not available. Call start() before using getConnection().');
}
return this.connection;
}
} }
module.exports = UnitOfWork; module.exports = UnitOfWork;

View File

@ -522,6 +522,69 @@ async function createDatabase() {
`); `);
console.log('✅ Rate limit table created/verified'); console.log('✅ Rate limit table created/verified');
// --- Tax: countries and VAT rates ---
await connection.query(`
CREATE TABLE IF NOT EXISTS countries (
id INT AUTO_INCREMENT PRIMARY KEY,
country_code VARCHAR(3) NOT NULL,
country_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INT NULL,
updated_by INT NULL,
CONSTRAINT uq_country_code UNIQUE (country_code),
INDEX idx_country_name (country_name),
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
);
`);
console.log('✅ Countries table created/verified');
await connection.query(`
CREATE TABLE IF NOT EXISTS vat_rates (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
country_id INT NOT NULL,
standard_rate DECIMAL(6,3) NULL,
reduced_rate DECIMAL(6,3) NULL,
super_reduced_rate DECIMAL(6,3) NULL,
parking_rate DECIMAL(6,3) NULL,
effective_from DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
effective_to DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INT NULL,
updated_by INT NULL,
FOREIGN KEY (country_id) REFERENCES countries(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_vat_country_eff_to (country_id, effective_to),
INDEX idx_vat_standard (standard_rate)
);
`);
console.log('✅ VAT rates table created/verified');
await connection.query(`
CREATE TABLE IF NOT EXISTS vat_rate_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
country_id INT NOT NULL,
standard_rate DECIMAL(6,3) NULL,
reduced_rate DECIMAL(6,3) NULL,
super_reduced_rate DECIMAL(6,3) NULL,
parking_rate DECIMAL(6,3) NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
updated_by INT NULL,
FOREIGN KEY (country_id) REFERENCES countries(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_vat_hist_country_from (country_id, effective_from),
INDEX idx_vat_hist_country_to (country_id, effective_to)
);
`);
console.log('✅ VAT rate history table created/verified');
// --- NEW: company_stamps table (for company/admin managed stamps) --- // --- NEW: company_stamps table (for company/admin managed stamps) ---
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS company_stamps ( CREATE TABLE IF NOT EXISTS company_stamps (

13
models/Country.js Normal file
View File

@ -0,0 +1,13 @@
class Country {
constructor(row = {}) {
this.id = row.id;
this.country_code = row.country_code;
this.country_name = row.country_name;
this.created_at = row.created_at;
this.updated_at = row.updated_at;
this.created_by = row.created_by;
this.updated_by = row.updated_by;
}
}
module.exports = Country;

View File

@ -1,19 +1,19 @@
class Tax { const VAT_FIELDS = ['standard_rate', 'reduced_rate', 'super_reduced_rate', 'parking_rate'];
constructor(row) {
class VatRate {
constructor(row = {}) {
VAT_FIELDS.forEach((k) => { this[k] = row[k] != null ? Number(row[k]) : null; });
this.id = row.id; this.id = row.id;
this.country_code = row.country_code; this.country_id = row.country_id;
this.country_name = row.country_name; this.effective_from = row.effective_from;
this.is_eu = !!row.is_eu; this.effective_to = row.effective_to;
this.effective_year = row.effective_year; this.created_by = row.created_by;
this.standard_rate = row.standard_rate; this.updated_by = row.updated_by;
this.reduced_rate_1 = row.reduced_rate_1;
this.reduced_rate_2 = row.reduced_rate_2;
this.super_reduced_rate = row.super_reduced_rate;
this.parking_rate = row.parking_rate;
this.coffee_subscription_vat_rate = row.coffee_subscription_vat_rate;
this.created_at = row.created_at; this.created_at = row.created_at;
this.updated_at = row.updated_at; this.updated_at = row.updated_at;
} }
} }
module.exports = Tax; class VatRateHistory extends VatRate {}
module.exports = { VAT_FIELDS, VatRate, VatRateHistory };

87
package-lock.json generated
View File

@ -16,6 +16,7 @@
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"express": "^5.1.0", "express": "^5.1.0",
"get-stream": "^9.0.1", "get-stream": "^9.0.1",
@ -2358,23 +2359,27 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.0", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"debug": "^4.4.0", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.7.0",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.0", "qs": "^6.14.0",
"raw-body": "^3.0.0", "raw-body": "^3.0.1",
"type-is": "^2.0.0" "type-is": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/media-typer": { "node_modules/body-parser/node_modules/media-typer": {
@ -2895,6 +2900,12 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csv-parse": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@ -4058,15 +4069,19 @@
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/ieee754": { "node_modules/ieee754": {
@ -4356,9 +4371,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -4425,12 +4440,12 @@
} }
}, },
"node_modules/jws": { "node_modules/jws": {
"version": "3.2.2", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jwa": "^1.4.1", "jwa": "^1.4.2",
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
@ -4740,22 +4755,6 @@
"node": ">= 8.0" "node": ">= 8.0"
} }
}, },
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": { "node_modules/named-placeholders": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
@ -4813,9 +4812,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.9", "version": "7.0.11",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -5331,22 +5330,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",

View File

@ -25,6 +25,7 @@
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"express": "^5.1.0", "express": "^5.1.0",
"get-stream": "^9.0.1", "get-stream": "^9.0.1",

View File

@ -1,43 +1,130 @@
const mysql = require('mysql2/promise'); const Country = require('../../models/Country');
require('dotenv').config(); const { VatRate, VatRateHistory, VAT_FIELDS } = require('../../models/Tax');
const NODE_ENV = process.env.NODE_ENV || 'development'; class TaxRepository {
constructor(uow) {
function getDbConfig() { this.uow = uow;
if (NODE_ENV === 'development') { this.conn = uow.getConnection();
return {
host: process.env.DEV_DB_HOST || 'localhost',
port: Number(process.env.DEV_DB_PORT) || 3306,
user: process.env.DEV_DB_USER || 'root',
password: process.env.DEV_DB_PASSWORD || '',
database: process.env.DEV_DB_NAME || 'profitplanet_centralserver',
ssl: undefined
};
} }
return {
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
};
}
async function listAllVatRates() { async upsertCountries(countries, actorUserId) {
const conn = await mysql.createConnection(getDbConfig()); let created = 0;
try { let updated = 0;
const [rows] = await conn.query(` for (const c of countries) {
SELECT id, country_code, country_name, is_eu, effective_year, const [res] = await this.conn.query(
standard_rate, reduced_rate_1, reduced_rate_2, super_reduced_rate, `INSERT INTO countries (country_code, country_name, created_by, updated_by)
parking_rate, coffee_subscription_vat_rate, created_at, updated_at VALUES (?, ?, ?, ?)
FROM vat_rates ON DUPLICATE KEY UPDATE country_name=VALUES(country_name), updated_by=VALUES(updated_by), updated_at=CURRENT_TIMESTAMP`,
ORDER BY country_name ASC [c.code, c.name, actorUserId || null, actorUserId || null]
`); );
if (res.affectedRows === 1) created += 1;
if (res.affectedRows === 2) updated += 1;
}
return { created, updated };
}
async getCountriesByCodes(codes) {
if (!codes.length) return {};
const [rows] = await this.conn.query(
`SELECT * FROM countries WHERE country_code IN (${codes.map(() => '?').join(',')})`,
codes
);
const map = {};
rows.forEach((r) => { map[r.country_code] = new Country(r); });
return map;
}
async getCountryByCode(code) {
const [rows] = await this.conn.query(`SELECT * FROM countries WHERE country_code = ? LIMIT 1`, [code]);
return rows[0] ? new Country(rows[0]) : null;
}
async getAllCurrentVatRates() {
const [rows] = await this.conn.query(
`SELECT c.*, vr.id AS vat_id, vr.standard_rate, vr.reduced_rate, vr.super_reduced_rate, vr.parking_rate, vr.effective_from
FROM countries c
LEFT JOIN vat_rates vr ON vr.country_id = c.id AND vr.effective_to IS NULL
ORDER BY c.country_name ASC`
);
return rows; return rows;
} finally { }
await conn.end();
async getVatHistory(countryId) {
const [rows] = await this.conn.query(
`SELECT * FROM vat_rate_history WHERE country_id = ? ORDER BY effective_from DESC`,
[countryId]
);
return rows.map((r) => new VatRateHistory(r));
}
async setCurrentVatRate(countryId, rates, actorUserId) {
const [currentRows] = await this.conn.query(
`SELECT * FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
[countryId]
);
const now = new Date();
const current = currentRows[0] ? new VatRate(currentRows[0]) : null;
const areEqual =
current &&
VAT_FIELDS.every((k) => {
const a = current[k] == null ? null : Number(current[k]);
const b = rates[k] == null ? null : Number(rates[k]);
return (a === null && b === null) || a === b;
});
if (areEqual) return { changed: false, skipped: true };
if (current) {
await this.conn.query(
`INSERT INTO vat_rate_history (country_id, standard_rate, reduced_rate, super_reduced_rate, parking_rate, effective_from, effective_to, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
countryId,
current.standard_rate,
current.reduced_rate,
current.super_reduced_rate,
current.parking_rate,
current.effective_from,
now,
actorUserId || current.created_by || null,
actorUserId || current.updated_by || null
]
);
await this.conn.query(
`UPDATE vat_rates SET effective_to = ?, updated_by = ? WHERE id = ?`,
[now, actorUserId || null, current.id]
);
}
await this.conn.query(
`INSERT INTO vat_rates (country_id, standard_rate, reduced_rate, super_reduced_rate, parking_rate, effective_from, effective_to, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
[
countryId,
rates.standard_rate,
rates.reduced_rate,
rates.super_reduced_rate,
rates.parking_rate,
now,
actorUserId || null,
actorUserId || null
]
);
return { changed: true, skipped: false };
}
async bulkImportVatRates(entries, actorUserId) {
let ratesUpdated = 0;
let ratesSkipped = 0;
for (const entry of entries) {
const res = await this.setCurrentVatRate(entry.country_id, entry.rates, actorUserId);
if (res.changed) ratesUpdated += 1;
if (res.skipped) ratesSkipped += 1;
}
return { ratesUpdated, ratesSkipped };
} }
} }
// export module.exports = TaxRepository;
module.exports = { listAllVatRates };

View File

@ -17,6 +17,7 @@ const CompanyStampController = require('../controller/companyStamp/CompanyStampC
const MatrixController = require('../controller/matrix/MatrixController'); // <-- added const MatrixController = require('../controller/matrix/MatrixController'); // <-- added
const CoffeeController = require('../controller/admin/CoffeeController'); const CoffeeController = require('../controller/admin/CoffeeController');
const PoolController = require('../controller/pool/PoolController'); const PoolController = require('../controller/pool/PoolController');
const TaxController = require('../controller/tax/taxController');
// small helpers copied from original files // small helpers copied from original files
function adminOnly(req, res, next) { function adminOnly(req, res, next) {
@ -132,5 +133,9 @@ router.get('/matrix/:id/overview', authMiddleware, MatrixController.getMyOvervie
// NEW: User matrix summary (totals and fill) // NEW: User matrix summary (totals and fill)
router.get('/matrix/:id/summary', authMiddleware, MatrixController.getMyMatrixSummary); router.get('/matrix/:id/summary', authMiddleware, MatrixController.getMyMatrixSummary);
// Tax GETs
router.get('/tax/vat-rates', authMiddleware, adminOnly, TaxController.getAllVatRates);
router.get('/tax/vat-history/:countryCode', authMiddleware, adminOnly, TaxController.getVatHistory);
// export // export
module.exports = router; module.exports = router;

View File

@ -23,6 +23,7 @@ const AdminUserController = require('../controller/admin/AdminUserController');
const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added
const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations
const PoolController = require('../controller/pool/PoolController'); const PoolController = require('../controller/pool/PoolController');
const TaxController = require('../controller/tax/taxController');
const multer = require('multer'); const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
@ -124,6 +125,8 @@ router.post('/admin/coffee', authMiddleware, adminOnly, upload.single('picture')
router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixController.addUser); // already added
// NEW: Admin create pool // NEW: Admin create pool
router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create); router.post('/admin/pools', authMiddleware, adminOnly, PoolController.create);
// NEW: import VAT rates CSV
router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('file'), TaxController.importVatRatesCsv);
// Existing registration handlers (keep) // Existing registration handlers (keep)
router.post('/register/personal', (req, res) => { router.post('/register/personal', (req, res) => {

View File

@ -1,9 +1,120 @@
const Tax = require('../../models/Tax'); const { parse } = require('csv-parse/sync');
const repo = require('../../repositories/tax/taxRepository'); const UnitOfWork = require('../../database/UnitOfWork');
const TaxRepository = require('../../repositories/tax/taxRepository');
async function getAllVatRates() { const HEADERS = [
const rows = await repo.listAllVatRates(); 'Country',
return rows.map(r => new Tax(r)); 'Super-Reduced Rate (%)',
'Reduced Rate (%)',
'Parking Rate (%)',
'Standard Rate (%)'
];
function parseRate(raw) {
if (!raw) return null;
const cleaned = String(raw).replace(/"/g, '').replace(/%/g, '').trim();
if (!cleaned || cleaned === '-') return null;
const parts = cleaned.split('/').map((p) => p.trim().replace(',', '.'));
const nums = parts.map((p) => Number(p)).filter((n) => !Number.isNaN(n));
if (!nums.length) return null;
return Math.max(...nums);
} }
module.exports = { getAllVatRates }; function extractCountry(value) {
const str = String(value || '').trim();
const match = str.match(/^(.*)\((\w{2,3})\)\s*$/);
if (match) return { name: match[1].trim(), code: match[2].trim().toUpperCase() };
return { name: str, code: str.slice(0, 2).toUpperCase() };
}
function normalizeRows(rows) {
return rows.map((row) => {
const country = extractCountry(row['Country']);
return {
country_name: country.name,
country_code: country.code,
rates: {
standard_rate: parseRate(row['Standard Rate (%)']),
reduced_rate: parseRate(row['Reduced Rate (%)']),
super_reduced_rate: parseRate(row['Super-Reduced Rate (%)']),
parking_rate: parseRate(row['Parking Rate (%)'])
}
};
});
}
async function importRates(entries, actorUserId) {
const uow = new UnitOfWork();
await uow.start();
const repo = new TaxRepository(uow);
try {
const uniqueCountries = [];
const seen = new Set();
for (const e of entries) {
if (!seen.has(e.country_code)) {
uniqueCountries.push({ code: e.country_code, name: e.country_name });
seen.add(e.country_code);
}
}
const countryStats = await repo.upsertCountries(uniqueCountries, actorUserId);
const codeMap = await repo.getCountriesByCodes(Array.from(seen));
const payloads = entries
.map((e) => ({ country_id: codeMap[e.country_code]?.id, rates: e.rates }))
.filter((p) => p.country_id);
const rateStats = await repo.bulkImportVatRates(payloads, actorUserId);
await uow.commit();
return {
totalRows: entries.length,
countriesCreated: countryStats.created,
countriesUpdated: countryStats.updated,
ratesUpdated: rateStats.ratesUpdated,
ratesSkipped: rateStats.ratesSkipped
};
} catch (err) {
await uow.rollback();
throw err;
}
}
async function listCurrentRates() {
const uow = new UnitOfWork();
await uow.start();
const repo = new TaxRepository(uow);
try {
const data = await repo.getAllCurrentVatRates();
await uow.commit();
return data;
} catch (err) {
await uow.rollback();
throw err;
}
}
async function getHistory(countryCode) {
const uow = new UnitOfWork();
await uow.start();
const repo = new TaxRepository(uow);
try {
const country = await repo.getCountryByCode(countryCode);
if (!country) throw new Error('Country not found');
const history = await repo.getVatHistory(country.id);
await uow.commit();
return { country, history };
} catch (err) {
await uow.rollback();
throw err;
}
}
module.exports = {
parseCsv(buffer) {
const records = parse(buffer, { columns: true, skip_empty_lines: true, trim: true });
const headers = Object.keys(records[0] || {});
const missing = HEADERS.filter((h) => !headers.includes(h));
if (missing.length) throw new Error(`Missing headers: ${missing.join(', ')}`);
return normalizeRows(records);
},
importRates,
listCurrentRates,
getHistory
};