feat: implement guest user registration and company settings management

This commit is contained in:
seaznCode 2026-03-09 22:07:12 +01:00
parent 2c239ad331
commit f85d01af8d
15 changed files with 533 additions and 65 deletions

View File

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

View File

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

View File

@ -168,7 +168,7 @@ const createDatabase = async () => {
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
user_type ENUM('personal', 'company') 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), iban VARCHAR(34),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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'); 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 // 2. personal_profiles table: Details specific to personal users
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS personal_profiles ( CREATE TABLE IF NOT EXISTS personal_profiles (
@ -705,6 +716,24 @@ const createDatabase = async () => {
`); `);
console.log('✅ User settings table created/verified'); 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 --- // --- Rate Limiting Table ---
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS rate_limit ( CREATE TABLE IF NOT EXISTS rate_limit (

View File

@ -77,6 +77,12 @@ async function authMiddleware(req, res, next) {
return res.status(500).json({ success: false, message: 'Internal server error' }); 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(); next();
} catch (error) { } catch (error) {
logger.warn('authMiddleware:tokenInvalid', { logger.warn('authMiddleware:tokenInvalid', {

View File

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

View File

@ -197,7 +197,7 @@ class ReferralTokenRepository {
u.email AS referrer_email, u.email AS referrer_email,
u.user_type AS referrer_user_type u.user_type AS referrer_user_type
FROM referral_tokens rt 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 = ? WHERE rt.token = ?
LIMIT 1 LIMIT 1
`; `;
@ -209,7 +209,8 @@ class ReferralTokenRepository {
token: r.token, token: r.token,
max_uses: r.max_uses, max_uses: r.max_uses,
uses_remaining: r.uses_remaining, 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 }); logger.info('ReferralTokenRepository.getReferrerInfoByToken:success', { token });
} else { } else {

View File

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

View File

@ -27,6 +27,7 @@ const AbonemmentController = require('../controller/abonemments/AbonemmentContro
const NewsController = require('../controller/news/NewsController'); const NewsController = require('../controller/news/NewsController');
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
const DevManagementController = require('../controller/dev/DevManagementController'); const DevManagementController = require('../controller/dev/DevManagementController');
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
// small helpers copied from original files // 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('/users/:id/permissions', authMiddleware, PermissionController.getUserPermissions);
router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails); router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails);
router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo); 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('/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 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

View File

@ -14,6 +14,7 @@ const PermissionController = require('../controller/permissions/PermissionContro
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
const PersonalRegisterController = require('../controller/register/PersonalRegisterController'); const PersonalRegisterController = require('../controller/register/PersonalRegisterController');
const CompanyRegisterController = require('../controller/register/CompanyRegisterController'); const CompanyRegisterController = require('../controller/register/CompanyRegisterController');
const GuestRegisterController = require('../controller/register/GuestRegisterController');
const PersonalDocumentController = require('../controller/documents/PersonalDocumentController'); const PersonalDocumentController = require('../controller/documents/PersonalDocumentController');
const CompanyDocumentController = require('../controller/documents/CompanyDocumentController'); const CompanyDocumentController = require('../controller/documents/CompanyDocumentController');
const ContractUploadController = require('../controller/documents/ContractUploadController'); const ContractUploadController = require('../controller/documents/ContractUploadController');
@ -192,6 +193,10 @@ router.post('/register/company', (req, res) => {
console.log('🔗 POST /register/company route accessed'); console.log('🔗 POST /register/company route accessed');
CompanyRegisterController.register(req, res); 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'); console.log('✅ POST routes configured successfully');

View File

@ -6,6 +6,7 @@ const adminOnly = require('../middleware/adminOnly');
const AdminUserController = require('../controller/admin/AdminUserController'); const AdminUserController = require('../controller/admin/AdminUserController');
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
const CoffeeController = require('../controller/admin/CoffeeController'); const CoffeeController = require('../controller/admin/CoffeeController');
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
const multer = require('multer'); const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() }); 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) // Admin: update coffee product (supports picture file replacement)
router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update); 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; module.exports = router;

View File

@ -97,14 +97,17 @@ class AbonemmentService {
const product = await this.getCoffeeProduct(coffeeId); const product = await this.getCoffeeProduct(coffeeId);
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); 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; totalPacks += packs;
totalPrice += packs * Number(product.price); totalPrice += packs * packPrice;
breakdown.push({ breakdown.push({
coffee_table_id: coffeeId, coffee_table_id: coffeeId,
coffee_title: product.title || null, coffee_title: product.title || null,
packs, packs,
price_per_pack: Number(product.price), price_per_pack: packPrice,
currency: product.currency, currency: product.currency,
}); });
} }
@ -195,9 +198,11 @@ class AbonemmentService {
if (!forSelf && effectiveEmail) { if (!forSelf && effectiveEmail) {
const existingUser = await this.findUserByEmail(effectiveEmail); const existingUser = await this.findUserByEmail(effectiveEmail);
console.log('[SUBSCRIBE ORDER] Gift flow:', { forSelf, effectiveEmail, existingUser: existingUser?.id || null });
if (!existingUser) { if (!existingUser) {
await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null); await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null);
const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null); const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null);
console.log('[SUBSCRIBE ORDER] Referral link generated:', referralLink);
if (referralLink) { if (referralLink) {
try { try {
await MailService.sendSubscriptionInvitationEmail({ await MailService.sendSubscriptionInvitationEmail({
@ -231,37 +236,21 @@ class AbonemmentService {
const unitOfWork = new UnitOfWork(); const unitOfWork = new UnitOfWork();
await unitOfWork.start(); await unitOfWork.start();
try { try {
const repo = new ReferralTokenRepository(unitOfWork); // Always create a fresh single-use token for each gift invitation
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;
});
let tokenValue;
if (active?.token) {
tokenValue = active.token;
} else {
const created = await ReferralService.createReferralToken({ const created = await ReferralService.createReferralToken({
userId, userId,
expiresInDays: 7, expiresInDays: 7,
maxUses: 1, maxUses: 1,
unitOfWork, unitOfWork,
}); });
tokenValue = created?.token; const tokenValue = created?.token;
} console.log('[REFERRAL LINK] Created new gift token:', tokenValue);
await unitOfWork.commit(); await unitOfWork.commit();
if (!tokenValue) return null; if (!tokenValue) return null;
const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, ''); 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) { } catch (error) {
await unitOfWork.rollback(error); await unitOfWork.rollback(error);
console.error('[ABONEMENT] getOrCreateReferralLink failed:', error); console.error('[ABONEMENT] getOrCreateReferralLink failed:', error);
@ -340,7 +329,7 @@ class AbonemmentService {
coffee_table_id: product.id, coffee_table_id: product.id,
coffee_title: product.title || null, coffee_title: product.title || null,
packs: 1, packs: 1,
price_per_pack: Number(product.price), price_per_pack: Number(product.price) * 10, // 10 capsules per pack
currency: product.currency, currency: product.currency,
}], }],
purchaser_user_id, // NEW purchaser_user_id, // NEW
@ -476,13 +465,16 @@ class AbonemmentService {
const product = await this.getCoffeeProduct(coffeeId); const product = await this.getCoffeeProduct(coffeeId);
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); 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; totalPacks += packs;
totalPrice += packs * Number(product.price); totalPrice += packs * packPrice;
breakdown.push({ breakdown.push({
coffee_table_id: coffeeId, coffee_table_id: coffeeId,
coffee_title: product.title || null, coffee_title: product.title || null,
packs, packs,
price_per_pack: Number(product.price), price_per_pack: packPrice,
currency: product.currency, currency: product.currency,
}); });
} }

View File

@ -269,12 +269,12 @@ class MailService {
logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang }); logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang });
const isDe = lang === 'de'; const isDe = lang === 'de';
const subject = isDe const subject = isDe
? 'ProfitPlanet: Einladung zur Registrierung' ? 'ProfitPlanet: Einladung zum Kaffee-Abonnement'
: 'ProfitPlanet: Invitation to register'; : 'ProfitPlanet: Invite for Coffee Abonnement';
const text = isDe 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` ? `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 subscription for you.\nPlease register here: ${referralLink}\n\nBest regards\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 = `<!doctype html> const html = `<!doctype html>
<html> <html>
@ -285,21 +285,31 @@ class MailService {
<td align="center"> <td align="center">
<table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;"> <table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
<tr> <tr>
<td style="padding:20px 24px;background:#111827;color:#ffffff;"> <td style="padding:20px 24px;background:#1C2B4A;color:#ffffff;">
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}</h2> <h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zum Kaffee-Abonnement' : 'Invite for Coffee Abonnement'}</h2>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:22px 24px;"> <td style="padding:22px 24px;">
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe <p style="margin:0 0 12px 0;line-height:1.6;">${isDe
? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.` ? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.`
: `${this._escapeForHtml(inviterName || 'A user')} created a coffee subscription for you.`}</p> : `${this._escapeForHtml(inviterName || 'A user')} created a coffee abonnement for you.`}</p>
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}</p> <p style="margin:0 0 12px 0;line-height:1.6;">${isDe
? 'Als Gastkunde können Sie Ihr Abonnement einsehen und verwalten.'
: 'As a guest customer, you can view and manage your subscription.'}</p>
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte registrieren Sie sich über den folgenden Link:' : 'Please register using the link below:'}</p>
<p style="margin:0;"> <p style="margin:0;">
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:700;">${isDe ? 'Jetzt registrieren' : 'Register now'}</a> <a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#1C2B4A;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:700;font-size:15px;">${isDe ? 'Jetzt als Gastkunde registrieren' : 'Register as Guest Customer'}</a>
</p> </p>
</td> </td>
</tr> </tr>
<tr>
<td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">${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.'}</p>
</td>
</tr>
</table> </table>
</td> </td>
</tr> </tr>

View File

@ -8,6 +8,10 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
const { logger } = require('../../middleware/logger'); const { logger } = require('../../middleware/logger');
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
class InvoiceService { class InvoiceService {
constructor() { constructor() {
@ -148,20 +152,138 @@ class InvoiceService {
</html>`; </html>`;
} }
_buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) { _buildItemsTableRows(items, currency) {
if (!Array.isArray(items) || !items.length) {
return `<tr><td>1</td><td>Subscription item</td><td>1</td><td>-</td><td>-</td></tr>`;
}
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 `<tr><td>${i + 1}</td><td>${desc}</td><td>${qty}</td><td>${unit}</td><td>${total}</td></tr>`;
}).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 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 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 = `<div class="meta-block"><h3>${this._escapeHtml(orderedByLabel)}</h3><p><span class="highlight">${this._escapeHtml(purchaserName)}</span></p></div>`;
}
} 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 `<!doctype html> return `<!doctype html>
<html> <html>
<head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head> <head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head>
<body> <body>
<h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2> <h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2>
<p>${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}</p> <p>${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}</p>
<p>${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}</p> <p>${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}</p>
<h3>${isDe ? 'Positionen' : 'Items'}</h3> <h3>${isDe ? 'Positionen' : 'Items'}</h3>
<ul>${this._buildItemsHtml(items, invoice.currency)}</ul> <ul>${variables.itemsHtml}</ul>
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</strong></p> <p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}</strong></p>
</body> </body>
</html>`; </html>`;
} }
@ -236,29 +358,17 @@ class InvoiceService {
const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const text = this._buildInvoiceMailText({ invoice, items, abonement, lang });
const subject = this._getEmailSubject(lang, invoice.invoice_number); 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 }); const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang });
let html = null; let html = null;
if (templateHtml) { 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); html = this._renderTemplate(templateHtml, variables);
if (html && !html.includes('<li>')) {
html += `<hr><h3>${lang === 'de' ? 'Positionen' : 'Items'}</h3><ul>${variables.itemsHtml}</ul>`;
}
} }
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); const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
await this._storeInvoicePdf(invoice, pdfBuffer); await this._storeInvoicePdf(invoice, pdfBuffer);

View File

@ -382,7 +382,24 @@ class ReferralService {
const repo = new ReferralTokenRepository(unitOfWork); const repo = new ReferralTokenRepository(unitOfWork);
const raw = await repo.getReferrerInfoByToken(token); const raw = await repo.getReferrerInfoByToken(token);
if (!raw) { 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' }; return { valid: false, reason: 'not_found' };
} }
const evalResult = this.evaluateTokenRecord(raw); const evalResult = this.evaluateTokenRecord(raw);

View File

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