380 lines
14 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
_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;
|