From f862097417055ba1b70b723c37d1bc3180dbd4b1 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sat, 6 Dec 2025 11:14:55 +0100 Subject: [PATCH] feat: added taxes --- controller/tax/taxController.js | 57 ++++++++--- database/UnitOfWork.js | 7 ++ database/createDb.js | 63 ++++++++++++ models/Country.js | 13 +++ models/Tax.js | 26 ++--- package-lock.json | 87 +++++++--------- package.json | 1 + repositories/tax/taxRepository.js | 159 +++++++++++++++++++++++------- routes/getRoutes.js | 5 + routes/postRoutes.js | 3 + services/tax/taxService.js | 123 +++++++++++++++++++++-- 11 files changed, 423 insertions(+), 121 deletions(-) create mode 100644 models/Country.js diff --git a/controller/tax/taxController.js b/controller/tax/taxController.js index 79d1fc9..ba0d015 100644 --- a/controller/tax/taxController.js +++ b/controller/tax/taxController.js @@ -1,17 +1,46 @@ -const service = require('../../services/tax/taxService'); +const taxService = require('../../services/tax/taxService'); -async function listVatRates(req, res) { - // Admin-only check - if (!req.user || req.user.role !== 'admin') { - return res.status(403).json({ success: false, message: 'Forbidden: Admins only.' }); - } - - 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 }); - } +function resolveActorUserId(req) { + const raw = req.user?.id ?? req.user?.user_id ?? req.body?.userId ?? req.body?.user_id; + if (raw == null) return null; + const num = Number(raw); + return Number.isNaN(num) ? null : num; } -module.exports = { listVatRates }; \ No newline at end of file +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 }); + } + } +}; diff --git a/database/UnitOfWork.js b/database/UnitOfWork.js index 6e41724..762bc2b 100644 --- a/database/UnitOfWork.js +++ b/database/UnitOfWork.js @@ -73,6 +73,13 @@ class UnitOfWork { getRepository(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; diff --git a/database/createDb.js b/database/createDb.js index 0fc0b5b..8df6584 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -522,6 +522,69 @@ async function createDatabase() { `); 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) --- await connection.query(` CREATE TABLE IF NOT EXISTS company_stamps ( diff --git a/models/Country.js b/models/Country.js new file mode 100644 index 0000000..31ac8aa --- /dev/null +++ b/models/Country.js @@ -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; diff --git a/models/Tax.js b/models/Tax.js index c2f2306..4417f4b 100644 --- a/models/Tax.js +++ b/models/Tax.js @@ -1,19 +1,19 @@ -class Tax { - constructor(row) { +const VAT_FIELDS = ['standard_rate', 'reduced_rate', 'super_reduced_rate', 'parking_rate']; + +class VatRate { + constructor(row = {}) { + VAT_FIELDS.forEach((k) => { this[k] = row[k] != null ? Number(row[k]) : null; }); this.id = row.id; - this.country_code = row.country_code; - this.country_name = row.country_name; - this.is_eu = !!row.is_eu; - this.effective_year = row.effective_year; - this.standard_rate = row.standard_rate; - 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.country_id = row.country_id; + this.effective_from = row.effective_from; + this.effective_to = row.effective_to; + this.created_by = row.created_by; + this.updated_by = row.updated_by; this.created_at = row.created_at; this.updated_at = row.updated_at; } } -module.exports = Tax; \ No newline at end of file +class VatRateHistory extends VatRate {} + +module.exports = { VAT_FIELDS, VatRate, VatRateHistory }; diff --git a/package-lock.json b/package-lock.json index 639d2ef..b9efab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "aws-sdk": "^2.1692.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^17.2.2", "express": "^5.1.0", "get-stream": "^9.0.1", @@ -2358,23 +2359,27 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/media-typer": { @@ -2895,6 +2900,12 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "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": { "version": "6.0.2", "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": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "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/ieee754": { @@ -4356,9 +4371,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4425,12 +4440,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -4740,22 +4755,6 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", @@ -4813,9 +4812,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -5331,22 +5330,6 @@ "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": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index 82d9f47..a7c40ee 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "aws-sdk": "^2.1692.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csv-parse": "^6.1.0", "dotenv": "^17.2.2", "express": "^5.1.0", "get-stream": "^9.0.1", diff --git a/repositories/tax/taxRepository.js b/repositories/tax/taxRepository.js index 6ed9af0..1330571 100644 --- a/repositories/tax/taxRepository.js +++ b/repositories/tax/taxRepository.js @@ -1,43 +1,130 @@ -const mysql = require('mysql2/promise'); -require('dotenv').config(); +const Country = require('../../models/Country'); +const { VatRate, VatRateHistory, VAT_FIELDS } = require('../../models/Tax'); -const NODE_ENV = process.env.NODE_ENV || 'development'; - -function getDbConfig() { - if (NODE_ENV === 'development') { - 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 - }; +class TaxRepository { + constructor(uow) { + this.uow = uow; + this.conn = uow.getConnection(); } - 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() { - const conn = await mysql.createConnection(getDbConfig()); - try { - const [rows] = await conn.query(` - SELECT id, country_code, country_name, is_eu, effective_year, - standard_rate, reduced_rate_1, reduced_rate_2, super_reduced_rate, - parking_rate, coffee_subscription_vat_rate, created_at, updated_at - FROM vat_rates - ORDER BY country_name ASC - `); + async upsertCountries(countries, actorUserId) { + let created = 0; + let updated = 0; + for (const c of countries) { + const [res] = await this.conn.query( + `INSERT INTO countries (country_code, country_name, created_by, updated_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE country_name=VALUES(country_name), updated_by=VALUES(updated_by), updated_at=CURRENT_TIMESTAMP`, + [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; - } 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 = { listAllVatRates }; \ No newline at end of file +module.exports = TaxRepository; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index cb7a631..4fa57a3 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -17,6 +17,7 @@ const CompanyStampController = require('../controller/companyStamp/CompanyStampC const MatrixController = require('../controller/matrix/MatrixController'); // <-- added const CoffeeController = require('../controller/admin/CoffeeController'); const PoolController = require('../controller/pool/PoolController'); +const TaxController = require('../controller/tax/taxController'); // small helpers copied from original files function adminOnly(req, res, next) { @@ -132,5 +133,9 @@ router.get('/matrix/:id/overview', authMiddleware, MatrixController.getMyOvervie // NEW: User matrix summary (totals and fill) 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 module.exports = router; \ No newline at end of file diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 60ab59b..d680c61 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -23,6 +23,7 @@ const AdminUserController = require('../controller/admin/AdminUserController'); const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added const MatrixController = require('../controller/matrix/MatrixController'); // Matrix admin operations const PoolController = require('../controller/pool/PoolController'); +const TaxController = require('../controller/tax/taxController'); const multer = require('multer'); 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 // NEW: Admin create pool 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) router.post('/register/personal', (req, res) => { diff --git a/services/tax/taxService.js b/services/tax/taxService.js index 96960b3..6f9906e 100644 --- a/services/tax/taxService.js +++ b/services/tax/taxService.js @@ -1,9 +1,120 @@ -const Tax = require('../../models/Tax'); -const repo = require('../../repositories/tax/taxRepository'); +const { parse } = require('csv-parse/sync'); +const UnitOfWork = require('../../database/UnitOfWork'); +const TaxRepository = require('../../repositories/tax/taxRepository'); -async function getAllVatRates() { - const rows = await repo.listAllVatRates(); - return rows.map(r => new Tax(r)); +const HEADERS = [ + 'Country', + '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 }; \ No newline at end of file +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 +};