From e7aee1e380ba7e9c5fc8d55a74994c40d375e103 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 17 May 2026 16:15:47 +0200 Subject: [PATCH] kmpaidkwtdwj --- controller/admin/CompanySettingsController.js | 255 +++--------- .../DocumentTemplateController.js | 8 +- controller/invoice/InvoiceController.js | 10 + database/createDb.js | 31 +- repositories/invoice/InvoiceRepository.js | 29 +- .../settings/CompanySettingsRepository.js | 28 +- routes/getRoutes.js | 1 + scripts/createAdminUser.js | 1 + services/invoice/InvoiceService.js | 55 ++- templates/invoice/invoice_company_DE.html | 373 ++++++++++++++++++ templates/invoice/invoice_company_EN.html | 373 ++++++++++++++++++ 11 files changed, 945 insertions(+), 219 deletions(-) create mode 100644 templates/invoice/invoice_company_DE.html create mode 100644 templates/invoice/invoice_company_EN.html diff --git a/controller/admin/CompanySettingsController.js b/controller/admin/CompanySettingsController.js index dd92ccb..32d03a7 100644 --- a/controller/admin/CompanySettingsController.js +++ b/controller/admin/CompanySettingsController.js @@ -1,111 +1,62 @@ const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); const { logger } = require('../../middleware/logger'); -const crypto = require('crypto'); const repo = new CompanySettingsRepository(); +const ALLOWED_LOGO_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']); +const MAX_LOGO_BYTES = 1024 * 1024; -class CompanySettingsController { - static _normalizeBase64ImageInput(value) { - if (value === undefined) return undefined; - if (value === null) return null; - - const stripDataUri = (str) => { - const s = String(str || '').trim(); - if (!s) return ''; - const m = s.match(/^data:([^;]+);base64,(.*)$/i); - return m ? (m[2] || '').trim() : s; - }; - - if (typeof value === 'string') { - // If base64 came via urlencoded forms, '+' often becomes ' ' after decoding. - // Restoring spaces back to '+' makes the payload usable again. - let normalized = stripDataUri(value); - if (normalized.includes(' ') && !normalized.includes('+')) { - normalized = normalized.replace(/ /g, '+'); - } - normalized = normalized.replace(/\s+/g, ''); - return normalized || null; - } - - if (typeof value === 'object') { - // Common Node.js JSON shape for Buffers: { type: 'Buffer', data: [..bytes..] } - if (value && value.type === 'Buffer' && Array.isArray(value.data)) { - try { - const buf = Buffer.from(value.data); - const asBase64 = buf.toString('base64'); - return asBase64 || null; - } catch (_) { - return undefined; - } - } - - // Common frontend shapes: - // { kind: 'base64', base64: '...' } - // { kind: 'base64', data: '...' } - // { dataUrl: 'data:image/png;base64,...' } - // { value: '...' } - const candidates = [ - value.base64, - value.data, - value.value, - value.content, - value.full, - value.raw, - value.payload, - value.src, - value.b64, - value.base64String, - value.dataUrl, - value.dataURL, - value.data_uri, - value.dataURI, - value.uri, - ]; - for (const c of candidates) { - if (typeof c === 'string' && c.trim()) { - let normalized = stripDataUri(c); - if (normalized.includes(' ') && !normalized.includes('+')) { - normalized = normalized.replace(/ /g, '+'); - } - normalized = normalized.replace(/\s+/g, ''); - return normalized || null; - } - } - - // Fallback: scan shallow object for a base64-ish string - const looksLikeImageBase64 = (str) => { - const s = String(str || '').trim(); - return s.startsWith('data:image/') || s.startsWith('iVBORw0KGgo') || s.startsWith('/9j/') || s.startsWith('R0lGOD'); - }; - - for (const v of Object.values(value)) { - if (typeof v === 'string' && looksLikeImageBase64(v)) { - let normalized = stripDataUri(v); - if (normalized.includes(' ') && !normalized.includes('+')) { - normalized = normalized.replace(/ /g, '+'); - } - normalized = normalized.replace(/\s+/g, ''); - return normalized || null; - } - if (v && typeof v === 'object') { - for (const vv of Object.values(v)) { - if (typeof vv === 'string' && looksLikeImageBase64(vv)) { - let normalized = stripDataUri(vv); - if (normalized.includes(' ') && !normalized.includes('+')) { - normalized = normalized.replace(/ /g, '+'); - } - normalized = normalized.replace(/\s+/g, ''); - return normalized || null; - } - } - } - } - } - - // Unknown shape; ignore instead of persisting garbage - return undefined; +function normalizeBase64ImageInput(rawValue, rawMimeType) { + if (rawValue === undefined && rawMimeType === undefined) { + return { provided: false }; } + if (rawValue === null || rawValue === '') { + return { provided: true, base64: null, mimeType: null }; + } + + if (typeof rawValue !== 'string') { + throw new Error('company_logo_base64 must be a string or null'); + } + + let value = rawValue.trim(); + let mimeType = typeof rawMimeType === 'string' ? rawMimeType.trim() : ''; + + if (!value) { + return { provided: true, base64: null, mimeType: null }; + } + + const dataUriMatch = value.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\r\n]+)$/); + if (dataUriMatch) { + mimeType = dataUriMatch[1]; + value = dataUriMatch[2]; + } + + const compactBase64 = value.replace(/\s+/g, ''); + if (!mimeType) { + throw new Error('company_logo_mime_type is required when company_logo_base64 is provided'); + } + + if (!ALLOWED_LOGO_MIME_TYPES.has(mimeType)) { + throw new Error(`Unsupported company logo type '${mimeType}'`); + } + + if (!/^[A-Za-z0-9+/=]+$/.test(compactBase64)) { + throw new Error('company_logo_base64 is not valid base64'); + } + + const bytes = Buffer.from(compactBase64, 'base64').length; + if (bytes > MAX_LOGO_BYTES) { + throw new Error('Company logo exceeds 1 MB'); + } + + return { + provided: true, + base64: compactBase64, + mimeType, + }; +} + +class CompanySettingsController { static async get(req, res) { try { const settings = await repo.get(); @@ -114,8 +65,8 @@ class CompanySettingsController { company_street: '', company_postal_city: '', company_country: '', - qr_code_60_base64: null, - qr_code_120_base64: null, + company_logo_base64: null, + company_logo_mime_type: null, }); } catch (err) { return res.status(500).json({ message: 'Failed to load company settings' }); @@ -146,59 +97,21 @@ class CompanySettingsController { }); } - const summarizeValue = (val) => { - const t = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val; - const summary = { type: t }; - if (t === 'string') { - const s = String(val); - summary.length = s.length; - summary.hasDataUriPrefix = s.trim().toLowerCase().startsWith('data:image/'); - summary.hasWhitespace = /\s/.test(s); - summary.startsWithPngSig = s.trim().startsWith('iVBORw0KGgo'); - } else if (t === 'array') { - summary.length = val.length; - } else if (t === 'object' && val) { - summary.keys = Object.keys(val).slice(0, 30); - if (val.type === 'Buffer' && Array.isArray(val.data)) { - summary.bufferLike = true; - summary.dataLength = val.data.length; - } - } - return summary; - }; - - const hash12 = (s) => { - try { - if (typeof s !== 'string' || !s) return null; - return crypto.createHash('sha256').update(s).digest('hex').slice(0, 12); - } catch { - return null; - } - }; - - // Log request shape (not the base64 itself) - const incoming60Raw = body.qr_code_60_base64 ?? body.qrCode60Base64; - const incoming120Raw = body.qr_code_120_base64 ?? body.qrCode120Base64; - if (incoming60Raw !== undefined || incoming120Raw !== undefined) { - logger.info('companySettings:update:incoming_qr', { - requestId, - contentType: req.get('content-type') || null, - contentLength, - bodyKeys: Object.keys(body).slice(0, 50), - qr60: summarizeValue(incoming60Raw), - qr120: summarizeValue(incoming120Raw), - }); - } - // Accept both snake_case and camelCase const payload = { company_name: body.company_name ?? body.companyName, company_street: body.company_street ?? body.companyStreet, company_postal_city: body.company_postal_city ?? body.companyPostalCity, company_country: body.company_country ?? body.companyCountry, - qr_code_60_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_60_base64 ?? body.qrCode60Base64), - qr_code_120_base64: CompanySettingsController._normalizeBase64ImageInput(body.qr_code_120_base64 ?? body.qrCode120Base64), }; + const normalizedLogo = normalizeBase64ImageInput( + body.company_logo_base64 ?? body.companyLogoBase64, + body.company_logo_mime_type ?? body.companyLogoMimeType, + ); + if (normalizedLogo.provided) { + payload.company_logo_base64 = normalizedLogo.base64; + payload.company_logo_mime_type = normalizedLogo.mimeType; + } // Only forward keys that were actually provided (so we don't wipe values on partial updates) const provided = {}; @@ -206,52 +119,8 @@ class CompanySettingsController { if (value !== undefined) provided[key] = value; } - // Debug without leaking base64 - if (incoming60Raw !== undefined && provided.qr_code_60_base64 === undefined) { - logger.warn('companySettings:update:qr60_ignored', { - requestId, - incoming: summarizeValue(incoming60Raw), - reason: 'normalize_returned_undefined_or_unrecognized_shape' - }); - } - if (incoming120Raw !== undefined && provided.qr_code_120_base64 === undefined) { - logger.warn('companySettings:update:qr120_ignored', { - requestId, - incoming: summarizeValue(incoming120Raw), - reason: 'normalize_returned_undefined_or_unrecognized_shape' - }); - } - - if (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined) { - const len60 = typeof provided.qr_code_60_base64 === 'string' ? provided.qr_code_60_base64.length : null; - const len120 = typeof provided.qr_code_120_base64 === 'string' ? provided.qr_code_120_base64.length : null; - logger.info('companySettings:update:qr_normalized', { - requestId, - has60: provided.qr_code_60_base64 !== undefined, - type60: provided.qr_code_60_base64 === null ? 'null' : typeof provided.qr_code_60_base64, - len60, - sha60: typeof provided.qr_code_60_base64 === 'string' ? hash12(provided.qr_code_60_base64) : null, - has120: provided.qr_code_120_base64 !== undefined, - type120: provided.qr_code_120_base64 === null ? 'null' : typeof provided.qr_code_120_base64, - len120, - sha120: typeof provided.qr_code_120_base64 === 'string' ? hash12(provided.qr_code_120_base64) : null, - }); - } - const updated = await repo.update(provided); - if (updated && (provided.qr_code_60_base64 !== undefined || provided.qr_code_120_base64 !== undefined)) { - const storedLen60 = typeof updated.qr_code_60_base64 === 'string' ? updated.qr_code_60_base64.length : null; - const storedLen120 = typeof updated.qr_code_120_base64 === 'string' ? updated.qr_code_120_base64.length : null; - logger.info('companySettings:update:qr_stored', { - requestId, - storedLen60, - storedSha60: typeof updated.qr_code_60_base64 === 'string' ? hash12(updated.qr_code_60_base64) : null, - storedLen120, - storedSha120: typeof updated.qr_code_120_base64 === 'string' ? hash12(updated.qr_code_120_base64) : null, - }); - } - return res.json(updated); } catch (err) { logger.error('companySettings:update:failed', { diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index de08451..c7d9965 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -36,8 +36,8 @@ function saveDebugFile(filename, data, encoding = 'utf8') { // Helper to remove/empty placeholders except allow-list // Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized. -// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'profitplanetSignature'). -function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']) { +// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'companyLogo', 'profitplanetSignature'). +function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']) { if (!html || typeof html !== 'string') return html; const allowSet = new Set((allowList || []).map(s => String(s).trim()).filter(Boolean)); @@ -412,7 +412,7 @@ async function renderLatestActiveContractHtmlForUser({ targetUserId, userType, c }); html = html.replace(/{{\s*signatureImage\s*}}/g, 'Your signature will appear here'); - html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']); + html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']); const reqForStamp = (req && req.user) ? req : { user: { id: targetUserId, user_type: userType } }; try { html = await applyCompanyStampPlaceholders(html, reqForStamp); } catch (e) {} @@ -2101,7 +2101,7 @@ exports.downloadPdf = async (req, res) => { // SANITIZE: remove variables for downloaded PDF. // Allow only stamp/signature placeholders to remain so images can still be injected. - html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']); + html = sanitizePlaceholders(html, ['companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']); // Do NOT keep currentDate for download (user requested variables emptied) // Apply company stamp & profitplanet signature (these placeholders were preserved above) diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js index 1b928fb..6c53a7d 100644 --- a/controller/invoice/InvoiceController.js +++ b/controller/invoice/InvoiceController.js @@ -30,6 +30,16 @@ module.exports = { } }, + async revenueSummary(req, res) { + try { + const data = await service.getRevenueSummary(); + return res.json({ success: true, data }); + } catch (e) { + console.error('[INVOICE REVENUE SUMMARY]', e); + return res.status(403).json({ success: false, message: e.message }); + } + }, + async pay(req, res) { try { const data = await service.markPaid(req.params.id, { diff --git a/database/createDb.js b/database/createDb.js index 2973b6f..827c5d7 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -94,6 +94,13 @@ async function addColumnIfMissing(conn, table, column, ddlFragment /* includes t return true; } +async function dropColumnIfExists(conn, table, column) { + if (!(await columnExists(conn, table, column))) return false; + await conn.query(`ALTER TABLE \`${table}\` DROP COLUMN \`${column}\``); + console.log(`🗑️ Dropped column ${table}.${column}`); + return true; +} + async function constraintExists(conn, table, constraintName) { const [rows] = await conn.query( `SELECT 1 @@ -837,8 +844,8 @@ const createDatabase = async () => { company_street VARCHAR(255) NOT NULL DEFAULT '', company_postal_city VARCHAR(255) NOT NULL DEFAULT '', company_country VARCHAR(100) NOT NULL DEFAULT 'Austria', - qr_code_60_base64 LONGTEXT NULL, - qr_code_120_base64 LONGTEXT NULL, + company_logo_base64 MEDIUMTEXT NULL, + company_logo_mime_type VARCHAR(100) NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CHECK (id = 1) ); @@ -847,11 +854,13 @@ const createDatabase = async () => { await connection.query(` INSERT IGNORE INTO company_settings (id) VALUES (1); `); + await addColumnIfMissing(connection, 'company_settings', 'company_logo_base64', `MEDIUMTEXT NULL AFTER company_country`); + await addColumnIfMissing(connection, 'company_settings', 'company_logo_mime_type', `VARCHAR(100) NULL AFTER company_logo_base64`); console.log('✅ Company settings table created/verified'); - // Backward-compatible: add QR code columns if missing - await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL'); - await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL'); + // Cleanup legacy invoice QR columns, which are no longer used. + await dropColumnIfExists(connection, 'company_settings', 'qr_code_60_base64'); + await dropColumnIfExists(connection, 'company_settings', 'qr_code_120_base64'); // --- I18n Preferences (single-row, admin language-management settings) --- await connection.query(` @@ -1302,7 +1311,7 @@ const createDatabase = async () => { total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00, total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00, vat_rate DECIMAL(6,3) NULL, - status ENUM('draft','issued','paid','canceled') NOT NULL DEFAULT 'draft', + status ENUM('draft','issued','paid','overdue','canceled') NOT NULL DEFAULT 'draft', issued_at DATETIME NULL, due_at DATETIME NULL, pdf_storage_key VARCHAR(255) NULL, @@ -1317,6 +1326,16 @@ const createDatabase = async () => { `); console.log('✅ Invoices table created/verified'); + try { + await connection.query(` + ALTER TABLE invoices + MODIFY COLUMN status ENUM('draft','issued','paid','overdue','canceled') NOT NULL DEFAULT 'draft' + `); + console.log('✅ Updated invoices.status column to include overdue'); + } catch (err) { + console.warn('⚠️ Could not modify invoices.status column:', err.message); + } + await connection.query(` CREATE TABLE IF NOT EXISTS invoice_items ( id BIGINT AUTO_INCREMENT PRIMARY KEY, diff --git a/repositories/invoice/InvoiceRepository.js b/repositories/invoice/InvoiceRepository.js index 8dcff86..2ecbd6f 100644 --- a/repositories/invoice/InvoiceRepository.js +++ b/repositories/invoice/InvoiceRepository.js @@ -206,8 +206,35 @@ class InvoiceRepository { return rows.map((r) => new Invoice(r)); } + async getPaidRevenueSummary() { + const [rows] = await pool.query( + `SELECT COALESCE(SUM(total_gross), 0) AS total_paid_all_time, + COALESCE(MAX(currency), 'EUR') AS currency, + COUNT(*) AS paid_invoice_count + FROM invoices + WHERE status = 'paid'` + ); + + return { + total_paid_all_time: Number(rows?.[0]?.total_paid_all_time || 0), + currency: rows?.[0]?.currency || 'EUR', + paid_invoice_count: Number(rows?.[0]?.paid_invoice_count || 0), + }; + } + + async markIssuedPastDueAsOverdue() { + const [result] = await pool.query( + `UPDATE invoices + SET status = 'overdue', updated_at = NOW() + WHERE status = 'issued' + AND due_at IS NOT NULL + AND due_at < CURDATE()` + ); + return result?.affectedRows || 0; + } + async updateStatus(invoiceId, newStatus) { - const allowed = ['draft', 'issued', 'paid', 'canceled']; + const allowed = ['draft', 'issued', 'paid', 'overdue', 'canceled']; if (!allowed.includes(newStatus)) { throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`); } diff --git a/repositories/settings/CompanySettingsRepository.js b/repositories/settings/CompanySettingsRepository.js index 7153f1b..573fdea 100644 --- a/repositories/settings/CompanySettingsRepository.js +++ b/repositories/settings/CompanySettingsRepository.js @@ -2,7 +2,12 @@ const pool = require('../../database/database'); class CompanySettingsRepository { async get() { - const [rows] = await pool.query('SELECT * FROM company_settings WHERE id = 1'); + const [rows] = await pool.query( + `SELECT id, company_name, company_street, company_postal_city, company_country, + company_logo_base64, company_logo_mime_type, updated_at + FROM company_settings + WHERE id = 1` + ); return rows[0] || null; } @@ -11,8 +16,8 @@ class CompanySettingsRepository { company_street, company_postal_city, company_country, - qr_code_60_base64, - qr_code_120_base64, + company_logo_base64, + company_logo_mime_type, } = {}) { const current = await this.get(); const next = { @@ -20,27 +25,30 @@ class CompanySettingsRepository { company_street: company_street !== undefined ? company_street : (current?.company_street ?? ''), company_postal_city: company_postal_city !== undefined ? company_postal_city : (current?.company_postal_city ?? ''), company_country: company_country !== undefined ? company_country : (current?.company_country ?? ''), - qr_code_60_base64: qr_code_60_base64 !== undefined ? qr_code_60_base64 : (current?.qr_code_60_base64 ?? null), - qr_code_120_base64: qr_code_120_base64 !== undefined ? qr_code_120_base64 : (current?.qr_code_120_base64 ?? null), + company_logo_base64: company_logo_base64 !== undefined ? (company_logo_base64 || null) : (current?.company_logo_base64 ?? null), + company_logo_mime_type: company_logo_mime_type !== undefined ? (company_logo_mime_type || null) : (current?.company_logo_mime_type ?? null), }; await pool.query( - `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country, qr_code_60_base64, qr_code_120_base64) + `INSERT INTO company_settings ( + id, company_name, company_street, company_postal_city, company_country, + company_logo_base64, company_logo_mime_type + ) VALUES (1, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE company_name = VALUES(company_name), company_street = VALUES(company_street), company_postal_city = VALUES(company_postal_city), company_country = VALUES(company_country), - qr_code_60_base64 = VALUES(qr_code_60_base64), - qr_code_120_base64 = VALUES(qr_code_120_base64)`, + company_logo_base64 = VALUES(company_logo_base64), + company_logo_mime_type = VALUES(company_logo_mime_type)`, [ next.company_name || '', next.company_street || '', next.company_postal_city || '', next.company_country || '', - next.qr_code_60_base64 ?? null, - next.qr_code_120_base64 ?? null, + next.company_logo_base64, + next.company_logo_mime_type, ] ); return this.get(); diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 82f9108..8812d5d 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -204,6 +204,7 @@ router.get('/news/:slug', NewsController.getPublic); router.get('/invoices/mine', authMiddleware, InvoiceController.listMine); router.get('/invoices/:id/pdf', authMiddleware, InvoiceController.downloadPdf); router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList); +router.get('/admin/invoices/revenue-summary', authMiddleware, adminOnly, InvoiceController.revenueSummary); router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail); // NOTE: Contract signing uses UnitOfWork; any DB cleanup must happen before commit() closes the connection. diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index 1f25dc8..c4c6d88 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,6 +3,7 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { + return; // const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 6d6a2c8..dfb49fd 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -277,6 +277,31 @@ class InvoiceService { return `${this._escapeHtml(bankAccountHolder)}
${this._escapeHtml(bankIban)}
${this._escapeHtml(bankBic)}`; } + async _loadCompanyInfo() { + try { + const [rows] = await pool.query( + `SELECT company_name, company_street, company_postal_city, company_country, + company_logo_base64, company_logo_mime_type + FROM company_settings + WHERE id = 1 + LIMIT 1` + ); + return rows?.[0] || {}; + } catch (e) { + logger.warn('InvoiceService._loadCompanyInfo:error', { message: e?.message }); + return {}; + } + } + + _buildCompanyLogoTag(companyInfo) { + const base64 = typeof companyInfo?.company_logo_base64 === 'string' ? companyInfo.company_logo_base64.trim() : ''; + const mimeType = typeof companyInfo?.company_logo_mime_type === 'string' ? companyInfo.company_logo_mime_type.trim() : ''; + if (!base64 || !mimeType || !mimeType.startsWith('image/')) return ''; + + const alt = this._escapeHtml(companyInfo?.company_name || 'Company logo'); + return `${alt}`; + } + _prepareVariablesForTemplate(templateHtml, variables) { // Ensure backwards compatibility with older templates that only contain {{paymentInfoText}} // by injecting the Profit Planet bank block into paymentInfoText. @@ -328,12 +353,14 @@ class InvoiceService { bankBic, ].join('
'); - // Hardcoded company address (Profit Planet) + const storedCompanyInfo = await this._loadCompanyInfo(); const companyInfo = { - company_name: 'Profit Planet GmbH', - company_street: 'Kärntner Straße 227', - company_postal_city: '8053 Graz', - company_country: '', + company_name: storedCompanyInfo.company_name || 'Profit Planet GmbH', + company_street: storedCompanyInfo.company_street || 'Kärntner Straße 227', + company_postal_city: storedCompanyInfo.company_postal_city || '8053 Graz', + company_country: storedCompanyInfo.company_country || 'Austria', + company_logo_base64: storedCompanyInfo.company_logo_base64 || null, + company_logo_mime_type: storedCompanyInfo.company_logo_mime_type || null, }; // For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser @@ -378,6 +405,7 @@ class InvoiceService { companyStreet: this._escapeHtml(companyInfo.company_street || ''), companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''), companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'), + companyLogo: this._buildCompanyLogoTag(companyInfo), customerName: this._escapeHtml(customerName), customerEmail: this._escapeHtml(customerEmail), customerStreet: this._escapeHtml(invoice.buyer_street || ''), @@ -763,18 +791,34 @@ class InvoiceService { return paidInvoice; } + async syncOverdueStatuses() { + return this.repo.markIssuedPastDueAsOverdue(); + } + async listMine(userId, { status, limit = 50, offset = 0 } = {}) { + await this.syncOverdueStatuses(); return this.repo.listByUser(userId, { status, limit, offset }); } async listByAbonement(abonementId) { + await this.syncOverdueStatuses(); return this.repo.findByAbonement(abonementId); } async adminList({ status, limit = 200, offset = 0 } = {}) { + await this.syncOverdueStatuses(); return this.repo.listAll({ status, limit, offset }); } + async getRevenueSummary() { + const summary = await this.repo.getPaidRevenueSummary(); + return { + totalPaidAllTime: Number(summary?.total_paid_all_time || 0), + currency: summary?.currency || 'EUR', + paidInvoiceCount: Number(summary?.paid_invoice_count || 0), + }; + } + async updateStatus(invoiceId, newStatus) { const invoice = await this.repo.getById(invoiceId); if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`); @@ -792,6 +836,7 @@ class InvoiceService { } async getInvoiceDetail(invoiceId) { + await this.syncOverdueStatuses(); const invoice = await this.repo.getById(invoiceId); if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`); const items = await this.repo.getItemsByInvoiceId(invoiceId); diff --git a/templates/invoice/invoice_company_DE.html b/templates/invoice/invoice_company_DE.html new file mode 100644 index 0000000..13a02d4 --- /dev/null +++ b/templates/invoice/invoice_company_DE.html @@ -0,0 +1,373 @@ + + + + + + {{documentTitle}} {{invoiceNumber}} + + + +
+
+
+
+ +

{{documentTitle}}

+
+
+
{{invoiceNumberLabel}}
+
{{invoiceNumber}}
+
+
+
+ +
+
+
+

{{fromLabel}}

+

+ {{companyName}}
+ {{companyStreet}}
+ {{companyPostalCity}}
+ {{companyCountry}} +

+
+
+

{{toLabel}}

+

+ {{customerName}}
+ {{customerEmail}}
+ {{customerStreet}}
+ {{customerPostalCity}}
+ {{customerCountry}} +

+
+
+

{{detailsLabel}}

+
+
{{dateLabel}}{{issuedAt}}
+
{{dueDateLabel}}{{dueAt}}
+
{{statusLabel}}{{invoiceStatus}}
+
+
+
+ + {{orderedByBlock}} + +
+
Steuerhinweis
+

{{taxLabel}} ({{vatRateDisplay}})

+
+ + + + + + + + + + + + + {{itemsRows}} + +
#{{descriptionHeader}}{{qtyHeader}}{{unitPriceHeader}}{{totalHeader}}
+ +
+
+

{{paymentInfoTitle}}

+

{{paymentInfoText}}

+
+
Kontoinhaber{{bankAccountHolder}}
+
IBAN{{bankIban}}
+
BIC{{bankBic}}
+
+
+ +
+

Zusammenfassung

+
{{subtotalLabel}}{{totalNet}}
+
{{taxLabel}} ({{vatRateDisplay}}){{totalTax}}
+
{{totalLabel}}{{totalGross}}
+
+
+ + +
+
+ + \ No newline at end of file diff --git a/templates/invoice/invoice_company_EN.html b/templates/invoice/invoice_company_EN.html new file mode 100644 index 0000000..880bc0f --- /dev/null +++ b/templates/invoice/invoice_company_EN.html @@ -0,0 +1,373 @@ + + + + + + {{documentTitle}} {{invoiceNumber}} + + + +
+
+
+
+ +

{{documentTitle}}

+
+
+
{{invoiceNumberLabel}}
+
{{invoiceNumber}}
+
+
+
+ +
+
+
+

{{fromLabel}}

+

+ {{companyName}}
+ {{companyStreet}}
+ {{companyPostalCity}}
+ {{companyCountry}} +

+
+
+

{{toLabel}}

+

+ {{customerName}}
+ {{customerEmail}}
+ {{customerStreet}}
+ {{customerPostalCity}}
+ {{customerCountry}} +

+
+
+

{{detailsLabel}}

+
+
{{dateLabel}}{{issuedAt}}
+
{{dueDateLabel}}{{dueAt}}
+
{{statusLabel}}{{invoiceStatus}}
+
+
+
+ + {{orderedByBlock}} + +
+
Tax treatment
+

{{taxLabel}} ({{vatRateDisplay}})

+
+ + + + + + + + + + + + + {{itemsRows}} + +
#{{descriptionHeader}}{{qtyHeader}}{{unitPriceHeader}}{{totalHeader}}
+ +
+
+

{{paymentInfoTitle}}

+

{{paymentInfoText}}

+
+
Account holder{{bankAccountHolder}}
+
IBAN{{bankIban}}
+
BIC{{bankBic}}
+
+
+ +
+

Summary

+
{{subtotalLabel}}{{totalNet}}
+
{{taxLabel}} ({{vatRateDisplay}}){{totalTax}}
+
{{totalLabel}}{{totalGross}}
+
+
+ + +
+
+ + \ No newline at end of file