fmlfmlflmflm

This commit is contained in:
DeathKaioken 2026-03-16 20:03:54 +01:00
parent 46a081ae8f
commit 914e8bd528
15 changed files with 556 additions and 40 deletions

BIN
QRCode/qr_120.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
QRCode/qr_60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -1,12 +1,122 @@
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
const { logger } = require('../../middleware/logger');
const crypto = require('crypto');
const repo = new CompanySettingsRepository(); const repo = new CompanySettingsRepository();
class CompanySettingsController { class CompanySettingsController {
static _normalizeBase64ImageInput(value) {
if (value === undefined) return undefined;
if (value === null) return null;
const stripDataUri = (str) => {
const s = String(str || '').trim();
if (!s) return '';
const m = s.match(/^data:([^;]+);base64,(.*)$/i);
return m ? (m[2] || '').trim() : s;
};
if (typeof value === 'string') {
// If base64 came via urlencoded forms, '+' often becomes ' ' after decoding.
// Restoring spaces back to '+' makes the payload usable again.
let normalized = stripDataUri(value);
if (normalized.includes(' ') && !normalized.includes('+')) {
normalized = normalized.replace(/ /g, '+');
}
normalized = normalized.replace(/\s+/g, '');
return normalized || null;
}
if (typeof value === 'object') {
// Common Node.js JSON shape for Buffers: { type: 'Buffer', data: [..bytes..] }
if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
try {
const buf = Buffer.from(value.data);
const asBase64 = buf.toString('base64');
return asBase64 || null;
} catch (_) {
return undefined;
}
}
// Common frontend shapes:
// { kind: 'base64', base64: '...' }
// { kind: 'base64', data: '...' }
// { dataUrl: 'data:image/png;base64,...' }
// { value: '...' }
const candidates = [
value.base64,
value.data,
value.value,
value.content,
value.full,
value.raw,
value.payload,
value.src,
value.b64,
value.base64String,
value.dataUrl,
value.dataURL,
value.data_uri,
value.dataURI,
value.uri,
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) {
let normalized = stripDataUri(c);
if (normalized.includes(' ') && !normalized.includes('+')) {
normalized = normalized.replace(/ /g, '+');
}
normalized = normalized.replace(/\s+/g, '');
return normalized || null;
}
}
// Fallback: scan shallow object for a base64-ish string
const looksLikeImageBase64 = (str) => {
const s = String(str || '').trim();
return s.startsWith('data:image/') || s.startsWith('iVBORw0KGgo') || s.startsWith('/9j/') || s.startsWith('R0lGOD');
};
for (const v of Object.values(value)) {
if (typeof v === 'string' && looksLikeImageBase64(v)) {
let normalized = stripDataUri(v);
if (normalized.includes(' ') && !normalized.includes('+')) {
normalized = normalized.replace(/ /g, '+');
}
normalized = normalized.replace(/\s+/g, '');
return normalized || null;
}
if (v && typeof v === 'object') {
for (const vv of Object.values(v)) {
if (typeof vv === 'string' && looksLikeImageBase64(vv)) {
let normalized = stripDataUri(vv);
if (normalized.includes(' ') && !normalized.includes('+')) {
normalized = normalized.replace(/ /g, '+');
}
normalized = normalized.replace(/\s+/g, '');
return normalized || null;
}
}
}
}
}
// Unknown shape; ignore instead of persisting garbage
return undefined;
}
static async get(req, res) { static async get(req, res) {
try { try {
const settings = await repo.get(); const settings = await repo.get();
return res.json(settings || { company_name: '', company_street: '', company_postal_city: '', company_country: '' }); return res.json(settings || {
company_name: '',
company_street: '',
company_postal_city: '',
company_country: '',
qr_code_60_base64: null,
qr_code_120_base64: 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' });
} }
@ -14,10 +124,141 @@ class CompanySettingsController {
static async update(req, res) { static async update(req, res) {
try { try {
const { company_name, company_street, company_postal_city, company_country } = req.body; const body = req.body || {};
const updated = await repo.update({ company_name, company_street, company_postal_city, company_country });
const requestId = req.id;
const contentType = (req.get('content-type') || '').toLowerCase();
const contentLength = req.get('content-length') || null;
if (contentType.includes('multipart/form-data')) {
logger.warn('companySettings:update:multipart_not_supported', {
requestId,
contentType: req.get('content-type') || null,
hint: 'Send JSON (application/json) or add multer middleware for this route.'
});
}
if (Object.keys(body).length === 0 && contentLength && Number(contentLength) > 0) {
logger.warn('companySettings:update:empty_parsed_body', {
requestId,
contentType: req.get('content-type') || null,
contentLength,
hint: 'Body parser may not match the request content-type or size limit.'
});
}
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),
};
// Only forward keys that were actually provided (so we don't wipe values on partial updates)
const provided = {};
for (const [key, value] of Object.entries(payload)) {
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); return res.json(updated);
} catch (err) { } catch (err) {
logger.error('companySettings:update:failed', {
requestId: req && req.id,
message: err?.message,
stack: err?.stack,
});
return res.status(500).json({ message: 'Failed to update company settings' }); return res.status(500).json({ message: 'Failed to update company settings' });
} }
} }

View File

@ -22,7 +22,7 @@ class EmailVerificationController {
res.json({ success: true, message: 'Verification email sent' }); res.json({ success: true, message: 'Verification email sent' });
} catch (error) { } catch (error) {
await unitOfWork.rollback(error); await unitOfWork.rollback(error);
logger.error('Error sending verification email:', error); logger.error('Error sending verification email', { message: error?.message, stack: error?.stack });
res.status(400).json({ success: false, message: error.message }); res.status(400).json({ success: false, message: error.message });
} }
} }
@ -44,7 +44,7 @@ class EmailVerificationController {
} }
} catch (error) { } catch (error) {
await unitOfWork.rollback(error); await unitOfWork.rollback(error);
logger.error('Error verifying email code:', error); logger.error('Error verifying email code', { message: error?.message, stack: error?.stack });
res.status(400).json({ success: false, error: error.message }); res.status(400).json({ success: false, error: error.message });
} }
} }

View File

@ -125,7 +125,10 @@ class LoginController {
lang: lang // <-- pass lang from request body lang: lang // <-- pass lang from request body
}); });
} catch (mailError) { } catch (mailError) {
logger.error('Error sending login notification email', { error: mailError }); logger.error('Error sending login notification email', {
message: mailError?.message,
stack: mailError?.stack,
});
// Do not block login // Do not block login
} }

View File

@ -72,7 +72,7 @@ module.exports = {
await uow.commit(); await uow.commit();
} catch (err) { } catch (err) {
await uow.rollback(); await uow.rollback();
logger.error('passwordReset:request_error', { error: err }); logger.error('passwordReset:request_error', { message: err?.message, stack: err?.stack });
} }
return res.json({ return res.json({
success: true, success: true,

View File

@ -810,6 +810,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 'Germany', company_country VARCHAR(100) NOT NULL DEFAULT 'Germany',
qr_code_60_base64 LONGTEXT NULL,
qr_code_120_base64 LONGTEXT 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)
); );
@ -820,6 +822,10 @@ const createDatabase = async () => {
`); `);
console.log('✅ Company settings table created/verified'); 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');
// --- Dashboard Platforms (admin managed dashboard cards) --- // --- Dashboard Platforms (admin managed dashboard cards) ---
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS dashboard_plattforms ( CREATE TABLE IF NOT EXISTS dashboard_plattforms (

View File

@ -6,16 +6,42 @@ class CompanySettingsRepository {
return rows[0] || null; return rows[0] || null;
} }
async update({ company_name, company_street, company_postal_city, company_country }) { async update({
company_name,
company_street,
company_postal_city,
company_country,
qr_code_60_base64,
qr_code_120_base64,
} = {}) {
const current = await this.get();
const next = {
company_name: company_name !== undefined ? company_name : (current?.company_name ?? ''),
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),
};
await pool.query( await pool.query(
`INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country) `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country, qr_code_60_base64, qr_code_120_base64)
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),
[company_name || '', company_street || '', company_postal_city || '', company_country || ''] qr_code_60_base64 = VALUES(qr_code_60_base64),
qr_code_120_base64 = VALUES(qr_code_120_base64)`,
[
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,
]
); );
return this.get(); return this.get();
} }

View File

@ -19,6 +19,9 @@ const RenewalCronService = require('./services/abonemments/RenewalCronService');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Increase body size limits for base64 payloads (e.g. QR code images)
const JSON_LIMIT = process.env.JSON_LIMIT || '2mb';
// CORS configuration // CORS configuration
const ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS) const ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS)
.split(',') .split(',')
@ -41,7 +44,8 @@ const corsOptions = {
app.use(cors(corsOptions)); app.use(cors(corsOptions));
// Middleware // Middleware
app.use(express.json()); app.use(express.json({ limit: JSON_LIMIT }));
app.use(express.urlencoded({ extended: true, limit: JSON_LIMIT }));
app.use(cookieParser()); app.use(cookieParser());
// -- Replace inline console logging with structured request logger -- // -- Replace inline console logging with structured request logger --

View File

@ -8,12 +8,114 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const { logger } = require('../../middleware/logger'); const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('path');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
class InvoiceService { class InvoiceService {
constructor() { constructor() {
this.repo = new InvoiceRepository(); this.repo = new InvoiceRepository();
this._qrDataUriCache = new Map();
}
_inferImageMimeFromBase64(base64) {
const s = String(base64 || '').trim();
if (!s) return 'image/png';
if (s.startsWith('iVBORw0KGgo')) return 'image/png';
if (s.startsWith('/9j/')) return 'image/jpeg';
if (s.startsWith('R0lGOD')) return 'image/gif';
return 'image/png';
}
_templateHasVars(template, varNames) {
if (!template) return false;
return varNames.every((name) => {
const re = new RegExp(`{{\\s*${name}\\s*}}`);
return re.test(template);
});
}
async _loadLocalInvoiceTemplateHtml() {
try {
const filePath = path.resolve(__dirname, '../../templates/invoice/invoiceTemplate.html');
return await fs.readFile(filePath, 'utf8');
} catch (e) {
logger.warn('InvoiceService._loadLocalInvoiceTemplateHtml:error', { message: e?.message });
return null;
}
}
_resolvePieceCountForQr(abonement) {
const packGroup = String(abonement?.pack_group || '').toLowerCase();
if (packGroup.includes('120')) return 120;
if (packGroup.includes('60')) return 60;
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
const totalPacks = breakdown.reduce((sum, item) => sum + Number(item?.packs || 0), 0);
const piecesByPack = totalPacks ? totalPacks * 10 : null;
if (piecesByPack === 60 || piecesByPack === 120) return piecesByPack;
return null;
}
_getLocalQrImagePath(pieceCount) {
const safePieceCount = pieceCount === 120 ? 120 : 60;
const fileName = safePieceCount === 120 ? 'qr_120.png' : 'qr_60.png';
return path.resolve(__dirname, '../../templates/invoice/qr', fileName);
}
async _getCompanySettingsQrDataUri(pieceCount) {
const safePieceCount = pieceCount === 120 ? 120 : 60;
try {
const repo = new CompanySettingsRepository();
const row = await repo.get();
const raw = safePieceCount === 120 ? row?.qr_code_120_base64 : row?.qr_code_60_base64;
const value = (raw == null) ? '' : String(raw).trim();
if (!value) return null;
if (value.startsWith('data:image/')) return value;
const mime = this._inferImageMimeFromBase64(value);
return `data:${mime};base64,${value}`;
} catch (e) {
logger.warn('InvoiceService._getCompanySettingsQrDataUri:error', {
pieceCount: safePieceCount,
message: e?.message,
});
return null;
}
}
async _getLocalQrDataUri(pieceCount) {
const safePieceCount = pieceCount === 120 ? 120 : 60;
if (this._qrDataUriCache.has(safePieceCount)) {
return this._qrDataUriCache.get(safePieceCount);
}
const filePath = this._getLocalQrImagePath(safePieceCount);
try {
const buffer = await fs.readFile(filePath);
const dataUri = `data:image/png;base64,${buffer.toString('base64')}`;
this._qrDataUriCache.set(safePieceCount, dataUri);
return dataUri;
} catch (e) {
logger.warn('InvoiceService._getLocalQrDataUri:missing_qr_file', {
pieceCount: safePieceCount,
filePath,
message: e?.message,
});
return null;
}
}
async _buildQrCodeImageTag({ abonement }) {
const pieceCount = this._resolvePieceCountForQr(abonement);
if (!pieceCount) return '';
const dataUri = await this._getCompanySettingsQrDataUri(pieceCount) || await this._getLocalQrDataUri(pieceCount);
if (!dataUri) return '';
return `<img alt="QR Code" src="${this._escapeHtml(dataUri)}" />`;
} }
_escapeHtml(value) { _escapeHtml(value) {
@ -175,13 +277,47 @@ class InvoiceService {
Key: selected.storageKey, Key: selected.storageKey,
}); });
const obj = await sharedExoscaleClient.send(command); const obj = await sharedExoscaleClient.send(command);
return await this._s3BodyToString(obj.Body) || null; const html = await this._s3BodyToString(obj.Body) || null;
if (!html) return null;
return html;
} catch (e) { } catch (e) {
logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message }); logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message });
return null; return null;
} }
} }
_getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) {
return `<strong>${this._escapeHtml(bankAccountHolder)}</strong><br>${this._escapeHtml(bankIban)}<br>${this._escapeHtml(bankBic)}`;
}
_prepareVariablesForTemplate(templateHtml, variables) {
// Ensure backwards compatibility with older templates that only contain {{paymentInfoText}}
// by injecting the Profit Planet bank block (and optionally QR) into paymentInfoText.
if (!templateHtml) return variables;
const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
const bankBlock = this._getProfitPlanetBankBlockHtml({
bankAccountHolder: variables.bankAccountHolder || 'Profit Planet GmbH',
bankIban: variables.bankIban || '',
bankBic: variables.bankBic || '',
});
const next = { ...variables };
if (!supportsBankVars) {
// Replace the default instruction text entirely with bank info
next.paymentInfoText = bankBlock;
}
if (!supportsQrVar && variables.qrCodeImage) {
// Append QR under payment info text when there's no dedicated placeholder
next.paymentInfoText = `${next.paymentInfoText || ''}<br><br>${variables.qrCodeImage}`;
}
return next;
}
async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) { async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) {
const isDe = lang === 'de'; const isDe = lang === 'de';
const isGift = abonement?.details?.is_for_self === false; const isGift = abonement?.details?.is_for_self === false;
@ -189,15 +325,33 @@ class InvoiceService {
const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-'; const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-';
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0; const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
// Load company info from DB // Hardcoded bank info (Profit Planet)
let companyInfo = { company_name: 'ProfitPlanet GmbH', company_street: '', company_postal_city: '', company_country: 'Germany' }; const bankAccountHolder = 'Profit Planet GmbH';
try { const bankIban = 'AT16 2081 5000 4639 9507';
const repo = new CompanySettingsRepository(); const bankBic = 'STSPAT2GXXX';
const row = await repo.get();
if (row) companyInfo = row; // Hardcoded footer/contact info (Profit Planet)
} catch (e) { const footerText = [
logger.warn('InvoiceService._buildInvoiceTemplateVariables:company_settings_error', { message: e?.message }); 'Profit Planet GmbH',
} 'Kärntner Straße 227',
'8053 Graz',
'',
'Kontakt',
'Telefon: 0676 344 0274',
'E-Mail: office@profit-planet.com',
'',
'Profit Planet GmbH',
bankIban,
bankBic,
].join('<br>');
// Hardcoded company address (Profit Planet)
const companyInfo = {
company_name: 'Profit Planet GmbH',
company_street: 'Kärntner Straße 227',
company_postal_city: '8053 Graz',
company_country: '',
};
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser // For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
// For self subscriptions: "Bill To" = the subscriber // For self subscriptions: "Bill To" = the subscriber
@ -223,6 +377,8 @@ class InvoiceService {
customerEmail = abonement?.email || invoice.buyer_email || ''; customerEmail = abonement?.email || invoice.buyer_email || '';
} }
const qrCodeImage = await this._buildQrCodeImageTag({ abonement });
return { return {
lang: isDe ? 'de' : 'en', lang: isDe ? 'de' : 'en',
documentTitle: isDe ? 'Rechnung' : 'Invoice', documentTitle: isDe ? 'Rechnung' : 'Invoice',
@ -263,9 +419,11 @@ class InvoiceService {
paymentInfoText: isDe paymentInfoText: isDe
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.' ? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
: 'Please transfer the total amount stating the invoice number as reference.', : 'Please transfer the total amount stating the invoice number as reference.',
footerText: isDe bankAccountHolder: this._escapeHtml(bankAccountHolder),
? 'Vielen Dank für Ihr Vertrauen.' bankIban: this._escapeHtml(bankIban),
: 'Thank you for your business.', bankBic: this._escapeHtml(bankBic),
qrCodeImage,
footerText,
// Legacy key used by S3-stored templates // Legacy key used by S3-stored templates
itemsHtml: this._buildItemsHtml(items, invoice.currency), itemsHtml: this._buildItemsHtml(items, invoice.currency),
}; };
@ -276,7 +434,8 @@ class InvoiceService {
const template = await this._loadInvoiceHtmlTemplate(); const template = await this._loadInvoiceHtmlTemplate();
if (template) { if (template) {
return this._renderTemplate(template, variables); const varsForTemplate = this._prepareVariablesForTemplate(template, variables);
return this._renderTemplate(template, varsForTemplate);
} }
// Absolute fallback if template file is missing // Absolute fallback if template file is missing
@ -309,10 +468,11 @@ class InvoiceService {
}); });
const obj = await sharedExoscaleClient.send(command); const obj = await sharedExoscaleClient.send(command);
const html = await this._s3BodyToString(obj.Body); const html = await this._s3BodyToString(obj.Body);
return html || null; if (!html) return await this._loadLocalInvoiceTemplateHtml();
return html;
} catch (error) { } catch (error) {
logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message }); logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message });
return null; return await this._loadLocalInvoiceTemplateHtml();
} }
} }
@ -372,7 +532,30 @@ class InvoiceService {
let html = null; let html = null;
if (templateHtml) { if (templateHtml) {
html = this._renderTemplate(templateHtml, variables); const supportsBankVars = this._templateHasVars(templateHtml, ['bankAccountHolder', 'bankIban', 'bankBic']);
const supportsQrVar = this._templateHasVars(templateHtml, ['qrCodeImage']);
const pieceCountForQr = this._resolvePieceCountForQr(abonement);
logger.info('InvoiceService._sendInvoiceEmail:template_compat', {
invoiceId: invoice?.id,
lang,
supportsBankVars,
supportsQrVar,
pieceCountForQr,
hasQrImage: Boolean(variables?.qrCodeImage),
});
const varsForTemplate = this._prepareVariablesForTemplate(templateHtml, variables);
html = this._renderTemplate(templateHtml, varsForTemplate);
// Final guard: if we still didn't embed QR but we expected one, force local template
const missingQr = variables.qrCodeImage && !html.includes('data:image/png;base64,');
if (missingQr) {
const localTemplate = await this._loadLocalInvoiceTemplateHtml();
if (localTemplate) {
const varsForLocal = this._prepareVariablesForTemplate(localTemplate, variables);
html = this._renderTemplate(localTemplate, varsForLocal);
}
}
} }
const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });

View File

@ -536,8 +536,10 @@
<p>(c) fremde Produkte auf den gestellten Kaffeemaschinen zubereitet.</p> <p>(c) fremde Produkte auf den gestellten Kaffeemaschinen zubereitet.</p>
<p>(3) Im Falle einer außerordentlichen fristlosen Kündigung durch Profit Planet GmbH, behält sich diese vor, dem Kunden eine Deckungsausgleichzahlung für die Restlaufzeit in Höhe von 25% der vereinbarten Mindestabnahmemenge, sowie der vertraglich vereinbarten Mietzinsen in Rechnung zu stellen. Die Geltendmachung weiteren Schadensersatzes bleibt vorbehalten. Dem Kunden bleibt der Nachweis offen, dass kein oder ein wesentlich geringerer Schaden entstanden ist.</p> <p>(3) Im Falle einer außerordentlichen fristlosen Kündigung durch Profit Planet GmbH, behält sich diese vor, dem Kunden eine Deckungsausgleichzahlung für die Restlaufzeit in Höhe von 25% der vereinbarten Mindestabnahmemenge, sowie der vertraglich vereinbarten Mietzinsen in Rechnung zu stellen. Die Geltendmachung weiteren Schadensersatzes bleibt vorbehalten. Dem Kunden bleibt der Nachweis offen, dass kein oder ein wesentlich geringerer Schaden entstanden ist.</p>
<div class="keepTogether">
<h2>§ 7 Eigentumsverhältnisse</h2> <h2>§ 7 Eigentumsverhältnisse</h2>
<p>Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.</p> <p>Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.</p>
</div>
<div class="pageBreak"></div> <div class="pageBreak"></div>

View File

@ -179,6 +179,41 @@
line-height: 1.6; line-height: 1.6;
} }
.payment-grid {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.payment-text {
flex: 1;
min-width: 0;
}
.payment-qr {
flex: 0 0 auto;
width: 170px;
text-align: right;
}
.payment-qr img {
width: 160px;
height: 160px;
object-fit: contain;
display: inline-block;
background: #ffffff;
border-radius: 6px;
padding: 6px;
}
@media (max-width: 560px) {
.payment-grid {
flex-direction: column;
}
.payment-qr {
width: auto;
text-align: left;
}
}
/* ── Footer ────────────────────────────────── */ /* ── Footer ────────────────────────────────── */
.footer { .footer {
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
@ -195,7 +230,7 @@
<!-- Header --> <!-- Header -->
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="company-name">{{companyName}}</div> <div class="company-name">Profit Planet GmbH</div>
<div class="company-sub">Coffee Subscription Service</div> <div class="company-sub">Coffee Subscription Service</div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -210,10 +245,9 @@
<div class="meta-block"> <div class="meta-block">
<h3>{{fromLabel}}</h3> <h3>{{fromLabel}}</h3>
<p> <p>
<span class="highlight">{{companyName}}</span><br> <span class="highlight">Profit Planet GmbH</span><br>
{{companyStreet}}<br> Kärntner Straße 227<br>
{{companyPostalCity}}<br> 8053 Graz
{{companyCountry}}
</p> </p>
</div> </div>
<div class="meta-block"> <div class="meta-block">
@ -273,13 +307,24 @@
<!-- Payment Info --> <!-- Payment Info -->
<div class="payment-info"> <div class="payment-info">
<h3>{{paymentInfoTitle}}</h3> <h3>PAYMENT INFORMATION</h3>
<p>{{paymentInfoText}}</p> <div class="payment-grid">
<div class="payment-text">
<p>
<strong>Profit Planet GmbH</strong><br>
AT16 2081 5000 4639 9507<br>
STSPAT2GXXX
</p>
<p>
Please use the Invoice number as a reference when making the payment.
</p>
</div>
<div class="payment-qr">{{qrCodeImage}}</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="footer"> <div class="footer">
{{companyName}} &middot; {{companyStreet}} &middot; {{companyPostalCity}} &middot; {{companyCountry}}<br>
{{footerText}} {{footerText}}
</div> </div>
</div> </div>

View File

@ -0,0 +1,6 @@
Place the locally-stored QR code images here:
- `qr_60.png` — QR for the 60-piece abo
- `qr_120.png` — QR for the 120-piece abo
The invoice PDF renderer embeds these files as base64 data-URIs so Puppeteer can render them without network access.

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB