feature+refactor/fewThingsIGuess #24
@ -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', {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(', ')}`);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
373
templates/invoice/invoice_company_DE.html
Normal file
373
templates/invoice/invoice_company_DE.html
Normal 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>
|
||||
373
templates/invoice/invoice_company_EN.html
Normal file
373
templates/invoice/invoice_company_EN.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user