feature+refactor/fewThingsIGuess #24

Merged
Seazn merged 3 commits from feature+refactor/fewThingsIGuess into dev 2026-05-21 17:34:29 +00:00
11 changed files with 945 additions and 219 deletions
Showing only changes of commit e7aee1e380 - Show all commits

View File

@ -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;
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 _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;
}
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', {

View File

@ -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)

View File

@ -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, {

View File

@ -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,

View File

@ -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(', ')}`);
}

View File

@ -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();

View File

@ -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.

View File

@ -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';

View File

@ -277,6 +277,31 @@ class InvoiceService {
return `<strong>${this._escapeHtml(bankAccountHolder)}</strong><br>${this._escapeHtml(bankIban)}<br>${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 `<img src="data:${mimeType};base64,${base64}" alt="${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('<br>');
// 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);

View File

@ -0,0 +1,373 @@
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{documentTitle}} {{invoiceNumber}}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
background: #f4f6fb;
color: #1f2937;
font-size: 13px;
line-height: 1.5;
}
.page {
max-width: 860px;
margin: 28px auto;
background: #ffffff;
border: 1px solid #dbe3f0;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 24px 60px -38px rgba(15, 23, 42, 0.35);
}
.hero {
padding: 34px 38px 28px;
background: linear-gradient(135deg, #1f2937 0%, #0f172a 45%, #0369a1 100%);
color: #ffffff;
}
.hero-grid {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
}
.hero-brand {
display: grid;
gap: 14px;
min-width: 0;
}
.hero-logo:empty {
display: none;
}
.hero-logo img {
display: block;
max-height: 72px;
max-width: 220px;
object-fit: contain;
}
.hero h1 {
margin: 0;
font-size: 31px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero p {
margin: 8px 0 0;
max-width: 440px;
color: rgba(255, 255, 255, 0.82);
}
.invoice-card {
min-width: 220px;
padding: 18px 20px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
text-align: right;
}
.invoice-card .eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: rgba(255, 255, 255, 0.72);
}
.invoice-card .number {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
}
.content {
padding: 30px 38px 36px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-bottom: 24px;
}
.info-card {
padding: 18px;
border: 1px solid #dbe3f0;
border-radius: 18px;
background: #f8fafc;
}
.info-card h2 {
margin: 0 0 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #64748b;
}
.info-card p {
margin: 0;
}
.highlight {
font-weight: 700;
color: #0f172a;
}
.meta-lines {
display: grid;
gap: 6px;
}
.meta-line {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
min-width: 0;
}
.meta-label {
color: #64748b;
flex: 1 1 auto;
min-width: 0;
padding-right: 12px;
}
.meta-line .highlight,
.meta-line .status-pill {
flex: 0 0 auto;
text-align: right;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 5px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tax-banner {
margin-bottom: 22px;
padding: 15px 18px;
border: 1px solid #bfdbfe;
border-radius: 18px;
background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%);
}
.tax-banner-title {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #1d4ed8;
font-weight: 700;
}
.tax-banner p {
margin: 0;
color: #334155;
}
.items-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 18px;
border: 1px solid #dbe3f0;
margin-bottom: 24px;
}
.items-table thead th {
padding: 12px 14px;
background: #e2e8f0;
color: #334155;
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.items-table thead th:nth-child(3),
.items-table thead th:nth-child(4),
.items-table thead th:nth-child(5),
.items-table tbody td:nth-child(3),
.items-table tbody td:nth-child(4),
.items-table tbody td:nth-child(5) {
text-align: right;
}
.items-table tbody td {
padding: 12px 14px;
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
.summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr);
gap: 20px;
align-items: start;
}
.payment-card,
.totals-card {
padding: 20px;
border: 1px solid #dbe3f0;
border-radius: 18px;
background: #f8fafc;
}
.payment-card h3,
.totals-card h3 {
margin: 0 0 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #64748b;
}
.payment-card p {
margin: 0 0 12px;
color: #334155;
}
.bank-list {
margin-top: 14px;
display: grid;
gap: 8px;
}
.bank-row {
display: flex;
justify-content: space-between;
gap: 16px;
}
.bank-label {
color: #64748b;
}
.bank-value {
text-align: right;
color: #0f172a;
font-weight: 600;
}
.totals-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 7px 0;
color: #475569;
}
.totals-row strong {
color: #0f172a;
}
.totals-row.total {
margin-top: 8px;
padding-top: 12px;
border-top: 2px solid #cbd5e1;
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.footer {
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
text-align: center;
line-height: 1.6;
}
@media (max-width: 760px) {
.hero,
.content {
padding-left: 22px;
padding-right: 22px;
}
.hero-grid,
.summary-grid,
.info-grid {
grid-template-columns: 1fr;
display: grid;
}
.invoice-card {
text-align: left;
}
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div class="hero-grid">
<div class="hero-brand">
<div class="hero-logo">{{companyLogo}}</div>
<h1>{{documentTitle}}</h1>
</div>
<div class="invoice-card">
<div class="eyebrow">{{invoiceNumberLabel}}</div>
<div class="number">{{invoiceNumber}}</div>
</div>
</div>
</section>
<section class="content">
<div class="info-grid">
<div class="info-card">
<h2>{{fromLabel}}</h2>
<p>
<span class="highlight">{{companyName}}</span><br>
{{companyStreet}}<br>
{{companyPostalCity}}<br>
{{companyCountry}}
</p>
</div>
<div class="info-card">
<h2>{{toLabel}}</h2>
<p>
<span class="highlight">{{customerName}}</span><br>
{{customerEmail}}<br>
{{customerStreet}}<br>
{{customerPostalCity}}<br>
{{customerCountry}}
</p>
</div>
<div class="info-card">
<h2>{{detailsLabel}}</h2>
<div class="meta-lines">
<div class="meta-line"><span class="meta-label">{{dateLabel}}</span><span class="highlight">{{issuedAt}}</span></div>
<div class="meta-line"><span class="meta-label">{{dueDateLabel}}</span><span class="highlight">{{dueAt}}</span></div>
<div class="meta-line"><span class="meta-label">{{statusLabel}}</span><span class="status-pill">{{invoiceStatus}}</span></div>
</div>
</div>
</div>
{{orderedByBlock}}
<div class="tax-banner">
<div class="tax-banner-title">Steuerhinweis</div>
<p><strong>{{taxLabel}}</strong> ({{vatRateDisplay}})</p>
</div>
<table class="items-table">
<thead>
<tr>
<th>#</th>
<th>{{descriptionHeader}}</th>
<th>{{qtyHeader}}</th>
<th>{{unitPriceHeader}}</th>
<th>{{totalHeader}}</th>
</tr>
</thead>
<tbody>
{{itemsRows}}
</tbody>
</table>
<div class="summary-grid">
<div class="payment-card">
<h3>{{paymentInfoTitle}}</h3>
<p>{{paymentInfoText}}</p>
<div class="bank-list">
<div class="bank-row"><span class="bank-label">Kontoinhaber</span><span class="bank-value">{{bankAccountHolder}}</span></div>
<div class="bank-row"><span class="bank-label">IBAN</span><span class="bank-value">{{bankIban}}</span></div>
<div class="bank-row"><span class="bank-label">BIC</span><span class="bank-value">{{bankBic}}</span></div>
</div>
</div>
<div class="totals-card">
<h3>Zusammenfassung</h3>
<div class="totals-row"><span>{{subtotalLabel}}</span><strong>{{totalNet}}</strong></div>
<div class="totals-row"><span>{{taxLabel}} ({{vatRateDisplay}})</span><strong>{{totalTax}}</strong></div>
<div class="totals-row total"><span>{{totalLabel}}</span><span>{{totalGross}}</span></div>
</div>
</div>
<div class="footer">{{footerText}}</div>
</section>
</div>
</body>
</html>

View File

@ -0,0 +1,373 @@
<!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{documentTitle}} {{invoiceNumber}}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
background: #f4f6fb;
color: #1f2937;
font-size: 13px;
line-height: 1.5;
}
.page {
max-width: 860px;
margin: 28px auto;
background: #ffffff;
border: 1px solid #dbe3f0;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 24px 60px -38px rgba(15, 23, 42, 0.35);
}
.hero {
padding: 34px 38px 28px;
background: linear-gradient(135deg, #102347 0%, #173b73 52%, #2563eb 100%);
color: #ffffff;
}
.hero-grid {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
}
.hero-brand {
display: grid;
gap: 14px;
min-width: 0;
}
.hero-logo:empty {
display: none;
}
.hero-logo img {
display: block;
max-height: 72px;
max-width: 220px;
object-fit: contain;
}
.hero h1 {
margin: 0;
font-size: 31px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero p {
margin: 8px 0 0;
max-width: 420px;
color: rgba(255, 255, 255, 0.82);
}
.invoice-card {
min-width: 220px;
padding: 18px 20px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
text-align: right;
}
.invoice-card .eyebrow {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: rgba(255, 255, 255, 0.72);
}
.invoice-card .number {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
}
.content {
padding: 30px 38px 36px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-bottom: 24px;
}
.info-card {
padding: 18px;
border: 1px solid #dbe3f0;
border-radius: 18px;
background: #f8fafc;
}
.info-card h2 {
margin: 0 0 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #64748b;
}
.info-card p {
margin: 0;
}
.highlight {
font-weight: 700;
color: #0f172a;
}
.meta-lines {
display: grid;
gap: 6px;
}
.meta-line {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
min-width: 0;
}
.meta-label {
color: #64748b;
flex: 1 1 auto;
min-width: 0;
padding-right: 12px;
}
.meta-line .highlight,
.meta-line .status-pill {
flex: 0 0 auto;
text-align: right;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.tax-banner {
margin-bottom: 22px;
padding: 15px 18px;
border: 1px solid #c7d2fe;
border-radius: 18px;
background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 100%);
}
.tax-banner-title {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #4f46e5;
font-weight: 700;
}
.tax-banner p {
margin: 0;
color: #334155;
}
.items-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 18px;
border: 1px solid #dbe3f0;
margin-bottom: 24px;
}
.items-table thead th {
padding: 12px 14px;
background: #e2e8f0;
color: #334155;
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.items-table thead th:nth-child(3),
.items-table thead th:nth-child(4),
.items-table thead th:nth-child(5),
.items-table tbody td:nth-child(3),
.items-table tbody td:nth-child(4),
.items-table tbody td:nth-child(5) {
text-align: right;
}
.items-table tbody td {
padding: 12px 14px;
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
.summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr);
gap: 20px;
align-items: start;
}
.payment-card,
.totals-card {
padding: 20px;
border: 1px solid #dbe3f0;
border-radius: 18px;
background: #f8fafc;
}
.payment-card h3,
.totals-card h3 {
margin: 0 0 12px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #64748b;
}
.payment-card p {
margin: 0 0 12px;
color: #334155;
}
.bank-list {
margin-top: 14px;
display: grid;
gap: 8px;
}
.bank-row {
display: flex;
justify-content: space-between;
gap: 16px;
}
.bank-label {
color: #64748b;
}
.bank-value {
text-align: right;
color: #0f172a;
font-weight: 600;
}
.totals-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 7px 0;
color: #475569;
}
.totals-row strong {
color: #0f172a;
}
.totals-row.total {
margin-top: 8px;
padding-top: 12px;
border-top: 2px solid #cbd5e1;
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.footer {
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid #e2e8f0;
color: #64748b;
font-size: 11px;
text-align: center;
line-height: 1.6;
}
@media (max-width: 760px) {
.hero,
.content {
padding-left: 22px;
padding-right: 22px;
}
.hero-grid,
.summary-grid,
.info-grid {
grid-template-columns: 1fr;
display: grid;
}
.invoice-card {
text-align: left;
}
}
</style>
</head>
<body>
<div class="page">
<section class="hero">
<div class="hero-grid">
<div class="hero-brand">
<div class="hero-logo">{{companyLogo}}</div>
<h1>{{documentTitle}}</h1>
</div>
<div class="invoice-card">
<div class="eyebrow">{{invoiceNumberLabel}}</div>
<div class="number">{{invoiceNumber}}</div>
</div>
</div>
</section>
<section class="content">
<div class="info-grid">
<div class="info-card">
<h2>{{fromLabel}}</h2>
<p>
<span class="highlight">{{companyName}}</span><br>
{{companyStreet}}<br>
{{companyPostalCity}}<br>
{{companyCountry}}
</p>
</div>
<div class="info-card">
<h2>{{toLabel}}</h2>
<p>
<span class="highlight">{{customerName}}</span><br>
{{customerEmail}}<br>
{{customerStreet}}<br>
{{customerPostalCity}}<br>
{{customerCountry}}
</p>
</div>
<div class="info-card">
<h2>{{detailsLabel}}</h2>
<div class="meta-lines">
<div class="meta-line"><span class="meta-label">{{dateLabel}}</span><span class="highlight">{{issuedAt}}</span></div>
<div class="meta-line"><span class="meta-label">{{dueDateLabel}}</span><span class="highlight">{{dueAt}}</span></div>
<div class="meta-line"><span class="meta-label">{{statusLabel}}</span><span class="status-pill">{{invoiceStatus}}</span></div>
</div>
</div>
</div>
{{orderedByBlock}}
<div class="tax-banner">
<div class="tax-banner-title">Tax treatment</div>
<p><strong>{{taxLabel}}</strong> ({{vatRateDisplay}})</p>
</div>
<table class="items-table">
<thead>
<tr>
<th>#</th>
<th>{{descriptionHeader}}</th>
<th>{{qtyHeader}}</th>
<th>{{unitPriceHeader}}</th>
<th>{{totalHeader}}</th>
</tr>
</thead>
<tbody>
{{itemsRows}}
</tbody>
</table>
<div class="summary-grid">
<div class="payment-card">
<h3>{{paymentInfoTitle}}</h3>
<p>{{paymentInfoText}}</p>
<div class="bank-list">
<div class="bank-row"><span class="bank-label">Account holder</span><span class="bank-value">{{bankAccountHolder}}</span></div>
<div class="bank-row"><span class="bank-label">IBAN</span><span class="bank-value">{{bankIban}}</span></div>
<div class="bank-row"><span class="bank-label">BIC</span><span class="bank-value">{{bankBic}}</span></div>
</div>
</div>
<div class="totals-card">
<h3>Summary</h3>
<div class="totals-row"><span>{{subtotalLabel}}</span><strong>{{totalNet}}</strong></div>
<div class="totals-row"><span>{{taxLabel}} ({{vatRateDisplay}})</span><strong>{{totalTax}}</strong></div>
<div class="totals-row total"><span>{{totalLabel}}</span><span>{{totalGross}}</span></div>
</div>
</div>
<div class="footer">{{footerText}}</div>
</section>
</div>
</body>
</html>