From 19847eefb4cc86ddb28b26cb029afe355bc14092 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Mon, 16 Mar 2026 20:35:10 +0100 Subject: [PATCH] feat: enhance subscription model with additional contact and invoice fields hawi i cant --- .../abonemments/AbonemmentController.js | 15 +- database/createDb.js | 27 ++ models/Abonemment.js | 13 + package-lock.json | 3 +- .../abonemments/AbonemmentRepository.js | 22 ++ services/abonemments/AboContractService.js | 230 ++++++++++++++++-- services/abonemments/AbonemmentService.js | 40 ++- 7 files changed, 320 insertions(+), 30 deletions(-) diff --git a/controller/abonemments/AbonemmentController.js b/controller/abonemments/AbonemmentController.js index 2dd3670..7969ed0 100644 --- a/controller/abonemments/AbonemmentController.js +++ b/controller/abonemments/AbonemmentController.js @@ -25,7 +25,6 @@ module.exports = { isForSelf: req.body.is_for_self, recipientName: req.body.recipient_name, recipientEmail: req.body.recipient_email, - recipientNotes: req.body.recipient_notes, firstName: req.body.firstName, lastName: req.body.lastName, email: req.body.email, @@ -34,8 +33,20 @@ module.exports = { city: req.body.city, country: req.body.country, frequency: req.body.frequency, - startDate: req.body.startDate, + phone: req.body.phone, + recipientContractName: req.body.recipientContractName, + recipientAddress: req.body.recipientAddress, + paymentMethod: req.body.paymentMethod, + invoiceByEmail: req.body.invoiceByEmail, + invoiceSameAsShipping: req.body.invoiceSameAsShipping, + invoiceFullName: req.body.invoiceFullName, + invoiceStreet: req.body.invoiceStreet, + invoicePostalCode: req.body.invoicePostalCode, + invoiceCity: req.body.invoiceCity, + invoicePhone: req.body.invoicePhone, + invoiceEmail: req.body.invoiceEmail, contractNumber: req.body.contractNumber || req.body.contract_number, + signingCity: req.body.signingCity || req.body.signing_city || '', signatureDataUrl: req.body.signatureDataUrl || req.body.signature_data_url, actorUser, // normalized to include id referredBy: req.body.referred_by, diff --git a/database/createDb.js b/database/createDb.js index 0acfeaf..b2ad46c 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -261,6 +261,9 @@ const createDatabase = async () => { console.log('ℹ️ Phone columns already nullable or ALTER not required'); } + // ATU number for company profiles + await addColumnIfMissing(connection, 'company_profiles', 'atu_number', `VARCHAR(50) NULL AFTER registration_number`); + // 4. user_status table: Comprehensive tracking of user verification and completion steps await connection.query(` CREATE TABLE IF NOT EXISTS user_status ( @@ -1116,6 +1119,30 @@ const createDatabase = async () => { ADD CONSTRAINT \`fk_abon_purchaser_user\` FOREIGN KEY (\`purchaser_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` ); + // Contract fields + await addColumnIfMissing(connection, 'coffee_abonements', 'contract_number', `VARCHAR(50) NULL AFTER purchaser_user_id`); + await addColumnIfMissing(connection, 'coffee_abonements', 'contract_storage_key', `VARCHAR(255) NULL AFTER contract_number`); + + // Additional shipping / contact fields + await addColumnIfMissing(connection, 'coffee_abonements', 'phone', `VARCHAR(50) NULL AFTER country`); + + // Contract recipient + await addColumnIfMissing(connection, 'coffee_abonements', 'recipient_name', `VARCHAR(255) NULL AFTER phone`); + await addColumnIfMissing(connection, 'coffee_abonements', 'recipient_address', `TEXT NULL AFTER recipient_name`); + + // Payment + await addColumnIfMissing(connection, 'coffee_abonements', 'payment_method', `VARCHAR(30) NULL AFTER frequency`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_by_email', `TINYINT(1) NOT NULL DEFAULT 0 AFTER payment_method`); + + // Invoice address + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_same_as_shipping', `TINYINT(1) NOT NULL DEFAULT 1 AFTER invoice_by_email`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_full_name', `VARCHAR(200) NULL AFTER invoice_same_as_shipping`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_street', `VARCHAR(255) NULL AFTER invoice_full_name`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_postal_code', `VARCHAR(20) NULL AFTER invoice_street`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_city', `VARCHAR(100) NULL AFTER invoice_postal_code`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_phone', `VARCHAR(50) NULL AFTER invoice_city`); + await addColumnIfMissing(connection, 'coffee_abonements', 'invoice_email', `VARCHAR(255) NULL AFTER invoice_phone`); + // --- Coffee Abonement History --- await connection.query(` CREATE TABLE IF NOT EXISTS coffee_abonement_history ( diff --git a/models/Abonemment.js b/models/Abonemment.js index f2b9b4f..d9d9270 100644 --- a/models/Abonemment.js +++ b/models/Abonemment.js @@ -26,6 +26,19 @@ class Abonemment { this.user_id = row.user_id ?? null; // NEW: map owner user_id this.contract_number = row.contract_number ?? null; this.contract_storage_key = row.contract_storage_key ?? null; + // Contact / invoice fields + this.phone = row.phone ?? null; + this.recipient_name = row.recipient_name ?? null; + this.recipient_address = row.recipient_address ?? null; + this.payment_method = row.payment_method ?? null; + this.invoice_by_email = !!row.invoice_by_email; + this.invoice_same_as_shipping = row.invoice_same_as_shipping !== undefined ? !!row.invoice_same_as_shipping : true; + this.invoice_full_name = row.invoice_full_name ?? null; + this.invoice_street = row.invoice_street ?? null; + this.invoice_postal_code = row.invoice_postal_code ?? null; + this.invoice_city = row.invoice_city ?? null; + this.invoice_phone = row.invoice_phone ?? null; + this.invoice_email = row.invoice_email ?? null; this.created_at = row.created_at; this.updated_at = row.updated_at; } diff --git a/package-lock.json b/package-lock.json index ceb6f91..663d587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2644,8 +2644,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dfa": { "version": "1.2.0", diff --git a/repositories/abonemments/AbonemmentRepository.js b/repositories/abonemments/AbonemmentRepository.js index 3c442bd..ae90619 100644 --- a/repositories/abonemments/AbonemmentRepository.js +++ b/repositories/abonemments/AbonemmentRepository.js @@ -74,6 +74,28 @@ class AbonemmentRepository { vals.push(snapshot.purchaser_user_id ?? null); } + // New contract / contact / invoice columns + const optionalCols = [ + ['phone', snapshot.phone || null], + ['recipient_name', snapshot.recipient_name || null], + ['recipient_address', snapshot.recipient_address || null], + ['payment_method', snapshot.payment_method || null], + ['invoice_by_email', snapshot.invoice_by_email ? 1 : 0], + ['invoice_same_as_shipping', snapshot.invoice_same_as_shipping !== false ? 1 : 0], + ['invoice_full_name', snapshot.invoice_full_name || null], + ['invoice_street', snapshot.invoice_street || null], + ['invoice_postal_code', snapshot.invoice_postal_code || null], + ['invoice_city', snapshot.invoice_city || null], + ['invoice_phone', snapshot.invoice_phone || null], + ['invoice_email', snapshot.invoice_email || null], + ]; + for (const [col, val] of optionalCols) { + if (await this.hasColumn(col)) { + cols.push(col); + vals.push(val); + } + } + const placeholders = cols.map(() => '?').join(', '); console.log('[CREATE ABONEMENT] Final columns:', cols); console.log('[CREATE ABONEMENT] Final values preview:', { diff --git a/services/abonemments/AboContractService.js b/services/abonemments/AboContractService.js index b7ef66b..0968242 100644 --- a/services/abonemments/AboContractService.js +++ b/services/abonemments/AboContractService.js @@ -1,15 +1,48 @@ const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer'); -const { PutObjectCommand } = require('@aws-sdk/client-s3'); +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.html'); + 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) { @@ -87,50 +120,148 @@ class AboContractService { 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 = fs.readFileSync(this.templatePath, 'utf8'); + template = await this._loadTemplate(userType); } catch (e) { - logger.error('AboContractService:template_missing', { templatePath: this.templatePath, message: e?.message }); + logger.error('AboContractService:template_missing', { message: e?.message }); throw new Error('ABO contract template missing'); } - const displayContractNumber = String(contractNumber || '').trim() || `ABO-${abonement.id}`; + // --- 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()), - firstName: abonement.first_name || '', - lastName: abonement.last_name || '', - email: abonement.email || '', - street: abonement.street || '', - postalCode: abonement.postal_code || '', - city: abonement.city || '', - country: abonement.country || '', - frequency: abonement.frequency || '', - price: abonement.price != null ? String(abonement.price) : '', - currency: abonement.currency || 'EUR', + + // 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']), + rawKeys: new Set(['selectedProductsHtml', 'profitplanetSignature', 'signatureImage', 'companyStampImage']), }); const pdfBuffer = await this._renderPdfFromHtml(html); @@ -165,8 +296,7 @@ class AboContractService { `; - await MailService.sendAboContractEmail({ - email: recipientEmail, + const mailPayload = { subject, text, html: mailHtml, @@ -175,13 +305,75 @@ class AboContractService { 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; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 81b93a1..79e1b82 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -49,7 +49,6 @@ class AbonemmentService { isForSelf, recipientName, recipientEmail, - recipientNotes, firstName, lastName, email, @@ -58,8 +57,20 @@ class AbonemmentService { city, country, frequency, - startDate, + phone, + recipientContractName, + recipientAddress, + paymentMethod, + invoiceByEmail, + invoiceSameAsShipping, + invoiceFullName, + invoiceStreet, + invoicePostalCode, + invoiceCity, + invoicePhone, + invoiceEmail, contractNumber, + signingCity, signatureDataUrl, actorUser, referredBy, // NEW: referred_by field @@ -75,7 +86,6 @@ class AbonemmentService { city, country, frequency, - startDate, }); const normalizedEmail = this.normalizeEmail(email); @@ -117,15 +127,14 @@ class AbonemmentService { } const now = new Date(); - const startDateObj = startDate ? new Date(startDate) : now; - const nextBilling = this.addInterval(startDateObj, billingInterval || 'month', intervalCount || 1); + const nextBilling = this.addInterval(now, billingInterval || 'month', intervalCount || 1); const effectiveRecipientName = recipientName || `${firstName || ''} ${lastName || ''}`.trim() || null; const effectiveEmail = forSelf ? normalizedEmail : normalizedRecipientEmail; const snapshot = { status: 'active', - started_at: startDateObj, + started_at: now, next_billing_at: nextBilling, billing_interval: billingInterval || 'month', interval_count: intervalCount || 1, @@ -138,7 +147,6 @@ class AbonemmentService { total_packs: totalPacks, is_for_self: forSelf, recipient_name: forSelf ? null : effectiveRecipientName, - recipient_notes: forSelf ? null : (recipientNotes || null) }, pack_breakdown: breakdown, first_name: forSelf ? firstName : (effectiveRecipientName || firstName), @@ -152,6 +160,19 @@ class AbonemmentService { referred_by: referredBy || (forSelf ? (actorUser?.id ?? null) : null), user_id: forSelf ? (actorUser?.id ?? null) : null, purchaser_user_id: actorUser?.id ?? null, // NEW: also store purchaser + // New fields + phone: phone || null, + recipient_name: recipientContractName || null, + recipient_address: recipientAddress || null, + payment_method: paymentMethod || null, + invoice_by_email: !!invoiceByEmail, + invoice_same_as_shipping: invoiceSameAsShipping !== false, + invoice_full_name: invoiceFullName || null, + invoice_street: invoiceStreet || null, + invoice_postal_code: invoicePostalCode || null, + invoice_city: invoiceCity || null, + invoice_phone: invoicePhone || null, + invoice_email: invoiceEmail || null, }; console.log('[SUBSCRIBE ORDER] Snapshot user linking:', { @@ -179,7 +200,9 @@ class AbonemmentService { abonement, actorUser, contractNumber, + signingCity, signatureDataUrl, + isForSelf: forSelf, lang, }); @@ -314,6 +337,7 @@ class AbonemmentService { recipientEmail, recipientNotes, contractNumber, + signingCity, signatureDataUrl, actorUser, referredBy, // NEW: referred_by field @@ -415,7 +439,9 @@ class AbonemmentService { abonement, actorUser, contractNumber, + signingCity, signatureDataUrl, + isForSelf: isForMe, lang, });