CentralBackend/services/abonemments/AboContractService.js

380 lines
14 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const { PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const DocumentTemplateService = require('../template/DocumentTemplateService');
const MailService = require('../email/MailService');
const pool = require('../../database/database');
const { logger } = require('../../middleware/logger');
class AboContractService {
constructor() {
this.templatePath = path.join(__dirname, '..', '..', 'templates', 'abo', 'abo-contract-template-new.html');
}
/**
* Load the latest active abo contract template from the contract management system (DB + S3).
* Falls back to the local file if no active template is found.
*/
async _loadTemplate(userType = 'both') {
try {
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', 'abo');
if (latest?.storageKey) {
const command = new GetObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: latest.storageKey,
});
const fileObj = await sharedExoscaleClient.send(command);
if (fileObj.Body) {
const chunks = [];
for await (const chunk of fileObj.Body) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const html = Buffer.concat(chunks).toString('utf-8');
if (html.trim()) {
logger.info('AboContractService:template_loaded_from_s3', { id: latest.id, storageKey: latest.storageKey });
return html;
}
}
}
} catch (e) {
logger.warn('AboContractService:s3_template_load_failed', { message: e?.message });
}
// Fallback: local file
return fs.readFileSync(this.templatePath, 'utf8');
}
_escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
_formatDateTime(d = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
_sanitizeKeyPart(input, fallback) {
const raw = String(input ?? '').trim();
const safe = raw.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
return safe || fallback;
}
_renderTemplate(template, variables, { rawKeys = new Set() } = {}) {
if (!template) return '';
return template.replace(/{{\s*([\w]+)\s*}}/g, (_, key) => {
const value = variables[key];
if (value === undefined || value === null) return '';
if (rawKeys.has(key)) return String(value);
return this._escapeHtml(String(value));
});
}
_buildSelectedProductsHtml(abonement) {
const breakdown = Array.isArray(abonement?.pack_breakdown) ? abonement.pack_breakdown : [];
if (!breakdown.length) return '<p>-</p>';
const rows = breakdown.map((item) => {
const title = this._escapeHtml(item?.coffee_title || item?.title || 'Coffee');
const packs = Number(item?.packs ?? item?.quantity ?? 0);
return `<li>${title}${Number.isFinite(packs) ? packs : 0} pack(s)</li>`;
}).join('');
return `<ul>${rows}</ul>`;
}
_buildSignatureImgHtml(signatureDataUrl) {
const s = typeof signatureDataUrl === 'string' ? signatureDataUrl.trim() : '';
if (!s) return '';
// Basic safety: only accept data:image/... urls
if (!/^data:image\//i.test(s)) return '';
const escaped = this._escapeHtml(s);
return `<img alt="Signature" src="${escaped}" style="max-width:280px;max-height:120px;" />`;
}
async _renderPdfFromHtml(html) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' },
});
return Buffer.isBuffer(pdfBuffer) ? pdfBuffer : Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
}
async generateUploadAndEmail({
abonement,
actorUser,
contractNumber,
signingCity,
signatureDataUrl,
isForSelf,
lang = 'en',
}) {
if (!abonement?.id) throw new Error('abonement is required');
// Load template from contract management system (DB + S3), fallback to local file
const userType = await this._resolveUserType(actorUser);
let template;
try {
template = await this._loadTemplate(userType);
} catch (e) {
logger.error('AboContractService:template_missing', { message: e?.message });
throw new Error('ABO contract template missing');
}
// --- Sequential contract number ---
let displayContractNumber = String(contractNumber || '').trim();
if (!displayContractNumber) {
displayContractNumber = await this._generateSequentialContractNumber();
}
const contractKeyPart = this._sanitizeKeyPart(displayContractNumber, `abo-${abonement.id}`);
let profitplanetSignature = '';
try {
const sig = await DocumentTemplateService.getProfitPlanetSignatureTag({ maxW: 300, maxH: 300 });
profitplanetSignature = sig?.tag || '';
logger.info('AboContractService:stamp_result', { reason: sig?.reason, hasTag: !!profitplanetSignature, tagLen: profitplanetSignature.length });
} catch (e) {
logger.warn('AboContractService:getProfitPlanetSignatureTag_failed', { message: e?.message });
}
// --- Determine user type from actorUser or DB ---
// (userType already resolved above for template loading)
const isCompany = userType === 'company';
// --- Company data (FN / ATU) ---
let fnNumber = '';
let atuNumber = '';
if (isCompany && actorUser?.id) {
const companyData = await this._loadCompanyData(actorUser.id);
fnNumber = companyData.registrationNumber || '';
atuNumber = companyData.atuNumber || '';
}
// --- Shipping & Invoice data ---
const fullName = `${abonement.first_name || ''} ${abonement.last_name || ''}`.trim();
const invoiceSame = abonement.invoice_same_as_shipping !== false;
// DEBUG: log abonement data to diagnose empty fields in PDF
logger.info('AboContractService:abonement_data', {
id: abonement.id,
first_name: abonement.first_name,
last_name: abonement.last_name,
email: abonement.email,
street: abonement.street,
postal_code: abonement.postal_code,
city: abonement.city,
phone: abonement.phone,
payment_method: abonement.payment_method,
invoice_by_email: abonement.invoice_by_email,
invoice_same_as_shipping: abonement.invoice_same_as_shipping,
recipient_name: abonement.recipient_name,
fullName,
invoiceSame,
});
const variables = {
// Meta
contractNumber: displayContractNumber,
currentDate: this._formatDateTime(new Date()),
// Empfänger (An die) — auto-fill from shipping data if no explicit recipient set
recipientName: abonement.recipient_name || fullName,
recipientAddress: abonement.recipient_address || `${abonement.street || ''}, ${abonement.postal_code || ''} ${abonement.city || ''}`.trim(),
// Shipping
shippingCustomerClass: isCompany ? '' : 'checked',
shippingCompanyClass: isCompany ? 'checked' : '',
shippingFullName: fullName,
shippingStreet: abonement.street || '',
shippingPostalCode: abonement.postal_code || '',
shippingCity: abonement.city || '',
shippingPhone: abonement.phone || '',
shippingEmail: abonement.email || '',
// Invoice same as shipping
invoiceSameAsShippingMark: invoiceSame ? '✓' : '',
// Invoice address
invoiceCompanyClass: isCompany ? 'checked' : '',
invoiceCustomerClass: isCompany ? '' : 'checked',
invoiceFullName: invoiceSame ? fullName : (abonement.invoice_full_name || ''),
invoiceStreet: invoiceSame ? (abonement.street || '') : (abonement.invoice_street || ''),
invoicePostalCode: invoiceSame ? (abonement.postal_code || '') : (abonement.invoice_postal_code || ''),
invoiceCity: invoiceSame ? (abonement.city || '') : (abonement.invoice_city || ''),
invoicePhone: invoiceSame ? (abonement.phone || '') : (abonement.invoice_phone || ''),
invoiceEmail: invoiceSame ? (abonement.email || '') : (abonement.invoice_email || ''),
// Company numbers (FN / ATU) — only shown for company users
fnCheckedClass: isCompany && fnNumber ? 'checked' : '',
fnNumber,
atuCheckedClass: isCompany && atuNumber ? 'checked' : '',
atuNumber,
// Unternehmer / Konsument
entrepreneurClass: isCompany ? 'checked' : '',
consumerClass: isCompany ? '' : 'checked',
// Product selection
selectedProductsHtml: this._buildSelectedProductsHtml(abonement),
// Payment
paymentSepaClass: abonement.payment_method === 'sepa' ? 'checked' : '',
paymentCardClass: abonement.payment_method === 'card' ? 'checked' : '',
paymentSofortClass: abonement.payment_method === 'sofort' ? 'checked' : '',
invoiceByEmailClass: abonement.invoice_by_email ? 'checked' : '',
// Signatures
profitplanetSignature,
companyStampImage: profitplanetSignature,
signatureImage: this._buildSignatureImgHtml(signatureDataUrl),
signingCity: signingCity || '',
fullName,
};
// DEBUG: log template source and variable keys with values
logger.info('AboContractService:render_debug', {
templateSource: template ? (template.includes('{{shippingFullName}}') ? 'has_placeholders' : 'NO_placeholders') : 'empty',
templateLen: template?.length,
variableKeys: Object.keys(variables),
sampleValues: {
shippingFullName: variables.shippingFullName,
shippingStreet: variables.shippingStreet,
shippingEmail: variables.shippingEmail,
recipientName: variables.recipientName,
},
});
const html = this._renderTemplate(template, variables, {
rawKeys: new Set(['selectedProductsHtml', 'profitplanetSignature', 'signatureImage', 'companyStampImage']),
});
const pdfBuffer = await this._renderPdfFromHtml(html);
const key = `abo/${abonement.id}/${contractKeyPart}.pdf`;
await sharedExoscaleClient.send(new PutObjectCommand({
Bucket: process.env.EXOSCALE_BUCKET,
Key: key,
Body: pdfBuffer,
ContentType: 'application/pdf',
}));
const recipientEmail = abonement.email || actorUser?.email;
if (recipientEmail) {
const isDe = lang === 'de';
const subject = isDe
? `ProfitPlanet Vertrag ${displayContractNumber}`
: `ProfitPlanet Contract ${displayContractNumber}`;
const text = isDe
? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.'
: 'Your signed contract is attached as a PDF.';
const mailHtml = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;">
<p>${isDe ? 'Hallo' : 'Hi'},</p>
<p>${isDe ? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.' : 'Your signed contract is attached as a PDF.'}</p>
<p>${isDe ? 'Vertragsnummer' : 'Contract number'}: <strong>${this._escapeHtml(displayContractNumber)}</strong></p>
<p>${isDe ? 'Viele Grüße' : 'Best regards'},<br>ProfitPlanet</p>
</body>
</html>`;
const mailPayload = {
subject,
text,
html: mailHtml,
lang,
attachments: [{
name: `${contractKeyPart}.pdf`,
content: pdfBuffer.toString('base64'),
}],
};
await MailService.sendAboContractEmail({ email: recipientEmail, ...mailPayload });
// When subscription is for someone else, also send the contract to the purchaser
const purchaserEmail = actorUser?.email;
const isForSomeoneElse = isForSelf === false;
if (isForSomeoneElse && purchaserEmail && purchaserEmail !== recipientEmail) {
try {
await MailService.sendAboContractEmail({ email: purchaserEmail, ...mailPayload });
} catch (e) {
logger.warn('AboContractService:purchaser_email_failed', { purchaserEmail, message: e?.message });
}
}
} else {
logger.warn('AboContractService:missing_recipient_email', { abonementId: abonement.id });
}
return { storageKey: key, contractNumber: displayContractNumber };
}
/**
* Generate sequential contract number: ABO-YYYY-NNNNN
* Uses MAX(id) from coffee_abonements as a simple sequence.
*/
async _generateSequentialContractNumber() {
const year = new Date().getFullYear();
const [rows] = await pool.query(
`SELECT COALESCE(MAX(id), 0) + 1 AS next_seq FROM coffee_abonements`
);
const seq = rows[0]?.next_seq || 1;
return `ABO-${year}-${String(seq).padStart(5, '0')}`;
}
/**
* Resolve user type from actorUser JWT payload or DB lookup.
*/
async _resolveUserType(actorUser) {
const fromToken = actorUser?.userType || actorUser?.user_type;
if (fromToken) return fromToken;
if (!actorUser?.id) return 'personal';
try {
const [rows] = await pool.query(
`SELECT user_type FROM users WHERE id = ? LIMIT 1`,
[actorUser.id]
);
return rows[0]?.user_type || 'personal';
} catch {
return 'personal';
}
}
/**
* Load company-specific data (FN number, ATU) for a company user.
*/
async _loadCompanyData(userId) {
try {
const [rows] = await pool.query(
`SELECT registration_number, atu_number FROM company_profiles WHERE user_id = ? LIMIT 1`,
[userId]
);
return {
registrationNumber: rows[0]?.registration_number || '',
atuNumber: rows[0]?.atu_number || '',
};
} catch {
return { registrationNumber: '', atuNumber: '' };
}
}
}
module.exports = AboContractService;