diff --git a/QRCode/qr_120.png b/QRCode/qr_120.png
new file mode 100644
index 0000000..80e38a7
Binary files /dev/null and b/QRCode/qr_120.png differ
diff --git a/QRCode/qr_60.png b/QRCode/qr_60.png
new file mode 100644
index 0000000..0fd81c7
Binary files /dev/null and b/QRCode/qr_60.png differ
diff --git a/controller/admin/CompanySettingsController.js b/controller/admin/CompanySettingsController.js
index b6b88eb..dd92ccb 100644
--- a/controller/admin/CompanySettingsController.js
+++ b/controller/admin/CompanySettingsController.js
@@ -1,12 +1,122 @@
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
+const { logger } = require('../../middleware/logger');
+const crypto = require('crypto');
const repo = new CompanySettingsRepository();
class CompanySettingsController {
+ static _normalizeBase64ImageInput(value) {
+ if (value === undefined) return undefined;
+ if (value === null) return null;
+
+ const stripDataUri = (str) => {
+ const s = String(str || '').trim();
+ if (!s) return '';
+ const m = s.match(/^data:([^;]+);base64,(.*)$/i);
+ return m ? (m[2] || '').trim() : s;
+ };
+
+ if (typeof value === 'string') {
+ // If base64 came via urlencoded forms, '+' often becomes ' ' after decoding.
+ // Restoring spaces back to '+' makes the payload usable again.
+ let normalized = stripDataUri(value);
+ if (normalized.includes(' ') && !normalized.includes('+')) {
+ normalized = normalized.replace(/ /g, '+');
+ }
+ normalized = normalized.replace(/\s+/g, '');
+ return normalized || null;
+ }
+
+ if (typeof value === 'object') {
+ // Common Node.js JSON shape for Buffers: { type: 'Buffer', data: [..bytes..] }
+ if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
+ try {
+ const buf = Buffer.from(value.data);
+ const asBase64 = buf.toString('base64');
+ return asBase64 || null;
+ } catch (_) {
+ return undefined;
+ }
+ }
+
+ // Common frontend shapes:
+ // { kind: 'base64', base64: '...' }
+ // { kind: 'base64', data: '...' }
+ // { dataUrl: 'data:image/png;base64,...' }
+ // { value: '...' }
+ const candidates = [
+ value.base64,
+ value.data,
+ value.value,
+ value.content,
+ value.full,
+ value.raw,
+ value.payload,
+ value.src,
+ value.b64,
+ value.base64String,
+ value.dataUrl,
+ value.dataURL,
+ value.data_uri,
+ value.dataURI,
+ value.uri,
+ ];
+ for (const c of candidates) {
+ if (typeof c === 'string' && c.trim()) {
+ let normalized = stripDataUri(c);
+ if (normalized.includes(' ') && !normalized.includes('+')) {
+ normalized = normalized.replace(/ /g, '+');
+ }
+ normalized = normalized.replace(/\s+/g, '');
+ return normalized || null;
+ }
+ }
+
+ // Fallback: scan shallow object for a base64-ish string
+ const looksLikeImageBase64 = (str) => {
+ const s = String(str || '').trim();
+ return s.startsWith('data:image/') || s.startsWith('iVBORw0KGgo') || s.startsWith('/9j/') || s.startsWith('R0lGOD');
+ };
+
+ for (const v of Object.values(value)) {
+ if (typeof v === 'string' && looksLikeImageBase64(v)) {
+ let normalized = stripDataUri(v);
+ if (normalized.includes(' ') && !normalized.includes('+')) {
+ normalized = normalized.replace(/ /g, '+');
+ }
+ normalized = normalized.replace(/\s+/g, '');
+ return normalized || null;
+ }
+ if (v && typeof v === 'object') {
+ for (const vv of Object.values(v)) {
+ if (typeof vv === 'string' && looksLikeImageBase64(vv)) {
+ let normalized = stripDataUri(vv);
+ if (normalized.includes(' ') && !normalized.includes('+')) {
+ normalized = normalized.replace(/ /g, '+');
+ }
+ normalized = normalized.replace(/\s+/g, '');
+ return normalized || null;
+ }
+ }
+ }
+ }
+ }
+
+ // Unknown shape; ignore instead of persisting garbage
+ return undefined;
+ }
+
static async get(req, res) {
try {
const settings = await repo.get();
- 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) {
return res.status(500).json({ message: 'Failed to load company settings' });
}
@@ -14,10 +124,141 @@ class CompanySettingsController {
static async update(req, res) {
try {
- const { company_name, company_street, company_postal_city, company_country } = req.body;
- const updated = await repo.update({ company_name, company_street, company_postal_city, company_country });
+ const body = req.body || {};
+
+ 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);
} 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' });
}
}
diff --git a/controller/auth/EmailVerificationController.js b/controller/auth/EmailVerificationController.js
index 9c24e39..81fbfe9 100644
--- a/controller/auth/EmailVerificationController.js
+++ b/controller/auth/EmailVerificationController.js
@@ -22,7 +22,7 @@ class EmailVerificationController {
res.json({ success: true, message: 'Verification email sent' });
} catch (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 });
}
}
@@ -44,7 +44,7 @@ class EmailVerificationController {
}
} catch (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 });
}
}
diff --git a/controller/login/LoginController.js b/controller/login/LoginController.js
index f80e68e..f65c84d 100644
--- a/controller/login/LoginController.js
+++ b/controller/login/LoginController.js
@@ -125,7 +125,10 @@ class LoginController {
lang: lang // <-- pass lang from request body
});
} 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
}
diff --git a/controller/password-reset/PasswordResetController.js b/controller/password-reset/PasswordResetController.js
index 3077456..ffd5ad4 100644
--- a/controller/password-reset/PasswordResetController.js
+++ b/controller/password-reset/PasswordResetController.js
@@ -72,7 +72,7 @@ module.exports = {
await uow.commit();
} catch (err) {
await uow.rollback();
- logger.error('passwordReset:request_error', { error: err });
+ logger.error('passwordReset:request_error', { message: err?.message, stack: err?.stack });
}
return res.json({
success: true,
diff --git a/database/createDb.js b/database/createDb.js
index b2ad46c..d662f78 100644
--- a/database/createDb.js
+++ b/database/createDb.js
@@ -813,6 +813,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 'Germany',
+ qr_code_60_base64 LONGTEXT NULL,
+ qr_code_120_base64 LONGTEXT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CHECK (id = 1)
);
@@ -823,6 +825,10 @@ const createDatabase = async () => {
`);
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) ---
await connection.query(`
CREATE TABLE IF NOT EXISTS dashboard_plattforms (
diff --git a/repositories/settings/CompanySettingsRepository.js b/repositories/settings/CompanySettingsRepository.js
index a0f9f13..7153f1b 100644
--- a/repositories/settings/CompanySettingsRepository.js
+++ b/repositories/settings/CompanySettingsRepository.js
@@ -6,16 +6,42 @@ class CompanySettingsRepository {
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(
- `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country)
- VALUES (1, ?, ?, ?, ?)
+ `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country, qr_code_60_base64, qr_code_120_base64)
+ 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)`,
- [company_name || '', company_street || '', company_postal_city || '', company_country || '']
+ company_country = VALUES(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();
}
diff --git a/server.js b/server.js
index 5268713..47e0922 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,9 @@ const RenewalCronService = require('./services/abonemments/RenewalCronService');
const app = express();
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
const ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS)
.split(',')
@@ -41,7 +44,8 @@ const corsOptions = {
app.use(cors(corsOptions));
// Middleware
-app.use(express.json());
+app.use(express.json({ limit: JSON_LIMIT }));
+app.use(express.urlencoded({ extended: true, limit: JSON_LIMIT }));
app.use(cookieParser());
// -- Replace inline console logging with structured request logger --
diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js
index 04485fc..998821c 100644
--- a/services/invoice/InvoiceService.js
+++ b/services/invoice/InvoiceService.js
@@ -8,12 +8,114 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer');
+const fs = require('fs/promises');
+const path = require('path');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
class InvoiceService {
constructor() {
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 ``;
}
_escapeHtml(value) {
@@ -175,13 +277,47 @@ class InvoiceService {
Key: selected.storageKey,
});
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) {
logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message });
return null;
}
}
+ _getProfitPlanetBankBlockHtml({ bankAccountHolder, bankIban, bankBic }) {
+ return `${this._escapeHtml(bankAccountHolder)}
${this._escapeHtml(bankIban)}
${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 || ''}
${variables.qrCodeImage}`;
+ }
+
+ return next;
+ }
+
async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) {
const isDe = lang === 'de';
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 vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
- // Load company info from DB
- let companyInfo = { company_name: 'ProfitPlanet GmbH', company_street: '', company_postal_city: '', company_country: 'Germany' };
- try {
- const repo = new CompanySettingsRepository();
- const row = await repo.get();
- if (row) companyInfo = row;
- } catch (e) {
- logger.warn('InvoiceService._buildInvoiceTemplateVariables:company_settings_error', { message: e?.message });
- }
+ // Hardcoded bank info (Profit Planet)
+ const bankAccountHolder = 'Profit Planet GmbH';
+ const bankIban = 'AT16 2081 5000 4639 9507';
+ const bankBic = 'STSPAT2GXXX';
+
+ // Hardcoded footer/contact info (Profit Planet)
+ const footerText = [
+ '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('
');
+
+ // 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 self subscriptions: "Bill To" = the subscriber
@@ -223,6 +377,8 @@ class InvoiceService {
customerEmail = abonement?.email || invoice.buyer_email || '';
}
+ const qrCodeImage = await this._buildQrCodeImageTag({ abonement });
+
return {
lang: isDe ? 'de' : 'en',
documentTitle: isDe ? 'Rechnung' : 'Invoice',
@@ -263,9 +419,11 @@ class InvoiceService {
paymentInfoText: isDe
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
: 'Please transfer the total amount stating the invoice number as reference.',
- footerText: isDe
- ? 'Vielen Dank für Ihr Vertrauen.'
- : 'Thank you for your business.',
+ bankAccountHolder: this._escapeHtml(bankAccountHolder),
+ bankIban: this._escapeHtml(bankIban),
+ bankBic: this._escapeHtml(bankBic),
+ qrCodeImage,
+ footerText,
// Legacy key used by S3-stored templates
itemsHtml: this._buildItemsHtml(items, invoice.currency),
};
@@ -276,7 +434,8 @@ class InvoiceService {
const template = await this._loadInvoiceHtmlTemplate();
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
@@ -309,10 +468,11 @@ class InvoiceService {
});
const obj = await sharedExoscaleClient.send(command);
const html = await this._s3BodyToString(obj.Body);
- return html || null;
+ if (!html) return await this._loadLocalInvoiceTemplateHtml();
+ return html;
} catch (error) {
logger.warn('InvoiceService._loadInvoiceTemplateHtml:error', { message: error?.message });
- return null;
+ return await this._loadLocalInvoiceTemplateHtml();
}
}
@@ -372,7 +532,30 @@ class InvoiceService {
let html = null;
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 });
diff --git a/templates/abo/abo-contract-template.html b/templates/abo/abo-contract-template.html
index 9367398..3342b3b 100644
--- a/templates/abo/abo-contract-template.html
+++ b/templates/abo/abo-contract-template.html
@@ -536,8 +536,10 @@
(c) fremde Produkte auf den gestellten Kaffeemaschinen zubereitet.
(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.
-Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.
+Die gelieferten Maschinen bleiben Eigentum von Profit Planet GmbH.
+