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 18b8976..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
@@ -836,9 +843,9 @@ const createDatabase = async () => {
company_name VARCHAR(200) NOT NULL DEFAULT 'ProfitPlanet GmbH',
company_street VARCHAR(255) NOT NULL DEFAULT '',
company_postal_city VARCHAR(255) NOT NULL DEFAULT '',
- company_country VARCHAR(100) NOT NULL DEFAULT 'Germany',
- qr_code_60_base64 LONGTEXT NULL,
- qr_code_120_base64 LONGTEXT NULL,
+ company_country VARCHAR(100) NOT NULL DEFAULT 'Austria',
+ 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/scripts/createCompanyUser.js b/scripts/createCompanyUser.js
index 82f3742..b3a21c4 100644
--- a/scripts/createCompanyUser.js
+++ b/scripts/createCompanyUser.js
@@ -2,7 +2,7 @@ const UnitOfWork = require('../database/UnitOfWork');
const argon2 = require('argon2');
async function createCompanyUser() {
- return
+
// Edit these values directly in code (no env vars)
const companyEmail = 'dummy-company@profitplanet.local';
const companyPassword = 'dummyPass!1234';
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 `
`;
+ }
+
_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}}
+
+
+
+
+
+
+
+
{{companyLogo}}
+
{{documentTitle}}
+
+
+
{{invoiceNumberLabel}}
+
{{invoiceNumber}}
+
+
+
+
+
+
+
+
{{fromLabel}}
+
+ {{companyName}}
+ {{companyStreet}}
+ {{companyPostalCity}}
+ {{companyCountry}}
+
+
+
+
{{toLabel}}
+
+ {{customerName}}
+ {{customerEmail}}
+ {{customerStreet}}
+ {{customerPostalCity}}
+ {{customerCountry}}
+
+
+
+
+
+ {{orderedByBlock}}
+
+
+
Steuerhinweis
+
{{taxLabel}} ({{vatRateDisplay}})
+
+
+
+
+
+ | # |
+ {{descriptionHeader}} |
+ {{qtyHeader}} |
+ {{unitPriceHeader}} |
+ {{totalHeader}} |
+
+
+
+ {{itemsRows}}
+
+
+
+
+
+
{{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}}
+
+
+
+
+
+
+
+
{{companyLogo}}
+
{{documentTitle}}
+
+
+
{{invoiceNumberLabel}}
+
{{invoiceNumber}}
+
+
+
+
+
+
+
+
{{fromLabel}}
+
+ {{companyName}}
+ {{companyStreet}}
+ {{companyPostalCity}}
+ {{companyCountry}}
+
+
+
+
{{toLabel}}
+
+ {{customerName}}
+ {{customerEmail}}
+ {{customerStreet}}
+ {{customerPostalCity}}
+ {{customerCountry}}
+
+
+
+
+
+ {{orderedByBlock}}
+
+
+
Tax treatment
+
{{taxLabel}} ({{vatRateDisplay}})
+
+
+
+
+
+ | # |
+ {{descriptionHeader}} |
+ {{qtyHeader}} |
+ {{unitPriceHeader}} |
+ {{totalHeader}} |
+
+
+
+ {{itemsRows}}
+
+
+
+
+
+
{{paymentInfoTitle}}
+
{{paymentInfoText}}
+
+
Account holder{{bankAccountHolder}}
+
IBAN{{bankIban}}
+
BIC{{bankBic}}
+
+
+
+
+
Summary
+
{{subtotalLabel}}{{totalNet}}
+
{{taxLabel}} ({{vatRateDisplay}}){{totalTax}}
+
{{totalLabel}}{{totalGross}}
+
+
+
+
+
+
+
+
\ No newline at end of file