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, '''); } _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 '

-

'; const rows = breakdown.map((item) => { const title = this._escapeHtml(item?.coffee_title || item?.title || 'Coffee'); const packs = Number(item?.packs ?? item?.quantity ?? 0); return `
  • ${title} — ${Number.isFinite(packs) ? packs : 0} pack(s)
  • `; }).join(''); return ``; } _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 `Signature`; } 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 = `

    ${isDe ? 'Hallo' : 'Hi'},

    ${isDe ? 'Ihr unterschriebener Vertrag ist als PDF im Anhang.' : 'Your signed contract is attached as a PDF.'}

    ${isDe ? 'Vertragsnummer' : 'Contract number'}: ${this._escapeHtml(displayContractNumber)}

    ${isDe ? 'Viele Grüße' : 'Best regards'},
    ProfitPlanet

    `; 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;