v2
This commit is contained in:
+616
@@ -1,7 +1,618 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const { pool, initializeDatabase } = require('./db');
|
||||
const { normalizeInvoiceData } = require('./invoice-data');
|
||||
|
||||
const app = express();
|
||||
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (error, derivedKey) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(`${salt}:${derivedKey.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
|
||||
const verifyPassword = async (password, storedHash) => {
|
||||
const [salt, expectedHex] = storedHash.split(':');
|
||||
const actualHash = await hashPassword(password, salt);
|
||||
const actualBuffer = Buffer.from(actualHash.split(':')[1], 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedHex, 'hex');
|
||||
return actualBuffer.length === expectedBuffer.length
|
||||
&& crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
};
|
||||
|
||||
const getSessionToken = (req) => {
|
||||
const cookie = req.headers.cookie || '';
|
||||
const sessionCookie = cookie.split(';').map((part) => part.trim())
|
||||
.find((part) => part.startsWith('fakturovac_session='));
|
||||
return sessionCookie ? decodeURIComponent(sessionCookie.split('=').slice(1).join('=')) : '';
|
||||
};
|
||||
|
||||
const setSessionCookie = (res, token) => {
|
||||
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
|
||||
res.setHeader('Set-Cookie', `fakturovac_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${sessionDurationMs / 1000}${secure}`);
|
||||
};
|
||||
|
||||
const clearSessionCookie = (res) => {
|
||||
const secure = process.env.SESSION_COOKIE_SECURE === 'true' ? '; Secure' : '';
|
||||
res.setHeader('Set-Cookie', `fakturovac_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
|
||||
};
|
||||
|
||||
const createSession = async (userId) => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await pool.query(
|
||||
'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, NOW() + INTERVAL \'30 days\')',
|
||||
[token, userId]
|
||||
);
|
||||
return token;
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
const token = getSessionToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Nejste přihlášeni.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT user_id FROM sessions WHERE id = $1 AND expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
if (!result.rowCount) {
|
||||
clearSessionCookie(res);
|
||||
res.status(401).json({ error: 'Relace vypršela.' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.userId = result.rows[0].user_id;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateCredentials = (email, password) => typeof email === 'string'
|
||||
&& email.includes('@')
|
||||
&& typeof password === 'string'
|
||||
&& password.length >= 8;
|
||||
|
||||
const profileFields = ['nazevSpolecnosti', 'ico', 'jmeno', 'prijmeni', 'uliceCp', 'psc', 'mesto'];
|
||||
const profileValues = (body) => profileFields.map((field) => typeof body[field] === 'string' ? body[field].trim() : '');
|
||||
const profileResponse = (row) => ({
|
||||
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
|
||||
nazevSpolecnosti: row.nazev_spolecnosti,
|
||||
ico: row.ico,
|
||||
jmeno: row.jmeno,
|
||||
prijmeni: row.prijmeni,
|
||||
uliceCp: row.ulice_cp,
|
||||
psc: row.psc,
|
||||
mesto: row.mesto
|
||||
});
|
||||
|
||||
const clientResponse = (row) => ({
|
||||
id: row.id,
|
||||
typSubjektu: row.typ_subjektu === 'spolecnost' ? 'spolecnost' : 'osoba',
|
||||
data: profileResponse(row)
|
||||
});
|
||||
|
||||
|
||||
app.post('/api/auth/register', async (req, res, next) => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
const password = req.body.password;
|
||||
if (!validateCredentials(email, password)) {
|
||||
res.status(400).json({ error: 'Zadejte platný e-mail a heslo dlouhé alespoň 8 znaků.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const result = await pool.query(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
|
||||
[email, passwordHash]
|
||||
);
|
||||
const token = await createSession(result.rows[0].id);
|
||||
setSessionCookie(res, token);
|
||||
res.status(201).json({ user: result.rows[0] });
|
||||
} catch (error) {
|
||||
if (error.code === '23505') {
|
||||
res.status(409).json({ error: 'Účet s tímto e-mailem už existuje.' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res, next) => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
const email = String(req.body.email || '').trim().toLowerCase();
|
||||
const result = await pool.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
|
||||
if (!result.rowCount || !(await verifyPassword(req.body.password || '', result.rows[0].password_hash))) {
|
||||
res.status(401).json({ error: 'E-mail nebo heslo není správně.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await createSession(result.rows[0].id);
|
||||
setSessionCookie(res, token);
|
||||
res.json({ user: { id: result.rows[0].id, email: result.rows[0].email } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, res, next) => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
const token = getSessionToken(req);
|
||||
if (token) {
|
||||
await pool.query('DELETE FROM sessions WHERE id = $1', [token]);
|
||||
}
|
||||
clearSessionCookie(res);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, email FROM users WHERE id = $1', [req.userId]);
|
||||
res.json({ user: result.rows[0] });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/password', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const currentPassword = req.body.currentPassword || '';
|
||||
const newPassword = req.body.newPassword || '';
|
||||
const result = await pool.query('SELECT password_hash FROM users WHERE id = $1', [req.userId]);
|
||||
if (!result.rowCount || !(await verifyPassword(currentPassword, result.rows[0].password_hash))) {
|
||||
res.status(400).json({ error: 'Současné heslo není správně.' });
|
||||
return;
|
||||
}
|
||||
if (!validateCredentials('account@example.com', newPassword)) {
|
||||
res.status(400).json({ error: 'Nové heslo musí mít alespoň 8 znaků.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(newPassword), req.userId]);
|
||||
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [req.userId, getSessionToken(req)]);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/supplier', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM supplier_profiles WHERE user_id = $1', [req.userId]);
|
||||
res.json({ supplier: result.rowCount ? profileResponse(result.rows[0]) : null });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/supplier', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const values = profileValues(req.body);
|
||||
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
|
||||
const result = await pool.query(`
|
||||
INSERT INTO supplier_profiles (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
typ_subjektu = EXCLUDED.typ_subjektu, nazev_spolecnosti = EXCLUDED.nazev_spolecnosti,
|
||||
ico = EXCLUDED.ico,
|
||||
jmeno = EXCLUDED.jmeno, prijmeni = EXCLUDED.prijmeni,
|
||||
ulice_cp = EXCLUDED.ulice_cp, psc = EXCLUDED.psc, mesto = EXCLUDED.mesto
|
||||
RETURNING *
|
||||
`, [req.userId, type, ...values]);
|
||||
res.json({ supplier: profileResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/clients', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM clients WHERE user_id = $1 ORDER BY created_at, id', [req.userId]);
|
||||
res.json({ clients: result.rows.map(clientResponse) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/clients', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const values = profileValues(req.body);
|
||||
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
|
||||
const result = await pool.query(`
|
||||
INSERT INTO clients (user_id, typ_subjektu, nazev_spolecnosti, ico, jmeno, prijmeni, ulice_cp, psc, mesto)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *
|
||||
`, [req.userId, type, ...values]);
|
||||
res.status(201).json({ client: clientResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/clients/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const values = profileValues(req.body);
|
||||
const type = req.body.typSubjektu === 'spolecnost' ? 'spolecnost' : 'osoba';
|
||||
const result = await pool.query(`
|
||||
UPDATE clients SET typ_subjektu = $1, nazev_spolecnosti = $2, ico = $3, jmeno = $4,
|
||||
prijmeni = $5, ulice_cp = $6, psc = $7, mesto = $8
|
||||
WHERE id = $9 AND user_id = $10 RETURNING *
|
||||
`, [type, ...values, req.params.id, req.userId]);
|
||||
if (!result.rowCount) {
|
||||
res.status(404).json({ error: 'Odběratel nebyl nalezen.' });
|
||||
return;
|
||||
}
|
||||
res.json({ client: clientResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/clients/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM clients WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const paymentPresetResponse = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accountNumber: row.account_number,
|
||||
iban: row.iban,
|
||||
swift: row.swift
|
||||
});
|
||||
|
||||
app.get('/api/payment-presets', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM payment_presets WHERE user_id = $1 ORDER BY name',
|
||||
[req.userId]
|
||||
);
|
||||
res.json({ presets: result.rows.map(paymentPresetResponse) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/payment-presets', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Zadejte název platební konfigurace.' });
|
||||
return;
|
||||
}
|
||||
const result = await pool.query(`
|
||||
INSERT INTO payment_presets (user_id, name, account_number, iban, swift)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, name) DO UPDATE SET
|
||||
account_number = EXCLUDED.account_number,
|
||||
iban = EXCLUDED.iban,
|
||||
swift = EXCLUDED.swift
|
||||
RETURNING *
|
||||
`, [
|
||||
req.userId,
|
||||
name,
|
||||
String(req.body.accountNumber || '').trim(),
|
||||
String(req.body.iban || '').trim(),
|
||||
String(req.body.swift || '').trim()
|
||||
]);
|
||||
res.status(201).json({ preset: paymentPresetResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM payment_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const invoiceResponse = (row) => ({
|
||||
id: row.id,
|
||||
invoiceNumber: row.invoice_number,
|
||||
data: row.invoice_data,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
app.get('/api/invoices', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, invoice_number, invoice_data, created_at, updated_at FROM invoices WHERE user_id = $1 ORDER BY updated_at DESC, id DESC',
|
||||
[req.userId]
|
||||
);
|
||||
res.json({ invoices: result.rows.map(invoiceResponse) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/health', async (req, res, next) => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await pool.query('SELECT 1');
|
||||
res.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/invoices', requireAuth, async (req, res, next) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const data = normalizeInvoiceData(req.body.data);
|
||||
if (!data) {
|
||||
res.status(400).json({ error: 'Neplatná data faktury.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const invoiceYear = new Date().getFullYear();
|
||||
await client.query('BEGIN');
|
||||
await client.query(`
|
||||
INSERT INTO invoice_counters (user_id, invoice_year, last_number)
|
||||
SELECT $1, $2, COALESCE(MAX((substring(invoice_number FROM '^F-[0-9]{4}-([0-9]+)$'))::integer), 0)
|
||||
FROM invoices
|
||||
WHERE user_id = $1 AND invoice_number LIKE $3
|
||||
ON CONFLICT (user_id, invoice_year) DO NOTHING
|
||||
`, [req.userId, invoiceYear, `F-${invoiceYear}-%`]);
|
||||
const counterResult = await client.query(`
|
||||
UPDATE invoice_counters
|
||||
SET last_number = last_number + 1
|
||||
WHERE user_id = $1 AND invoice_year = $2
|
||||
RETURNING last_number
|
||||
`, [req.userId, invoiceYear]);
|
||||
const invoiceNumber = `F-${invoiceYear}-${String(counterResult.rows[0].last_number).padStart(4, '0')}`;
|
||||
const result = await client.query(
|
||||
'INSERT INTO invoices (user_id, invoice_number, invoice_data) VALUES ($1, $2, $3) RETURNING *',
|
||||
[req.userId, invoiceNumber, data]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ invoice: invoiceResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
next(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/invoices/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const data = normalizeInvoiceData(req.body.data);
|
||||
if (!data) {
|
||||
res.status(400).json({ error: 'Neplatná data faktury.' });
|
||||
return;
|
||||
}
|
||||
const result = await pool.query(
|
||||
'UPDATE invoices SET invoice_data = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3 RETURNING *',
|
||||
[data, req.params.id, req.userId]
|
||||
);
|
||||
if (!result.rowCount) {
|
||||
res.status(404).json({ error: 'Faktura nebyla nalezena.' });
|
||||
return;
|
||||
}
|
||||
res.json({ invoice: invoiceResponse(result.rows[0]) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/invoices/:id', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM invoices WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/invoices', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM invoices WHERE user_id = $1', [req.userId]);
|
||||
await pool.query('DELETE FROM invoice_counters WHERE user_id = $1', [req.userId]);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const pdfText = (value) => String(value || 'Neuvedeno');
|
||||
const formatCzechNumber = (value) => Number(value || 0).toLocaleString('cs-CZ', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
const normalizeIban = (value) => String(value || '').replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
const domesticAccountToIban = (accountNumber) => {
|
||||
const match = String(accountNumber || '').replace(/\s+/g, '')
|
||||
.match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const bban = `${match[3]}${(match[1] || '').padStart(6, '0')}${match[2].padStart(10, '0')}`;
|
||||
const remainder = `${bban}123500`.split('').reduce(
|
||||
(value, digit) => (value * 10 + Number(digit)) % 97,
|
||||
0
|
||||
);
|
||||
return `CZ${String(98 - remainder).padStart(2, '0')}${bban}`;
|
||||
};
|
||||
|
||||
const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => {
|
||||
const account = normalizeIban(payment.iban) || domesticAccountToIban(payment.cisloUctu);
|
||||
if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(account)) {
|
||||
return '';
|
||||
}
|
||||
return [
|
||||
'SPD*1.0',
|
||||
`ACC:${account}`,
|
||||
`AM:${Number(total || 0).toFixed(2)}`,
|
||||
'CC:CZK',
|
||||
invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, '').slice(-10)}`,
|
||||
payment.swift && `X-SWIFT:${normalizeIban(payment.swift)}`
|
||||
].filter(Boolean).join('*');
|
||||
};
|
||||
|
||||
const appendInvoicePdf = async (archive, invoice) => {
|
||||
const data = invoice.invoice_data || {};
|
||||
const supplier = data.supplier || {};
|
||||
const customer = data.customer || {};
|
||||
const payment = data.payment || {};
|
||||
const supplierName = supplier.typSubjektu === 'spolecnost'
|
||||
? supplier.nazevSpolecnosti
|
||||
: [supplier.jmeno, supplier.prijmeni].filter(Boolean).join(' ');
|
||||
const customerName = customer.typSubjektu === 'spolecnost'
|
||||
? customer.nazevSpolecnosti
|
||||
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(' ');
|
||||
const document = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
|
||||
const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
||||
const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number);
|
||||
const qrBuffer = qrPayload ? await QRCode.toBuffer(qrPayload, {
|
||||
type: 'png',
|
||||
width: 180,
|
||||
margin: 1
|
||||
}) : null;
|
||||
const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right;
|
||||
const navy = '#183b56';
|
||||
const blue = '#2b6f8f';
|
||||
const line = '#d8d4cc';
|
||||
const lightBlue = '#f4f7fb';
|
||||
const warm = '#fffaf0';
|
||||
const bodyText = '#17202a';
|
||||
const muted = '#65717d';
|
||||
const address = (profile) => [
|
||||
profile.uliceCp,
|
||||
[profile.psc, profile.mesto].filter(Boolean).join(', ')
|
||||
].filter(Boolean).join(', ');
|
||||
const identity = (profile, name) => [
|
||||
name || 'Neuvedeno',
|
||||
profile.ico ? `IČO: ${profile.ico}` : '',
|
||||
address(profile)
|
||||
].filter(Boolean);
|
||||
const partyWidth = (pageWidth - 24) / 2;
|
||||
const customerX = document.page.margins.left + partyWidth + 24;
|
||||
|
||||
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
|
||||
const headerY = document.y;
|
||||
document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY);
|
||||
document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, {
|
||||
width: partyWidth,
|
||||
align: 'right'
|
||||
});
|
||||
document.y = headerY + 40;
|
||||
|
||||
const partyTop = document.y;
|
||||
const drawParty = (x, title, profile, name) => {
|
||||
const lines = identity(profile, name);
|
||||
document.roundedRect(x, partyTop, partyWidth, 86, 4).fillAndStroke('#f4f7fb', line);
|
||||
document.fillColor(navy).font(boldFont).fontSize(11).text(title, x + 10, partyTop + 10, { width: partyWidth - 20 });
|
||||
document.fillColor(bodyText).font(regularFont).fontSize(9).text(lines.join('\n'), x + 10, partyTop + 30, { width: partyWidth - 20, lineGap: 3 });
|
||||
};
|
||||
drawParty(document.page.margins.left, 'Dodavatel', supplier, supplierName);
|
||||
drawParty(customerX, 'Odběratel', customer, customerName);
|
||||
document.y = partyTop + 100;
|
||||
|
||||
const tableTop = document.y + 12;
|
||||
const columns = [0, pageWidth * 0.52, pageWidth * 0.68, pageWidth * 0.82, pageWidth];
|
||||
const rowHeight = 25;
|
||||
document.rect(document.page.margins.left, tableTop, pageWidth, rowHeight).fill('#eef1f3');
|
||||
document.fillColor(navy).font(boldFont).fontSize(9);
|
||||
['Položka', 'Množství', 'MJ', 'Cena za MJ'].forEach((heading, index) => {
|
||||
document.text(heading, document.page.margins.left + columns[index] + 6, tableTop + 8, {
|
||||
width: columns[index + 1] - columns[index] - 12
|
||||
});
|
||||
});
|
||||
document.y = tableTop + rowHeight;
|
||||
(data.items || []).forEach((item, index) => {
|
||||
const rowTop = document.y;
|
||||
document.rect(document.page.margins.left, rowTop, pageWidth, rowHeight)
|
||||
.fill(index % 2 ? lightBlue : warm);
|
||||
document.moveTo(document.page.margins.left, rowTop + rowHeight)
|
||||
.lineTo(document.page.margins.left + pageWidth, rowTop + rowHeight)
|
||||
.strokeColor(line).lineWidth(0.7).stroke();
|
||||
document.fillColor(bodyText).font(regularFont).fontSize(9);
|
||||
[pdfText(item.popis), formatCzechNumber(item.mnozstvi), pdfText(item.mernaJednotka), `${formatCzechNumber(item.cenaZaMj)} Kč`]
|
||||
.forEach((value, columnIndex) => document.text(value, document.page.margins.left + columns[columnIndex] + 6, rowTop + 8, {
|
||||
width: columns[columnIndex + 1] - columns[columnIndex] - 12
|
||||
}));
|
||||
document.y = rowTop + rowHeight;
|
||||
});
|
||||
|
||||
document.y += 18;
|
||||
document.fillColor(navy).font(boldFont).fontSize(15)
|
||||
.text(`Celková částka: ${formatCzechNumber(data.total)} Kč`, document.page.margins.left, document.y, {
|
||||
width: pageWidth,
|
||||
align: 'right'
|
||||
});
|
||||
document.y += 35;
|
||||
document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y)
|
||||
.strokeColor(navy).lineWidth(1.5).stroke();
|
||||
document.y += 12;
|
||||
const paymentTop = document.y;
|
||||
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line);
|
||||
document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10);
|
||||
document.fillColor(bodyText).font(regularFont).fontSize(10)
|
||||
.text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35)
|
||||
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55)
|
||||
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75);
|
||||
if (qrBuffer) {
|
||||
document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 });
|
||||
}
|
||||
document.end();
|
||||
};
|
||||
|
||||
app.get('/api/invoices/export', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT invoice_number, invoice_data FROM invoices WHERE user_id = $1 ORDER BY invoice_number',
|
||||
[req.userId]
|
||||
);
|
||||
if (!result.rowCount) {
|
||||
res.status(404).json({ error: 'Nejsou uložené žádné faktury k exportu.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.attachment('faktury.zip');
|
||||
const archive = new archiver.ZipArchive({ zlib: { level: 9 } });
|
||||
archive.on('error', next);
|
||||
archive.pipe(res);
|
||||
for (const invoice of result.rows) {
|
||||
await appendInvoicePdf(archive, invoice);
|
||||
}
|
||||
archive.finalize();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// specifying the folder with the static content - html+css
|
||||
app.use(express.static(__dirname + '/static'));
|
||||
@@ -10,4 +621,9 @@ app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '/static/index.html'));
|
||||
});
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Na serveru došlo k chybě.' });
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
Reference in New Issue
Block a user