feat: enhance subscription model with additional contact and invoice fields

hawi i cant
This commit is contained in:
seaznCode 2026-03-16 20:35:10 +01:00
parent 46a081ae8f
commit 19847eefb4
7 changed files with 320 additions and 30 deletions

View File

@ -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,

View File

@ -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 (

View File

@ -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;
}

3
package-lock.json generated
View File

@ -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",

View File

@ -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:', {

View File

@ -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 {
</body>
</html>`;
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;

View File

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