feat: added taxes
This commit is contained in:
parent
c4da868a96
commit
f862097417
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async getAllVatRates(req, res) {
|
||||||
try {
|
try {
|
||||||
const data = await service.getAllVatRates();
|
const data = await taxService.listCurrentRates();
|
||||||
res.json({ success: true, data });
|
return res.json({ success: true, data });
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: 'Failed to fetch VAT rates', error: e.message });
|
console.error('[GET VAT RATES]', err);
|
||||||
}
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
module.exports = { listVatRates };
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
13
models/Country.js
Normal 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;
|
||||||
@ -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
87
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 };
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user