diff --git a/controller/admin/CompanySettingsController.js b/controller/admin/CompanySettingsController.js new file mode 100644 index 0000000..b6b88eb --- /dev/null +++ b/controller/admin/CompanySettingsController.js @@ -0,0 +1,26 @@ +const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); + +const repo = new CompanySettingsRepository(); + +class CompanySettingsController { + static async get(req, res) { + try { + const settings = await repo.get(); + return res.json(settings || { company_name: '', company_street: '', company_postal_city: '', company_country: '' }); + } catch (err) { + return res.status(500).json({ message: 'Failed to load company settings' }); + } + } + + static async update(req, res) { + try { + const { company_name, company_street, company_postal_city, company_country } = req.body; + const updated = await repo.update({ company_name, company_street, company_postal_city, company_country }); + return res.json(updated); + } catch (err) { + return res.status(500).json({ message: 'Failed to update company settings' }); + } + } +} + +module.exports = CompanySettingsController; diff --git a/controller/register/GuestRegisterController.js b/controller/register/GuestRegisterController.js new file mode 100644 index 0000000..ad9174d --- /dev/null +++ b/controller/register/GuestRegisterController.js @@ -0,0 +1,77 @@ +const GuestUserService = require('../../services/user/guest/GuestUserService'); +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); +const { logger } = require('../../middleware/logger'); + +const abonemmentService = new AbonemmentService(); + +class GuestRegisterController { + static async register(req, res) { + logger.info('GuestRegisterController.register:start'); + try { + const { + firstName, + lastName, + email, + confirmEmail, + password, + confirmPassword, + referralEmail, + lang, + } = req.body; + + if (!email || !password || !firstName || !lastName) { + return res.status(400).json({ + success: false, + message: 'firstName, lastName, email, and password are required', + }); + } + + if (email !== confirmEmail) { + return res.status(400).json({ + success: false, + message: 'Email and confirm email do not match', + }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ + success: false, + message: 'Password and confirm password do not match', + }); + } + + const newUser = await GuestUserService.createGuestUser({ + email, + password, + firstName, + lastName, + referralEmail, + lang, + }); + + logger.info('GuestRegisterController.register:success', { userId: newUser.id }); + + res.status(201).json({ + success: true, + message: 'Guest user registered successfully', + userId: newUser.id, + }); + } catch (error) { + logger.error('GuestRegisterController.register:error', { error: error.message }); + + if (error.message === 'User already exists') { + return res.status(400).json({ + success: false, + message: 'User already exists', + }); + } + + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } +} + +module.exports = GuestRegisterController; diff --git a/database/createDb.js b/database/createDb.js index 4998b6b..eae39d3 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -168,7 +168,7 @@ const createDatabase = async () => { email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, user_type ENUM('personal', 'company') NOT NULL, - role ENUM('user', 'admin', 'super_admin') DEFAULT 'user', + role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user', iban VARCHAR(34), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -180,6 +180,17 @@ const createDatabase = async () => { `); console.log('✅ Users table created/verified'); + // Migrate existing role ENUM to include 'guest' + try { + await connection.query(` + ALTER TABLE users + MODIFY COLUMN role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user' + `); + console.log('✅ Updated users.role column to include guest'); + } catch (err) { + console.warn('⚠️ Could not modify users.role column:', err.message); + } + // 2. personal_profiles table: Details specific to personal users await connection.query(` CREATE TABLE IF NOT EXISTS personal_profiles ( @@ -705,6 +716,24 @@ const createDatabase = async () => { `); console.log('✅ User settings table created/verified'); + // --- Company Settings (single-row, global invoice / company info) --- + await connection.query(` + CREATE TABLE IF NOT EXISTS company_settings ( + id INT PRIMARY KEY DEFAULT 1, + company_name VARCHAR(200) NOT NULL DEFAULT 'ProfitPlanet GmbH', + company_street VARCHAR(255) NOT NULL DEFAULT '', + company_postal_city VARCHAR(255) NOT NULL DEFAULT '', + company_country VARCHAR(100) NOT NULL DEFAULT 'Germany', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (id = 1) + ); + `); + // Seed default row if missing + await connection.query(` + INSERT IGNORE INTO company_settings (id) VALUES (1); + `); + console.log('✅ Company settings table created/verified'); + // --- Rate Limiting Table --- await connection.query(` CREATE TABLE IF NOT EXISTS rate_limit ( diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js index 7f4ed0b..3ab00fb 100644 --- a/middleware/authMiddleware.js +++ b/middleware/authMiddleware.js @@ -77,6 +77,12 @@ async function authMiddleware(req, res, next) { return res.status(500).json({ success: false, message: 'Internal server error' }); } + // Guest restriction: guest users can only access abo-related routes + if (normalizedRole === 'guest') { + const guestRestriction = require('./guestRestriction'); + return guestRestriction(req, res, next); + } + next(); } catch (error) { logger.warn('authMiddleware:tokenInvalid', { diff --git a/middleware/guestRestriction.js b/middleware/guestRestriction.js new file mode 100644 index 0000000..00873a4 --- /dev/null +++ b/middleware/guestRestriction.js @@ -0,0 +1,53 @@ +const { logger } = require('./logger'); + +/** + * Middleware that blocks guest users from accessing non-abonnement routes. + * Guest users (role='guest') can ONLY access: + * - /abonements/* + * - /invoices/mine + * - /me + * - /user/settings + * - /logout + * - /refresh + * + * Place this AFTER authMiddleware in the app-level middleware chain. + */ +const GUEST_ALLOWED_PREFIXES = [ + '/abonements', + '/invoices/mine', + '/me', + '/user/settings', + '/user/status', + '/logout', + '/refresh', + '/coffee/active', + '/tax/vat-rates', +]; + +function guestRestriction(req, res, next) { + const user = req.user; + if (!user || user.role !== 'guest') { + return next(); + } + + const urlPath = req.originalUrl.split('?')[0]; + + const isAllowed = GUEST_ALLOWED_PREFIXES.some((prefix) => urlPath.startsWith(prefix)); + + if (isAllowed) { + return next(); + } + + logger.warn('guestRestriction:blocked', { + userId: user.userId || user.id, + route: urlPath, + method: req.method, + }); + + return res.status(403).json({ + success: false, + message: 'Guest accounts can only access subscription features. Please upgrade your account for full access.', + }); +} + +module.exports = guestRestriction; diff --git a/repositories/referral/ReferralTokenRepository.js b/repositories/referral/ReferralTokenRepository.js index 21ebf51..8bdfdf1 100644 --- a/repositories/referral/ReferralTokenRepository.js +++ b/repositories/referral/ReferralTokenRepository.js @@ -197,7 +197,7 @@ class ReferralTokenRepository { u.email AS referrer_email, u.user_type AS referrer_user_type FROM referral_tokens rt - JOIN users u ON rt.created_by_user_id = u.id + LEFT JOIN users u ON rt.created_by_user_id = u.id WHERE rt.token = ? LIMIT 1 `; @@ -209,7 +209,8 @@ class ReferralTokenRepository { token: r.token, max_uses: r.max_uses, uses_remaining: r.uses_remaining, - used_count: r.used_count + used_count: r.used_count, + referrer_id: r.referrer_id }); logger.info('ReferralTokenRepository.getReferrerInfoByToken:success', { token }); } else { diff --git a/repositories/settings/CompanySettingsRepository.js b/repositories/settings/CompanySettingsRepository.js new file mode 100644 index 0000000..a0f9f13 --- /dev/null +++ b/repositories/settings/CompanySettingsRepository.js @@ -0,0 +1,24 @@ +const pool = require('../../database/database'); + +class CompanySettingsRepository { + async get() { + const [rows] = await pool.query('SELECT * FROM company_settings WHERE id = 1'); + return rows[0] || null; + } + + async update({ company_name, company_street, company_postal_city, company_country }) { + await pool.query( + `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country) + VALUES (1, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + company_name = VALUES(company_name), + company_street = VALUES(company_street), + company_postal_city = VALUES(company_postal_city), + company_country = VALUES(company_country)`, + [company_name || '', company_street || '', company_postal_city || '', company_country || ''] + ); + return this.get(); + } +} + +module.exports = CompanySettingsRepository; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index dfe66a3..dc60a58 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -27,6 +27,7 @@ const AbonemmentController = require('../controller/abonemments/AbonemmentContro const NewsController = require('../controller/news/NewsController'); const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW const DevManagementController = require('../controller/dev/DevManagementController'); +const CompanySettingsController = require('../controller/admin/CompanySettingsController'); // small helpers copied from original files @@ -49,6 +50,7 @@ router.get('/user/settings', authMiddleware, UserSettingsController.getSettings) router.get('/users/:id/permissions', authMiddleware, PermissionController.getUserPermissions); router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails); router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo); +router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get); router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts); router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 4f9b9d6..52c640b 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -14,6 +14,7 @@ const PermissionController = require('../controller/permissions/PermissionContro const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const PersonalRegisterController = require('../controller/register/PersonalRegisterController'); const CompanyRegisterController = require('../controller/register/CompanyRegisterController'); +const GuestRegisterController = require('../controller/register/GuestRegisterController'); const PersonalDocumentController = require('../controller/documents/PersonalDocumentController'); const CompanyDocumentController = require('../controller/documents/CompanyDocumentController'); const ContractUploadController = require('../controller/documents/ContractUploadController'); @@ -192,6 +193,10 @@ router.post('/register/company', (req, res) => { console.log('🔗 POST /register/company route accessed'); CompanyRegisterController.register(req, res); }); +router.post('/register/guest', (req, res) => { + console.log('🔗 POST /register/guest route accessed'); + GuestRegisterController.register(req, res); +}); console.log('✅ POST routes configured successfully'); diff --git a/routes/putRoutes.js b/routes/putRoutes.js index d219cc1..b0f4af3 100644 --- a/routes/putRoutes.js +++ b/routes/putRoutes.js @@ -6,6 +6,7 @@ const adminOnly = require('../middleware/adminOnly'); const AdminUserController = require('../controller/admin/AdminUserController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const CoffeeController = require('../controller/admin/CoffeeController'); +const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -17,4 +18,7 @@ router.put('/document-templates/:id', authMiddleware, upload.single('file'), Doc // Admin: update coffee product (supports picture file replacement) router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update); +// Admin: update company settings (invoice address etc.) +router.put('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.update); + module.exports = router; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 2a4c479..b95d5d9 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -97,14 +97,17 @@ class AbonemmentService { const product = await this.getCoffeeProduct(coffeeId); if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); + const capsulePrice = Number(product.price); + const packPrice = capsulePrice * 10; // 10 capsules per pack + totalPacks += packs; - totalPrice += packs * Number(product.price); + totalPrice += packs * packPrice; breakdown.push({ coffee_table_id: coffeeId, coffee_title: product.title || null, packs, - price_per_pack: Number(product.price), + price_per_pack: packPrice, currency: product.currency, }); } @@ -195,9 +198,11 @@ class AbonemmentService { if (!forSelf && effectiveEmail) { const existingUser = await this.findUserByEmail(effectiveEmail); + console.log('[SUBSCRIBE ORDER] Gift flow:', { forSelf, effectiveEmail, existingUser: existingUser?.id || null }); if (!existingUser) { await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null); const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null); + console.log('[SUBSCRIBE ORDER] Referral link generated:', referralLink); if (referralLink) { try { await MailService.sendSubscriptionInvitationEmail({ @@ -231,37 +236,21 @@ class AbonemmentService { const unitOfWork = new UnitOfWork(); await unitOfWork.start(); try { - const repo = new ReferralTokenRepository(unitOfWork); - const tokens = await repo.getTokensByUser(userId); - const now = Date.now(); - const active = (tokens || []).find((t) => { - const statusOk = t.status === 'active'; - const remaining = Number(t.uses_remaining); - const unlimited = Number(t.max_uses) === -1 || remaining === -1; - const remainingOk = unlimited || remaining > 0; - const exp = t.expires_at ? new Date(t.expires_at).getTime() : NaN; - const expiryOk = Number.isNaN(exp) ? true : exp > now; - return statusOk && remainingOk && expiryOk; + // Always create a fresh single-use token for each gift invitation + const created = await ReferralService.createReferralToken({ + userId, + expiresInDays: 7, + maxUses: 1, + unitOfWork, }); - - let tokenValue; - if (active?.token) { - tokenValue = active.token; - } else { - const created = await ReferralService.createReferralToken({ - userId, - expiresInDays: 7, - maxUses: 1, - unitOfWork, - }); - tokenValue = created?.token; - } + const tokenValue = created?.token; + console.log('[REFERRAL LINK] Created new gift token:', tokenValue); await unitOfWork.commit(); if (!tokenValue) return null; const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, ''); - return `${base}/register?ref=${tokenValue}`; + return `${base}/register?ref=${tokenValue}&guest=true`; } catch (error) { await unitOfWork.rollback(error); console.error('[ABONEMENT] getOrCreateReferralLink failed:', error); @@ -340,7 +329,7 @@ class AbonemmentService { coffee_table_id: product.id, coffee_title: product.title || null, packs: 1, - price_per_pack: Number(product.price), + price_per_pack: Number(product.price) * 10, // 10 capsules per pack currency: product.currency, }], purchaser_user_id, // NEW @@ -476,13 +465,16 @@ class AbonemmentService { const product = await this.getCoffeeProduct(coffeeId); if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); + const capsulePrice = Number(product.price); + const packPrice = capsulePrice * 10; // 10 capsules per pack + totalPacks += packs; - totalPrice += packs * Number(product.price); + totalPrice += packs * packPrice; breakdown.push({ coffee_table_id: coffeeId, coffee_title: product.title || null, packs, - price_per_pack: Number(product.price), + price_per_pack: packPrice, currency: product.currency, }); } diff --git a/services/email/MailService.js b/services/email/MailService.js index d737d16..6f23e8a 100644 --- a/services/email/MailService.js +++ b/services/email/MailService.js @@ -269,12 +269,12 @@ class MailService { logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang }); const isDe = lang === 'de'; const subject = isDe - ? 'ProfitPlanet: Einladung zur Registrierung' - : 'ProfitPlanet: Invitation to register'; + ? 'ProfitPlanet: Einladung zum Kaffee-Abonnement' + : 'ProfitPlanet: Invite for Coffee Abonnement'; const text = isDe - ? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich hier: ${referralLink}\n\nViele Grüße\nProfitPlanet Team` - : `Hi,\n\n${inviterName || 'A user'} created a coffee subscription for you.\nPlease register here: ${referralLink}\n\nBest regards\nProfitPlanet Team`; + ? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich als Gastkunde hier: ${referralLink}\n\nSobald Sie sich registriert haben, können Sie Ihr Abonnement einsehen und verwalten.\n\nViele Grüße\nProfitPlanet Team` + : `Hi,\n\n${inviterName || 'A user'} created a coffee abonnement for you.\nPlease register as a guest customer here: ${referralLink}\n\nOnce registered, you can view and manage your subscription.\n\nBest regards\nProfitPlanet Team`; const html = ` @@ -285,21 +285,31 @@ class MailService { - + + +
-

${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}

+
+

${isDe ? 'Einladung zum Kaffee-Abonnement' : 'Invite for Coffee Abonnement'}

${isDe ? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.` - : `${this._escapeForHtml(inviterName || 'A user')} created a coffee subscription for you.`}

-

${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}

+ : `${this._escapeForHtml(inviterName || 'A user')} created a coffee abonnement for you.`}

+

${isDe + ? 'Als Gastkunde können Sie Ihr Abonnement einsehen und verwalten.' + : 'As a guest customer, you can view and manage your subscription.'}

+

${isDe ? 'Bitte registrieren Sie sich über den folgenden Link:' : 'Please register using the link below:'}

- ${isDe ? 'Jetzt registrieren' : 'Register now'} + ${isDe ? 'Jetzt als Gastkunde registrieren' : 'Register as Guest Customer'}

+

${isDe + ? 'Sie erhalten als Gastkunde Zugang zu Ihrem Abonnement. Weitere Funktionen der Plattform stehen Ihnen nach einem Upgrade zur Verfügung.' + : 'As a guest customer you will have access to your subscription. Other platform features are available after upgrading your account.'}

+
diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 76f170d..3d97225 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -8,6 +8,10 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { logger } = require('../../middleware/logger'); const puppeteer = require('puppeteer'); +const fs = require('fs'); +const path = require('path'); + +const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); class InvoiceService { constructor() { @@ -148,20 +152,138 @@ class InvoiceService { `; } - _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) { + _buildItemsTableRows(items, currency) { + if (!Array.isArray(items) || !items.length) { + return `1Subscription item1--`; + } + return items.map((item, i) => { + const desc = this._escapeHtml(item.description || 'Coffee'); + const qty = Number(item.quantity || 0); + const unit = this._formatAmount(item.unit_price || 0, currency); + const total = this._formatAmount(item.line_gross || 0, currency); + return `${i + 1}${desc}${qty}${unit}${total}`; + }).join(''); + } + + _loadInvoiceHtmlTemplate() { + try { + const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice', 'invoiceTemplate.html'); + return fs.readFileSync(templatePath, 'utf8'); + } catch (e) { + logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message }); + return null; + } + } + + async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) { const isDe = lang === 'de'; - const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-'; + const isGift = abonement?.details?.is_for_self === false; const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); + const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-'; + const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0; + + // Load company info from DB + let companyInfo = { company_name: 'ProfitPlanet GmbH', company_street: '', company_postal_city: '', company_country: 'Germany' }; + try { + const repo = new CompanySettingsRepository(); + const row = await repo.get(); + if (row) companyInfo = row; + } catch (e) { + logger.warn('InvoiceService._buildInvoiceTemplateVariables:company_settings_error', { message: e?.message }); + } + + // For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser + // For self subscriptions: "Bill To" = the subscriber + let customerName; + let customerEmail = ''; + let orderedByBlock = ''; + + if (isGift) { + // Recipient info for "Bill To" + const recipientName = abonement?.details?.recipient_name || ''; + const recipientEmail = abonement?.email || invoice.buyer_email || ''; + customerName = recipientName || recipientEmail || '-'; + customerEmail = recipientName ? recipientEmail : ''; + + // Purchaser info for "Ordered by" + const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || ''; + if (purchaserName) { + const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By'; + orderedByBlock = `

${this._escapeHtml(orderedByLabel)}

${this._escapeHtml(purchaserName)}

`; + } + } else { + customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-'; + customerEmail = abonement?.email || invoice.buyer_email || ''; + } + + return { + lang: isDe ? 'de' : 'en', + documentTitle: isDe ? 'Rechnung' : 'Invoice', + invoiceNumber: this._escapeHtml(invoice.invoice_number || ''), + invoiceNumberLabel: isDe ? 'Rechnungsnummer' : 'Invoice Number', + fromLabel: isDe ? 'Von' : 'From', + toLabel: isDe ? 'An' : 'Bill To', + detailsLabel: isDe ? 'Details' : 'Details', + dateLabel: isDe ? 'Datum' : 'Date', + dueDateLabel: isDe ? 'Fällig am' : 'Due Date', + statusLabel: 'Status', + invoiceStatus: this._escapeHtml((invoice.status || 'issued').toUpperCase()), + companyName: this._escapeHtml(companyInfo.company_name || 'ProfitPlanet GmbH'), + companyStreet: this._escapeHtml(companyInfo.company_street || ''), + companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''), + companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'), + customerName: this._escapeHtml(customerName), + customerEmail: this._escapeHtml(customerEmail), + customerStreet: this._escapeHtml(invoice.buyer_street || ''), + customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')), + customerCountry: this._escapeHtml(invoice.buyer_country || ''), + orderedByBlock, + issuedAt: this._escapeHtml(issuedAt), + dueAt: this._escapeHtml(dueAt), + descriptionHeader: isDe ? 'Beschreibung' : 'Description', + qtyHeader: isDe ? 'Menge' : 'Qty', + unitPriceHeader: isDe ? 'Stückpreis' : 'Unit Price', + totalHeader: isDe ? 'Gesamt' : 'Total', + itemsRows: this._buildItemsTableRows(items, invoice.currency), + subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)', + taxLabel: isDe ? 'MwSt.' : 'Tax', + vatRateDisplay: vatRate ? `${vatRate}%` : '0%', + totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)), + totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)), + totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)', + totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)), + paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information', + paymentInfoText: isDe + ? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.' + : 'Please transfer the total amount stating the invoice number as reference.', + footerText: isDe + ? 'Vielen Dank für Ihr Vertrauen.' + : 'Thank you for your business.', + // Legacy key used by S3-stored templates + itemsHtml: this._buildItemsHtml(items, invoice.currency), + }; + } + + async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) { + const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); + + const template = this._loadInvoiceHtmlTemplate(); + if (template) { + return this._renderTemplate(template, variables); + } + + // Absolute fallback if template file is missing + const isDe = lang === 'de'; return ` ${this._escapeHtml(invoice.invoice_number)}

${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}

-

${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}

-

${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}

+

${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}

+

${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}

${isDe ? 'Positionen' : 'Items'}

- -

${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}

+ +

${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}

`; } @@ -236,29 +358,17 @@ class InvoiceService { const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const subject = this._getEmailSubject(lang, invoice.invoice_number); + // Build the full set of template variables once – used by both S3 and local paths + const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); + const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang }); let html = null; if (templateHtml) { - const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || ''; - const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); - const variables = { - invoiceNumber: this._escapeHtml(invoice.invoice_number || ''), - customerName: this._escapeHtml(customerName), - issuedAt: this._escapeHtml(issuedAt), - totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)), - totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)), - totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)), - itemsHtml: this._buildItemsHtml(items, invoice.currency), - }; html = this._renderTemplate(templateHtml, variables); - - if (html && !html.includes('
  • ')) { - html += `

    ${lang === 'de' ? 'Positionen' : 'Items'}

    `; - } } - const htmlForPdf = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); + const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang }); const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf); await this._storeInvoicePdf(invoice, pdfBuffer); diff --git a/services/referral/ReferralService.js b/services/referral/ReferralService.js index efa979d..8731fdc 100644 --- a/services/referral/ReferralService.js +++ b/services/referral/ReferralService.js @@ -382,7 +382,24 @@ class ReferralService { const repo = new ReferralTokenRepository(unitOfWork); const raw = await repo.getReferrerInfoByToken(token); if (!raw) { - logger.warn('ReferralService:getReferrerInfo:not_found', { token }); + // Diagnostic: check if token exists at all (without JOIN) + try { + const conn = unitOfWork.connection; + const [diag] = await conn.query( + 'SELECT id, token, status, created_by_user_id, expires_at, max_uses, uses_remaining FROM referral_tokens WHERE token = ? LIMIT 1', + [token] + ); + if (diag.length) { + logger.warn('ReferralService:getReferrerInfo:token_exists_but_join_failed', { + token, tokenId: diag[0].id, created_by_user_id: diag[0].created_by_user_id, + status: diag[0].status, expires_at: diag[0].expires_at + }); + } else { + logger.warn('ReferralService:getReferrerInfo:token_truly_not_in_db', { token }); + } + } catch (diagErr) { + logger.warn('ReferralService:getReferrerInfo:diagnostic_query_failed', { error: diagErr.message }); + } return { valid: false, reason: 'not_found' }; } const evalResult = this.evaluateTokenRecord(raw); diff --git a/services/user/guest/GuestUserService.js b/services/user/guest/GuestUserService.js new file mode 100644 index 0000000..f17a6f8 --- /dev/null +++ b/services/user/guest/GuestUserService.js @@ -0,0 +1,112 @@ +const PersonalUserRepository = require('../../../repositories/user/personal/PersonalUserRepository'); +const UserStatusService = require('../../status/UserStatusService'); +const UnitOfWork = require('../../../database/UnitOfWork'); +const MailService = require('../../email/MailService'); +const AbonemmentService = require('../../abonemments/AbonemmentService'); +const User = require('../../../models/User'); +const { logger } = require('../../../middleware/logger'); + +const abonemmentService = new AbonemmentService(); + +class GuestUserService { + /** + * Create a guest user account with role='guest'. + * Guest users only have access to subscriptions, nothing else. + */ + static async createGuestUser({ email, password, firstName, lastName, referralEmail, lang }) { + logger.info('GuestUserService.createGuestUser:start', { email, firstName, lastName }); + + const unitOfWork = new UnitOfWork(); + await unitOfWork.start(); + unitOfWork.registerRepository('personalUser', new PersonalUserRepository(unitOfWork)); + + try { + const personalRepo = unitOfWork.getRepository('personalUser'); + + // Check if user already exists + const existing = await personalRepo.findByEmail(email); + if (existing) { + await unitOfWork.rollback(); + throw new Error('User already exists'); + } + + // Create user in DB with role='guest' + const conn = unitOfWork.connection; + const hashedPassword = await User.hashPassword(password); + + const [userResult] = await conn.query( + `INSERT INTO users (email, password, user_type, role) VALUES (?, ?, 'personal', 'guest')`, + [email, hashedPassword] + ); + const userId = userResult.insertId; + + await conn.query( + `INSERT INTO personal_profiles (user_id, first_name, last_name) VALUES (?, ?, ?)`, + [userId, firstName, lastName] + ); + + // Initialize user status as active (skip full registration flow for guests) + await UserStatusService.initializeUserStatus(userId, 'personal', unitOfWork, 'active'); + + // Mark email as verified and profile as completed for guests + await conn.query( + `UPDATE user_status SET email_verified = TRUE, profile_completed = TRUE, registration_completed = TRUE WHERE user_id = ?`, + [userId] + ); + + // Handle referral if provided + if (referralEmail) { + try { + const ReferralService = require('../../referral/ReferralService'); + await ReferralService.processReferral(userId, referralEmail, unitOfWork); + } catch (refErr) { + logger.warn('GuestUserService.createGuestUser:referral_failed', { userId, error: refErr?.message }); + } + } + + // Link any pending gift subscriptions to this user + await abonemmentService.linkGiftFlagsToUser(email, userId); + + // Send a welcome email + const chosenLang = lang || 'en'; + await MailService.sendRegistrationEmail({ + email, + firstName, + lastName, + userType: 'personal', + lang: chosenLang, + }); + + await unitOfWork.commit(); + + logger.info('GuestUserService.createGuestUser:success', { userId, email }); + return { id: userId, email, firstName, lastName, role: 'guest' }; + } catch (error) { + logger.error('GuestUserService.createGuestUser:error', { email, error: error.message }); + await unitOfWork.rollback(error); + throw error; + } + } + + static async findGuestByEmail(email) { + const unitOfWork = new UnitOfWork(); + await unitOfWork.start(); + try { + const conn = unitOfWork.connection; + const [rows] = await conn.query( + `SELECT u.id, u.email, u.role, pp.first_name, pp.last_name + FROM users u + LEFT JOIN personal_profiles pp ON u.id = pp.user_id + WHERE u.email = ? AND u.role = 'guest' LIMIT 1`, + [email] + ); + await unitOfWork.commit(); + return rows[0] || null; + } catch (error) { + await unitOfWork.rollback(error); + throw error; + } + } +} + +module.exports = GuestUserService;