dev #23
@ -1,111 +1,62 @@
|
|||||||
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
|
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const repo = new CompanySettingsRepository();
|
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 {
|
function normalizeBase64ImageInput(rawValue, rawMimeType) {
|
||||||
static _normalizeBase64ImageInput(value) {
|
if (rawValue === undefined && rawMimeType === undefined) {
|
||||||
if (value === undefined) return undefined;
|
return { provided: false };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
static async get(req, res) {
|
||||||
try {
|
try {
|
||||||
const settings = await repo.get();
|
const settings = await repo.get();
|
||||||
@ -114,8 +65,8 @@ class CompanySettingsController {
|
|||||||
company_street: '',
|
company_street: '',
|
||||||
company_postal_city: '',
|
company_postal_city: '',
|
||||||
company_country: '',
|
company_country: '',
|
||||||
qr_code_60_base64: null,
|
company_logo_base64: null,
|
||||||
qr_code_120_base64: null,
|
company_logo_mime_type: null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({ message: 'Failed to load company settings' });
|
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
|
// Accept both snake_case and camelCase
|
||||||
const payload = {
|
const payload = {
|
||||||
company_name: body.company_name ?? body.companyName,
|
company_name: body.company_name ?? body.companyName,
|
||||||
company_street: body.company_street ?? body.companyStreet,
|
company_street: body.company_street ?? body.companyStreet,
|
||||||
company_postal_city: body.company_postal_city ?? body.companyPostalCity,
|
company_postal_city: body.company_postal_city ?? body.companyPostalCity,
|
||||||
company_country: body.company_country ?? body.companyCountry,
|
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)
|
// Only forward keys that were actually provided (so we don't wipe values on partial updates)
|
||||||
const provided = {};
|
const provided = {};
|
||||||
@ -206,52 +119,8 @@ class CompanySettingsController {
|
|||||||
if (value !== undefined) provided[key] = value;
|
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);
|
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);
|
return res.json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('companySettings:update:failed', {
|
logger.error('companySettings:update:failed', {
|
||||||
|
|||||||
@ -36,8 +36,8 @@ function saveDebugFile(filename, data, encoding = 'utf8') {
|
|||||||
|
|
||||||
// Helper to remove/empty placeholders except allow-list
|
// Helper to remove/empty placeholders except allow-list
|
||||||
// Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized.
|
// 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').
|
// allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'companyLogo', 'profitplanetSignature').
|
||||||
function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','profitplanetSignature']) {
|
function sanitizePlaceholders(html, allowList = ['currentDate','companyStamp','companyStampInline','companyStampSmall','companyLogo','profitplanetSignature']) {
|
||||||
if (!html || typeof html !== 'string') return html;
|
if (!html || typeof html !== 'string') return html;
|
||||||
const allowSet = new Set((allowList || []).map(s => String(s).trim()).filter(Boolean));
|
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 = 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 } };
|
const reqForStamp = (req && req.user) ? req : { user: { id: targetUserId, user_type: userType } };
|
||||||
try { html = await applyCompanyStampPlaceholders(html, reqForStamp); } catch (e) {}
|
try { html = await applyCompanyStampPlaceholders(html, reqForStamp); } catch (e) {}
|
||||||
@ -2101,7 +2101,7 @@ exports.downloadPdf = async (req, res) => {
|
|||||||
|
|
||||||
// SANITIZE: remove variables for downloaded PDF.
|
// SANITIZE: remove variables for downloaded PDF.
|
||||||
// Allow only stamp/signature placeholders to remain so images can still be injected.
|
// 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)
|
// Do NOT keep currentDate for download (user requested variables emptied)
|
||||||
|
|
||||||
// Apply company stamp & profitplanet signature (these placeholders were preserved above)
|
// 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) {
|
async pay(req, res) {
|
||||||
try {
|
try {
|
||||||
const data = await service.markPaid(req.params.id, {
|
const data = await service.markPaid(req.params.id, {
|
||||||
|
|||||||
@ -94,6 +94,13 @@ async function addColumnIfMissing(conn, table, column, ddlFragment /* includes t
|
|||||||
return true;
|
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) {
|
async function constraintExists(conn, table, constraintName) {
|
||||||
const [rows] = await conn.query(
|
const [rows] = await conn.query(
|
||||||
`SELECT 1
|
`SELECT 1
|
||||||
@ -837,8 +844,8 @@ const createDatabase = async () => {
|
|||||||
company_street VARCHAR(255) NOT NULL DEFAULT '',
|
company_street VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
company_postal_city VARCHAR(255) NOT NULL DEFAULT '',
|
company_postal_city VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
company_country VARCHAR(100) NOT NULL DEFAULT 'Austria',
|
company_country VARCHAR(100) NOT NULL DEFAULT 'Austria',
|
||||||
qr_code_60_base64 LONGTEXT NULL,
|
company_logo_base64 MEDIUMTEXT NULL,
|
||||||
qr_code_120_base64 LONGTEXT NULL,
|
company_logo_mime_type VARCHAR(100) NULL,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CHECK (id = 1)
|
CHECK (id = 1)
|
||||||
);
|
);
|
||||||
@ -847,11 +854,13 @@ const createDatabase = async () => {
|
|||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT IGNORE INTO company_settings (id) VALUES (1);
|
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');
|
console.log('✅ Company settings table created/verified');
|
||||||
|
|
||||||
// Backward-compatible: add QR code columns if missing
|
// Cleanup legacy invoice QR columns, which are no longer used.
|
||||||
await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL');
|
await dropColumnIfExists(connection, 'company_settings', 'qr_code_60_base64');
|
||||||
await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL');
|
await dropColumnIfExists(connection, 'company_settings', 'qr_code_120_base64');
|
||||||
|
|
||||||
// --- I18n Preferences (single-row, admin language-management settings) ---
|
// --- I18n Preferences (single-row, admin language-management settings) ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
@ -1302,7 +1311,7 @@ const createDatabase = async () => {
|
|||||||
total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
vat_rate DECIMAL(6,3) NULL,
|
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,
|
issued_at DATETIME NULL,
|
||||||
due_at DATETIME NULL,
|
due_at DATETIME NULL,
|
||||||
pdf_storage_key VARCHAR(255) NULL,
|
pdf_storage_key VARCHAR(255) NULL,
|
||||||
@ -1317,6 +1326,16 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ Invoices table created/verified');
|
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(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS invoice_items (
|
CREATE TABLE IF NOT EXISTS invoice_items (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|||||||
@ -206,8 +206,35 @@ class InvoiceRepository {
|
|||||||
return rows.map((r) => new Invoice(r));
|
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) {
|
async updateStatus(invoiceId, newStatus) {
|
||||||
const allowed = ['draft', 'issued', 'paid', 'canceled'];
|
const allowed = ['draft', 'issued', 'paid', 'overdue', 'canceled'];
|
||||||
if (!allowed.includes(newStatus)) {
|
if (!allowed.includes(newStatus)) {
|
||||||
throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`);
|
throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,12 @@ const pool = require('../../database/database');
|
|||||||
|
|
||||||
class CompanySettingsRepository {
|
class CompanySettingsRepository {
|
||||||
async get() {
|
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;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,8 +16,8 @@ class CompanySettingsRepository {
|
|||||||
company_street,
|
company_street,
|
||||||
company_postal_city,
|
company_postal_city,
|
||||||
company_country,
|
company_country,
|
||||||
qr_code_60_base64,
|
company_logo_base64,
|
||||||
qr_code_120_base64,
|
company_logo_mime_type,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
const next = {
|
const next = {
|
||||||
@ -20,27 +25,30 @@ class CompanySettingsRepository {
|
|||||||
company_street: company_street !== undefined ? company_street : (current?.company_street ?? ''),
|
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_postal_city: company_postal_city !== undefined ? company_postal_city : (current?.company_postal_city ?? ''),
|
||||||
company_country: company_country !== undefined ? company_country : (current?.company_country ?? ''),
|
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),
|
company_logo_base64: company_logo_base64 !== undefined ? (company_logo_base64 || null) : (current?.company_logo_base64 ?? null),
|
||||||
qr_code_120_base64: qr_code_120_base64 !== undefined ? qr_code_120_base64 : (current?.qr_code_120_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(
|
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, ?, ?, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
company_name = VALUES(company_name),
|
company_name = VALUES(company_name),
|
||||||
company_street = VALUES(company_street),
|
company_street = VALUES(company_street),
|
||||||
company_postal_city = VALUES(company_postal_city),
|
company_postal_city = VALUES(company_postal_city),
|
||||||
company_country = VALUES(company_country),
|
company_country = VALUES(company_country),
|
||||||
qr_code_60_base64 = VALUES(qr_code_60_base64),
|
company_logo_base64 = VALUES(company_logo_base64),
|
||||||
qr_code_120_base64 = VALUES(qr_code_120_base64)`,
|
company_logo_mime_type = VALUES(company_logo_mime_type)`,
|
||||||
[
|
[
|
||||||
next.company_name || '',
|
next.company_name || '',
|
||||||
next.company_street || '',
|
next.company_street || '',
|
||||||
next.company_postal_city || '',
|
next.company_postal_city || '',
|
||||||
next.company_country || '',
|
next.company_country || '',
|
||||||
next.qr_code_60_base64 ?? null,
|
next.company_logo_base64,
|
||||||
next.qr_code_120_base64 ?? null,
|
next.company_logo_mime_type,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return this.get();
|
return this.get();
|
||||||
|
|||||||
@ -204,6 +204,7 @@ router.get('/news/:slug', NewsController.getPublic);
|
|||||||
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
|
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
|
||||||
router.get('/invoices/:id/pdf', authMiddleware, InvoiceController.downloadPdf);
|
router.get('/invoices/:id/pdf', authMiddleware, InvoiceController.downloadPdf);
|
||||||
router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList);
|
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);
|
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.
|
// 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');
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
async function createAdminUser() {
|
async function createAdminUser() {
|
||||||
|
return;
|
||||||
// const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com';
|
// 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 || 'alexander.ibrahim.ai@gmail.com';
|
||||||
// const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@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)}`;
|
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) {
|
_prepareVariablesForTemplate(templateHtml, variables) {
|
||||||
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
|
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
|
||||||
// by injecting the Profit Planet bank block into paymentInfoText.
|
// by injecting the Profit Planet bank block into paymentInfoText.
|
||||||
@ -328,12 +353,14 @@ class InvoiceService {
|
|||||||
bankBic,
|
bankBic,
|
||||||
].join('<br>');
|
].join('<br>');
|
||||||
|
|
||||||
// Hardcoded company address (Profit Planet)
|
const storedCompanyInfo = await this._loadCompanyInfo();
|
||||||
const companyInfo = {
|
const companyInfo = {
|
||||||
company_name: 'Profit Planet GmbH',
|
company_name: storedCompanyInfo.company_name || 'Profit Planet GmbH',
|
||||||
company_street: 'Kärntner Straße 227',
|
company_street: storedCompanyInfo.company_street || 'Kärntner Straße 227',
|
||||||
company_postal_city: '8053 Graz',
|
company_postal_city: storedCompanyInfo.company_postal_city || '8053 Graz',
|
||||||
company_country: '',
|
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
|
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
|
||||||
@ -378,6 +405,7 @@ class InvoiceService {
|
|||||||
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
|
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
|
||||||
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
|
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
|
||||||
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
|
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
|
||||||
|
companyLogo: this._buildCompanyLogoTag(companyInfo),
|
||||||
customerName: this._escapeHtml(customerName),
|
customerName: this._escapeHtml(customerName),
|
||||||
customerEmail: this._escapeHtml(customerEmail),
|
customerEmail: this._escapeHtml(customerEmail),
|
||||||
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
|
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
|
||||||
@ -763,18 +791,34 @@ class InvoiceService {
|
|||||||
return paidInvoice;
|
return paidInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncOverdueStatuses() {
|
||||||
|
return this.repo.markIssuedPastDueAsOverdue();
|
||||||
|
}
|
||||||
|
|
||||||
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
|
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
|
||||||
|
await this.syncOverdueStatuses();
|
||||||
return this.repo.listByUser(userId, { status, limit, offset });
|
return this.repo.listByUser(userId, { status, limit, offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listByAbonement(abonementId) {
|
async listByAbonement(abonementId) {
|
||||||
|
await this.syncOverdueStatuses();
|
||||||
return this.repo.findByAbonement(abonementId);
|
return this.repo.findByAbonement(abonementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminList({ status, limit = 200, offset = 0 } = {}) {
|
async adminList({ status, limit = 200, offset = 0 } = {}) {
|
||||||
|
await this.syncOverdueStatuses();
|
||||||
return this.repo.listAll({ status, limit, offset });
|
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) {
|
async updateStatus(invoiceId, newStatus) {
|
||||||
const invoice = await this.repo.getById(invoiceId);
|
const invoice = await this.repo.getById(invoiceId);
|
||||||
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
||||||
@ -792,6 +836,7 @@ class InvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getInvoiceDetail(invoiceId) {
|
async getInvoiceDetail(invoiceId) {
|
||||||
|
await this.syncOverdueStatuses();
|
||||||
const invoice = await this.repo.getById(invoiceId);
|
const invoice = await this.repo.getById(invoiceId);
|
||||||
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
||||||
const items = await this.repo.getItemsByInvoiceId(invoiceId);
|
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