diff --git a/controller/abonemments/AbonemmentController.js b/controller/abonemments/AbonemmentController.js new file mode 100644 index 0000000..13d9528 --- /dev/null +++ b/controller/abonemments/AbonemmentController.js @@ -0,0 +1,114 @@ +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); +const service = new AbonemmentService(); + +module.exports = { + async subscribe(req, res) { + try { + const result = await service.subscribeOrder({ + userId: req.user?.id || null, + items: req.body.items, + billingInterval: req.body.billing_interval, + intervalCount: req.body.interval_count, + isAutoRenew: req.body.is_auto_renew, + firstName: req.body.firstName, + lastName: req.body.lastName, + email: req.body.email, + street: req.body.street, + postalCode: req.body.postalCode, + city: req.body.city, + country: req.body.country, + frequency: req.body.frequency, + startDate: req.body.startDate, + actorUser: req.user, + referredBy: req.body.referred_by, // NEW: Pass referred_by from frontend + }); + return res.json({ success: true, data: result }); + } catch (err) { + console.error('[ABONEMENT SUBSCRIBE]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, + + async pause(req, res) { + try { + const data = await service.pause({ abonementId: req.params.id, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT PAUSE]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, + + async resume(req, res) { + try { + const data = await service.resume({ abonementId: req.params.id, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT RESUME]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, + + async cancel(req, res) { + try { + const data = await service.cancel({ abonementId: req.params.id, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT CANCEL]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, + + async renew(req, res) { + try { + const data = await service.adminRenew({ abonementId: req.params.id, actorUser: req.user }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT RENEW]', err); + return res.status(403).json({ success: false, message: err.message }); + } + }, + + async getMine(req, res) { + try { + const data = await service.getMyAbonements({ userId: req.user.id }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT MINE]', err); + return res.status(500).json({ success: false, message: 'Internal error' }); + } + }, + + async getHistory(req, res) { + try { + const data = await service.getHistory({ abonementId: req.params.id }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT HISTORY]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, + + async adminList(req, res) { + try { + const data = await service.adminList({ status: req.query.status }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT ADMIN LIST]', err); + return res.status(403).json({ success: false, message: err.message }); + } + }, + + async getReferredSubscriptions(req, res) { + try { + const data = await service.getReferredSubscriptions({ + userId: req.user.id, + email: req.user.email, + }); + return res.json({ success: true, data }); + } catch (err) { + console.error('[ABONEMENT REFERRED SUBSCRIPTIONS]', err); + return res.status(400).json({ success: false, message: err.message }); + } + }, +}; \ No newline at end of file diff --git a/database/createDb.js b/database/createDb.js index 28584ad..e45699a 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -617,52 +617,68 @@ async function createDatabase() { object_storage_id VARCHAR(255) NULL, original_filename VARCHAR(255) NULL, state BOOLEAN NOT NULL DEFAULT TRUE, + pack_group VARCHAR(100) NULL, 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, + INDEX idx_pack_group (pack_group) ); `); console.log('✅ Coffee table (simplified) created/verified'); - // --- Pools Table --- + // --- Coffee Abonements (subscriptions) --- await connection.query(` - CREATE TABLE IF NOT EXISTS pools ( - id INT AUTO_INCREMENT PRIMARY KEY, - pool_name VARCHAR(255) NOT NULL, - description TEXT, - price DECIMAL(10,2) NOT NULL DEFAULT 0.00, - pool_type ENUM('coffee', 'other') NOT NULL DEFAULT 'other', - is_active BOOLEAN DEFAULT TRUE, - created_by INT NULL, - updated_by INT NULL, + CREATE TABLE IF NOT EXISTS coffee_abonements ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + pack_group VARCHAR(100) NOT NULL DEFAULT '', + status ENUM('active','paused','canceled','expired') NOT NULL DEFAULT 'active', + started_at DATETIME NOT NULL, + ended_at DATETIME NULL, + next_billing_at DATETIME NULL, + billing_interval ENUM('day','week','month','year') NOT NULL, + interval_count INT UNSIGNED NOT NULL DEFAULT 1, + price DECIMAL(10,2) NOT NULL, + currency CHAR(3) NOT NULL, + is_auto_renew TINYINT(1) NOT NULL DEFAULT 1, + notes VARCHAR(255) NULL, + pack_breakdown JSON NULL, + first_name VARCHAR(100) NULL, + last_name VARCHAR(100) NULL, + email VARCHAR(255) NULL, + street VARCHAR(255) NULL, + postal_code VARCHAR(20) NULL, + city VARCHAR(100) NULL, + country VARCHAR(100) NULL, + frequency VARCHAR(50) NULL, + referred_by INT NULL, -- NEW: user_id of the logged-in user who referred the subscription created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, - FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, - INDEX idx_pool_type (pool_type), - INDEX idx_is_active (is_active) + CONSTRAINT fk_abon_referred_by FOREIGN KEY (referred_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, + INDEX idx_pack_group (pack_group), + INDEX idx_abon_status (status), + INDEX idx_abon_billing (next_billing_at), + INDEX idx_abon_created (created_at) ); `); - console.log('✅ Pools table created/verified'); + console.log('✅ Coffee abonements table updated'); - // --- Affiliates Table --- + // --- Coffee Abonement History --- await connection.query(` - CREATE TABLE IF NOT EXISTS affiliates ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - url VARCHAR(512) NOT NULL, - object_storage_id VARCHAR(255) NULL, - original_filename VARCHAR(255) NULL, - category VARCHAR(100) NOT NULL, - is_active BOOLEAN DEFAULT TRUE, - commission_rate VARCHAR(50), + CREATE TABLE IF NOT EXISTS coffee_abonement_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + abonement_id BIGINT NOT NULL, + event_type VARCHAR(50) NOT NULL, + event_at DATETIME NOT NULL, + actor_user_id INT NULL, + details JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_category (category), - INDEX idx_is_active (is_active) + CONSTRAINT fk_hist_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_hist_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, + INDEX idx_hist_abon (abonement_id), + INDEX idx_hist_event_at (event_at), + INDEX idx_hist_event_type (event_type) ); `); - console.log('✅ Affiliates table created/verified'); + console.log('✅ Coffee abonement history table created/verified'); // --- Matrix: Global 5-ary tree config and relations --- await connection.query(` diff --git a/models/Abonemment.js b/models/Abonemment.js new file mode 100644 index 0000000..5873906 --- /dev/null +++ b/models/Abonemment.js @@ -0,0 +1,35 @@ +class Abonemment { + constructor(row) { + this.id = row.id; + this.status = row.status; + this.started_at = row.started_at; + this.ended_at = row.ended_at; + this.next_billing_at = row.next_billing_at; + this.billing_interval = row.billing_interval; + this.interval_count = row.interval_count; + this.price = row.price; + this.currency = row.currency; + this.is_auto_renew = !!row.is_auto_renew; + this.notes = row.notes; + this.pack_group = row.pack_group; + this.pack_breakdown = row.pack_breakdown ? (typeof row.pack_breakdown === 'string' ? JSON.parse(row.pack_breakdown) : row.pack_breakdown) : null; + this.first_name = row.first_name; + this.last_name = row.last_name; + this.email = row.email; + this.street = row.street; + this.postal_code = row.postal_code; + this.city = row.city; + this.country = row.country; + this.frequency = row.frequency; + this.referred_by = row.referred_by; // NEW + this.created_at = row.created_at; + this.updated_at = row.updated_at; + } + + get isActive() { return this.status === 'active'; } + get isPaused() { return this.status === 'paused'; } + get isCanceled() { return this.status === 'canceled'; } + get isExpired() { return this.status === 'expired'; } +} + +module.exports = Abonemment; diff --git a/repositories/abonemments/AbonemmentRepository.js b/repositories/abonemments/AbonemmentRepository.js new file mode 100644 index 0000000..7720c20 --- /dev/null +++ b/repositories/abonemments/AbonemmentRepository.js @@ -0,0 +1,353 @@ +const pool = require('../../database/database'); +const Abonemment = require('../../models/Abonemment'); + +class AbonemmentRepository { + async createAbonement(referredBy, snapshot) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [res] = await conn.query( + `INSERT INTO coffee_abonements + (pack_group, status, started_at, ended_at, next_billing_at, billing_interval, interval_count, price, currency, is_auto_renew, notes, pack_breakdown, first_name, last_name, email, street, postal_code, city, country, frequency, referred_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`, + [ + snapshot.pack_group || '', + snapshot.status || 'active', + snapshot.started_at, + snapshot.ended_at || null, + snapshot.next_billing_at || null, + snapshot.billing_interval, + snapshot.interval_count, + snapshot.price, + snapshot.currency, + snapshot.is_auto_renew ? 1 : 0, + snapshot.notes || null, + snapshot.pack_breakdown ? JSON.stringify(snapshot.pack_breakdown) : null, + snapshot.first_name || null, + snapshot.last_name || null, + snapshot.email || null, + snapshot.street || null, + snapshot.postal_code || null, + snapshot.city || null, + snapshot.country || null, + snapshot.frequency || null, + referredBy || null, // Ensure referred_by is stored + ], + ); + const abonementId = res.insertId; + + const historyDetails = { ...(snapshot.details || {}), pack_group: snapshot.pack_group }; + await conn.query( + `INSERT INTO coffee_abonement_history + (abonement_id, event_type, event_at, actor_user_id, details, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, + [ + abonementId, + 'created', + snapshot.started_at, + referredBy || snapshot.actor_user_id || null, // Use referredBy as actor_user_id if available + JSON.stringify(historyDetails), + ], + ); + + await conn.commit(); + return this.getAbonementById(abonementId); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + + async getAbonementById(id) { + const [rows] = await pool.query(`SELECT * FROM coffee_abonements WHERE id = ?`, [id]); + return rows[0] ? new Abonemment(rows[0]) : null; + } + + async listByUser(userId, { status, limit = 50, offset = 0 } = {}) { + const params = [userId]; + let sql = `SELECT * FROM coffee_abonements WHERE user_id = ?`; + if (status) { + sql += ` AND status = ?`; + params.push(status); + } + sql += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`; + params.push(Number(limit), Number(offset)); + const [rows] = await pool.query(sql, params); + return rows.map((r) => new Abonemment(r)); + } + + async updateStatus(id, newStatus, { ended_at, updated_at = new Date() } = {}) { + await pool.query( + `UPDATE coffee_abonements SET status = ?, ended_at = ?, updated_at = ? WHERE id = ?`, + [newStatus, ended_at || null, updated_at, id], + ); + return this.getAbonementById(id); + } + + async updateBilling(id, next_billing_at, updated_at = new Date()) { + await pool.query( + `UPDATE coffee_abonements SET next_billing_at = ?, updated_at = ? WHERE id = ?`, + [next_billing_at, updated_at, id], + ); + return this.getAbonementById(id); + } + + async appendHistory(abonementId, eventType, actorUserId, details = {}, eventAt = new Date()) { + await pool.query( + `INSERT INTO coffee_abonement_history + (abonement_id, event_type, event_at, actor_user_id, details, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, + [abonementId, eventType, eventAt, actorUserId || null, JSON.stringify(details || {})], + ); + } + + async transitionStatus(id, newStatus, historyPayload = {}) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `UPDATE coffee_abonements SET status = ?, ended_at = ?, updated_at = ? WHERE id = ?`, + [ + newStatus, + historyPayload.ended_at || null, + historyPayload.updated_at || new Date(), + id, + ], + ); + await conn.query( + `INSERT INTO coffee_abonement_history + (abonement_id, event_type, event_at, actor_user_id, details, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, + [ + id, + historyPayload.event_type || 'updated', + historyPayload.event_at || new Date(), + historyPayload.actor_user_id || null, + JSON.stringify(historyPayload.details || {}), + ], + ); + await conn.commit(); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + return this.getAbonementById(id); + } + + async transitionBilling(id, nextBillingAt, historyPayload = {}) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `UPDATE coffee_abonements SET next_billing_at = ?, updated_at = ? WHERE id = ?`, + [nextBillingAt, historyPayload.updated_at || new Date(), id], + ); + await conn.query( + `INSERT INTO coffee_abonement_history + (abonement_id, event_type, event_at, actor_user_id, details, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, + [ + id, + historyPayload.event_type || 'renewed', + historyPayload.event_at || new Date(), + historyPayload.actor_user_id || null, + JSON.stringify(historyPayload.details || {}), + ], + ); + await conn.commit(); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + return this.getAbonementById(id); + } + + async listDueForBilling(now) { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements + WHERE status = 'active' AND is_auto_renew = 1 AND next_billing_at IS NOT NULL AND next_billing_at <= ? + ORDER BY next_billing_at ASC`, + [now], + ); + return rows.map((r) => new Abonemment(r)); + } + + async listActiveByProduct(productId) { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements WHERE coffee_table_id = ? AND status = 'active'`, + [productId], + ); + return rows.map((r) => new Abonemment(r)); + } + + async listHistory(abonementId) { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonement_history WHERE abonement_id = ? ORDER BY event_at ASC, id ASC`, + [abonementId], + ); + return rows.map((r) => ({ + id: r.id, + abonement_id: r.abonement_id, + event_type: r.event_type, + event_at: r.event_at, + actor_user_id: r.actor_user_id, + details: r.details ? JSON.parse(r.details) : {}, + created_at: r.created_at, + })); + } + + async findActiveOrPausedByUserAndProduct(userId, packGroup) { + const normalizedUserId = userId ?? null; + const normalizedPackGroup = packGroup || ''; + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements + WHERE pack_group = ? AND user_id <=> ? AND status IN ('active','paused') + ORDER BY created_at DESC LIMIT 1`, + [normalizedPackGroup, normalizedUserId], + ); + return rows[0] ? new Abonemment(rows[0]) : null; + } + + async updateExistingAbonementForSubscribe(id, snapshot) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `UPDATE coffee_abonements + SET status = 'active', + pack_group = ?, + started_at = IFNULL(started_at, ?), + next_billing_at = ?, + billing_interval = ?, + interval_count = ?, + price = ?, + currency = ?, + is_auto_renew = ?, + notes = ?, + recipient_email = ?, + purchaser_user_id = IFNULL(purchaser_user_id, ?), + pack_breakdown = ?, -- NEW + updated_at = NOW() + WHERE id = ?`, + [ + snapshot.pack_group || '', + snapshot.started_at, + snapshot.next_billing_at, + snapshot.billing_interval, + snapshot.interval_count, + snapshot.price, + snapshot.currency, + snapshot.is_auto_renew ? 1 : 0, + snapshot.notes || null, + snapshot.recipient_email || null, + snapshot.purchaser_user_id ?? null, + snapshot.pack_breakdown ? JSON.stringify(snapshot.pack_breakdown) : null, + id, + ], + ); + const historyDetails = { ...(snapshot.details || {}), reused_existing: true, pack_group: snapshot.pack_group }; + if (snapshot.recipient) historyDetails.recipient = snapshot.recipient; + await conn.query( + `INSERT INTO coffee_abonement_history + (abonement_id, event_type, event_at, actor_user_id, details, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, + [id, 'merged_subscribe', snapshot.started_at, snapshot.actor_user_id || null, JSON.stringify(historyDetails)], + ); + await conn.commit(); + return this.getAbonementById(id); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + + // Helper: attempt insert into alias table if it exists + async tryAliasInsert(email, abonementId, createdByUserId) { + try { + await pool.query( + `INSERT INTO no_user_aboo_mails (email, abonement_id, status, source, created_by_user_id, created_at, updated_at) + VALUES (?, ?, 'pending', 'subscribe', ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE status = 'pending', source = 'subscribe', created_by_user_id = VALUES(created_by_user_id), updated_at = NOW()`, + [email, abonementId, createdByUserId || null], + ); + } catch (e) { + if (!(e && e.code === 'ER_NO_SUCH_TABLE')) throw e; + } + } + + // Insert/update pending ownership by email (primary table), with alias fallback + async upsertNoUserAboMail(email, abonementId, createdByUserId) { + const normalized = typeof email === 'string' ? email.trim().toLowerCase() : null; + console.log('[UPSERT NO USER ABO MAIL] Normalized email:', normalized); // NEW + console.log('[UPSERT NO USER ABO MAIL] Abonement ID:', abonementId); // NEW + console.log('[UPSERT NO USER ABO MAIL] Created by user ID:', createdByUserId); // NEW + + if (!normalized) { + console.log('[UPSERT NO USER ABO MAIL] Skipping due to invalid email'); // NEW + return; + } + + try { + await pool.query( + `INSERT INTO no_user_abo_mails (email, abonement_id, status, source, created_by_user_id, created_at, updated_at) + VALUES (?, ?, 'pending', 'subscribe', ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE status = 'pending', source = 'subscribe', created_by_user_id = VALUES(created_by_user_id), updated_at = NOW()`, + [normalized, abonementId, createdByUserId || null], + ); + console.log('[UPSERT NO USER ABO MAIL] Successfully inserted/updated record'); // NEW + } catch (err) { + console.error('[UPSERT NO USER ABO MAIL] Error inserting/updating record:', err); // NEW + throw err; + } + } + + // Fetch pending ownership rows for an email + async findPendingNoUserAboMailsByEmail(email) { + const normalized = typeof email === 'string' ? email.trim().toLowerCase() : null; + console.log('[FIND PENDING NO USER ABO MAILS] Normalized email:', normalized); // NEW + + if (!normalized) { + console.log('[FIND PENDING NO USER ABO MAILS] Skipping due to invalid email'); // NEW + return []; + } + + const [rows] = await pool.query( + `SELECT * FROM no_user_abo_mails WHERE email = ? AND status = 'pending'`, + [normalized], + ); + console.log('[FIND PENDING NO USER ABO MAILS] Found rows:', rows); // NEW + return rows || []; + } + + // Mark a pending ownership record as linked + async markNoUserAboMailLinked(id) { + console.log('[MARK NO USER ABO MAIL LINKED] Marking record as linked for ID:', id); // NEW + await pool.query(`UPDATE no_user_abo_mails SET status = 'linked', updated_at = NOW() WHERE id = ?`, [id]); + console.log('[MARK NO USER ABO MAIL LINKED] Successfully marked record as linked'); // NEW + } + + // Link abonement to a user (on registration) + async linkAbonementToUser(abonementId, userId) { + await pool.query(`UPDATE coffee_abonements SET user_id = ?, updated_at = NOW() WHERE id = ?`, [userId, abonementId]); + } + + async findByReferredByAndEmail(referredBy, email) { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements + WHERE referred_by = ? AND email = ? + ORDER BY created_at DESC`, + [referredBy, email], + ); + return rows.map((row) => new Abonemment(row)); + } +} + +module.exports = AbonemmentRepository; \ No newline at end of file diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 8737f8d..44655eb 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -19,6 +19,7 @@ const CoffeeController = require('../controller/admin/CoffeeController'); const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); +const AbonemmentController = require('../controller/abonemments/AbonemmentController'); // small helpers copied from original files function adminOnly(req, res, next) { @@ -147,5 +148,9 @@ router.get('/admin/affiliates', authMiddleware, adminOnly, AffiliateController.l // Public Affiliates Route (Active only) router.get('/affiliates/active', AffiliateController.listActive); -// export +// Abonement GETs +router.get('/abonements/mine', authMiddleware, AbonemmentController.getMine); +router.get('/abonements/:id/history', authMiddleware, AbonemmentController.getHistory); +router.get('/admin/abonements', authMiddleware, adminOnly, AbonemmentController.adminList); + module.exports = router; \ No newline at end of file diff --git a/routes/postRoutes.js b/routes/postRoutes.js index dd61525..4947aa0 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -25,6 +25,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // Ma const PoolController = require('../controller/pool/PoolController'); const TaxController = require('../controller/tax/taxController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); +const AbonemmentController = require('../controller/abonemments/AbonemmentController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -118,6 +119,27 @@ function forceCompanyForAdmin(req, res, next) { next(); } +// NEW: route-specific helper to ensure req.user has id/email from POST body +function ensureUserFromBody(req, res, next) { + try { + const bodyUserId = req.body?.userId ?? req.body?.id; + const bodyEmail = req.body?.email; + + if (!req.user) req.user = {}; + if (!req.user.id && bodyUserId) req.user.id = bodyUserId; + if (!req.user.email && bodyEmail) req.user.email = bodyEmail; + + // keep user_type/userType normalization intact + if (!req.user.userType && req.user.user_type) req.user.userType = req.user.user_type; + if (!req.user.user_type && req.user.userType) req.user.user_type = req.user.userType; + + next(); + } catch (e) { + console.error('[ensureUserFromBody] Error:', e); + next(); + } +} + // Company-stamp POST router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload); // Admin: create coffee product (supports multipart file 'picture') @@ -135,6 +157,15 @@ router.post('/tax/vat-rates/import', authMiddleware, adminOnly, upload.single('f // NEW: Admin create affiliate with logo upload router.post('/admin/affiliates', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.create); +// Abonement POSTs +router.post('/abonements/subscribe', authMiddleware, AbonemmentController.subscribe); +router.post('/abonements/:id/pause', authMiddleware, AbonemmentController.pause); +router.post('/abonements/:id/resume', authMiddleware, AbonemmentController.resume); +router.post('/abonements/:id/cancel', authMiddleware, AbonemmentController.cancel); +router.post('/admin/abonements/:id/renew', authMiddleware, adminOnly, AbonemmentController.renew); +// CHANGED: ensure req.user has id/email from body for this route +router.post('/abonements/referred', authMiddleware, ensureUserFromBody, AbonemmentController.getReferredSubscriptions); + // Existing registration handlers (keep) router.post('/register/personal', (req, res) => { console.log('🔗 POST /register/personal route accessed'); diff --git a/scripts/createAdminUser.js b/scripts/createAdminUser.js index 721dbb3..e9eaf30 100644 --- a/scripts/createAdminUser.js +++ b/scripts/createAdminUser.js @@ -3,7 +3,7 @@ const UnitOfWork = require('../database/UnitOfWork'); const argon2 = require('argon2'); async function createAdminUser() { - const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com'; + const adminEmail = process.env.ADMIN_EMAIL || 'alexander.ibrahim.ai@gmail.com'; // const adminEmail = process.env.ADMIN_EMAIL || 'loki.aahi@gmail.com'; // const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%'; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js new file mode 100644 index 0000000..44d6fe1 --- /dev/null +++ b/services/abonemments/AbonemmentService.js @@ -0,0 +1,342 @@ +const pool = require('../../database/database'); +const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository'); + +class AbonemmentService { + constructor() { + this.repo = new AbonemmentRepository(); + } + + isAdmin(user) { + return user && ['admin', 'super_admin'].includes(user.role); + } + + addInterval(date, interval, count) { + const d = new Date(date); + if (interval === 'day') d.setDate(d.getDate() + count); + if (interval === 'week') d.setDate(d.getDate() + 7 * count); + if (interval === 'month') d.setMonth(d.getMonth() + count); + if (interval === 'year') d.setFullYear(d.getFullYear() + count); + return d; + } + + async getCoffeeProduct(id) { + const [rows] = await pool.query( + `SELECT id, price, currency, billing_interval, interval_count, state AS is_active, pack_group FROM coffee_table WHERE id = ?`, + [id], + ); + return rows[0] || null; + } + + // Helper: normalize email + normalizeEmail(email) { + return typeof email === 'string' ? email.trim().toLowerCase() : null; + } + + // NEW: single bundle subscribe using items array (12 packs, 120 capsules) + async subscribeOrder({ + items, + billingInterval, + intervalCount, + isAutoRenew, + firstName, + lastName, + email, + street, + postalCode, + city, + country, + frequency, + startDate, + actorUser, + referredBy, // NEW: referred_by field + }) { + console.log('[SUBSCRIBE ORDER] Start processing subscription order'); + console.log('[SUBSCRIBE ORDER] Payload:', { + firstName, + lastName, + email, + street, + postalCode, + city, + country, + frequency, + startDate, + }); + + const normalizedEmail = this.normalizeEmail(email); + + if (!Array.isArray(items) || items.length === 0) throw new Error('items must be a non-empty array'); + + let totalPacks = 0; + let totalPrice = 0; + const breakdown = []; + + for (const item of items) { + const coffeeId = item?.coffeeId; + const packs = Number(item?.quantity ?? 0); + if (!coffeeId) throw new Error('coffeeId is required for each item'); + if (!Number.isFinite(packs) || packs <= 0) throw new Error('quantity must be a positive integer per item'); + + const product = await this.getCoffeeProduct(coffeeId); + if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); + + totalPacks += packs; + totalPrice += packs * Number(product.price); + + breakdown.push({ + coffee_table_id: coffeeId, + packs, + price_per_pack: Number(product.price), + currency: product.currency, + }); + } + + const now = new Date(); + const startDateObj = startDate ? new Date(startDate) : now; + const nextBilling = this.addInterval(startDateObj, billingInterval || 'month', intervalCount || 1); + + const snapshot = { + status: 'active', + started_at: startDateObj, + next_billing_at: nextBilling, + billing_interval: billingInterval || 'month', + interval_count: intervalCount || 1, + price: Number(totalPrice.toFixed(2)), + currency: breakdown[0]?.currency || 'EUR', + is_auto_renew: isAutoRenew !== false, + actor_user_id: actorUser?.id, + details: { origin: 'subscribe_order', total_packs: totalPacks }, + pack_breakdown: breakdown, + first_name: firstName, + last_name: lastName, + email: normalizedEmail, + street, + postal_code: postalCode, + city, + country, + frequency, + referred_by: referredBy || null, // Pass referred_by to snapshot + }; + + return this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository + } + + async subscribe({ + userId, + coffeeId, + billingInterval, + intervalCount, + isAutoRenew, + targetUserId, + recipientName, + recipientEmail, + recipientNotes, + actorUser, + referredBy, // NEW: referred_by field + }) { + console.log('[SUBSCRIBE] Start processing single subscription'); // NEW + console.log('[SUBSCRIBE] Recipient email:', recipientEmail); // NEW + + const normalizedRecipientEmail = this.normalizeEmail(recipientEmail); + console.log('[SUBSCRIBE] Normalized recipient email:', normalizedRecipientEmail); // NEW + + if (coffeeId === undefined || coffeeId === null) throw new Error('coffeeId is required'); + + const hasRecipientFields = recipientName || normalizedRecipientEmail || recipientNotes; + if (targetUserId && hasRecipientFields) throw new Error('Provide either target_user_id or recipient fields, not both'); + if (hasRecipientFields && !normalizedRecipientEmail) throw new Error('recipient_email is required when subscribing for another person'); + + const safeUserId = userId ?? null; + const isForMe = !targetUserId && !hasRecipientFields; + const ownerUserId = targetUserId ?? (hasRecipientFields ? null : safeUserId); + const purchaserUserId = isForMe ? null : actorUser?.id || null; + + const recipientMeta = targetUserId + ? { target_user_id: targetUserId } + : hasRecipientFields + ? { recipient_name: recipientName, recipient_email: normalizedRecipientEmail, recipient_notes: recipientNotes } + : null; + + const product = await this.getCoffeeProduct(coffeeId); + if (!product || !product.is_active) throw new Error('Product not available'); + + const canonicalPackGroup = product.pack_group || `product:${coffeeId}`; + const now = new Date(); + const nextBilling = this.addInterval( + now, + billingInterval || product.billing_interval, + intervalCount || product.interval_count || 1, + ); + + const details = { origin: 'subscribe', pack_group: canonicalPackGroup }; + if (recipientMeta) details.recipient = recipientMeta; + + const snapshot = { + status: 'active', + pack_group: canonicalPackGroup, + started_at: now, + next_billing_at: nextBilling, + billing_interval: billingInterval || product.billing_interval, + interval_count: intervalCount || product.interval_count || 1, + price: product.price, + currency: product.currency, + is_auto_renew: isAutoRenew !== false, + actor_user_id: actorUser?.id, + notes: recipientMeta && recipientMeta.recipient_name ? recipientMeta.recipient_name : undefined, + recipient_email: normalizedRecipientEmail || null, // CHANGED + details, + recipient: recipientMeta || undefined, + purchaser_user_id: purchaserUserId, // NEW + referred_by: referredBy || null, // Pass referred_by to snapshot + }; + + const existing = await this.repo.findActiveOrPausedByUserAndProduct(ownerUserId ?? null, canonicalPackGroup); + const abonement = existing + ? await this.repo.updateExistingAbonementForSubscribe(existing.id, snapshot) + : await this.repo.createAbonement(ownerUserId ?? null, snapshot, referredBy); // Pass referredBy to repository + + console.log('[SUBSCRIBE] Single subscription completed successfully'); // NEW + return abonement; + } + + // NEW: authorization helper + canManageAbonement(abon, actorUser) { + if (this.isAdmin(actorUser)) return true; + const actorId = actorUser?.id; + if (!actorId) return false; + if (abon.user_id && abon.user_id === actorId) return true; + if (!abon.user_id && abon.purchaser_user_id && abon.purchaser_user_id === actorId) return true; + return false; + } + + async pause({ abonementId, actorUser }) { + const abon = await this.repo.getAbonementById(abonementId); + if (!abon) throw new Error('Not found'); + if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW + if (!abon.isActive) throw new Error('Only active abonements can be paused'); + return this.repo.transitionStatus(abonementId, 'paused', { + event_type: 'paused', + actor_user_id: actorUser?.id, + details: { pack_group: abon.pack_group }, + }); + } + + async resume({ abonementId, actorUser }) { + const abon = await this.repo.getAbonementById(abonementId); + if (!abon) throw new Error('Not found'); + if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW + if (!abon.isPaused) throw new Error('Only paused abonements can be resumed'); + return this.repo.transitionStatus(abonementId, 'active', { + event_type: 'resumed', + actor_user_id: actorUser?.id, + details: { pack_group: abon.pack_group }, + }); + } + + async cancel({ abonementId, actorUser }) { + const abon = await this.repo.getAbonementById(abonementId); + if (!abon) throw new Error('Not found'); + if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW + if (abon.isCanceled) return abon; + return this.repo.transitionStatus(abonementId, 'canceled', { + event_type: 'canceled', + actor_user_id: actorUser?.id, + ended_at: new Date(), + details: { pack_group: abon.pack_group }, + }); + } + + async renew({ abonementId, actorUser, invoiceId }) { + const abon = await this.repo.getAbonementById(abonementId); + if (!abon) throw new Error('Not found'); + if (!abon.isActive) throw new Error('Only active abonements can be renewed'); + const next = this.addInterval(new Date(abon.next_billing_at || new Date()), abon.billing_interval, abon.interval_count); + return this.repo.transitionBilling(abonementId, next, { + event_type: 'renewed', + actor_user_id: actorUser?.id || null, + details: { pack_group: abon.pack_group, ...(invoiceId ? { invoiceId } : {}) }, + }); + } + + async getMyAbonements({ userId }) { + return this.repo.listByUser(userId); + } + + async getHistory({ abonementId }) { + return this.repo.listHistory(abonementId); + } + + async linkGiftFlagsToUser(email, userId) { + const normalizedEmail = this.normalizeEmail(email); // NEW + const pending = await this.repo.findPendingNoUserAboMailsByEmail(normalizedEmail); + for (const row of pending) { + await this.repo.linkAbonementToUser(row.abonement_id, userId); + await this.repo.appendHistory( + row.abonement_id, + 'gift_linked', + userId, + { email, pack_group: (await this.repo.getAbonementById(row.abonement_id))?.pack_group || null } + ); + await this.repo.markNoUserAboMailLinked(row.id); // CHANGED + } + return pending.length; + } + + async adminList({ status }) { + const [rows] = await pool.query( + `SELECT * FROM coffee_abonements ${status ? 'WHERE status = ?' : ''} ORDER BY created_at DESC LIMIT 200`, + status ? [status] : [], + ); + return rows; + } + + async adminRenew({ abonementId, actorUser }) { + if (!this.isAdmin(actorUser)) throw new Error('Forbidden'); + return this.renew({ abonementId, actorUser }); + } + + async getReferredSubscriptions({ userId, email }) { + if (!userId || !email) throw new Error('User ID and email are required'); + const rows = await this.repo.findByReferredByAndEmail(userId, email); + + // Collect distinct coffee_table_ids from pack_breakdown + const idsSet = new Set(); + for (const r of rows) { + const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : []; + for (const item of breakdown) { + const id = item?.coffee_table_id; + if (id !== undefined && id !== null) idsSet.add(Number(id)); + } + } + const ids = Array.from(idsSet); + let nameMap = {}; + if (ids.length) { + const [nameRows] = await pool.query( + `SELECT id, title FROM coffee_table WHERE id IN (${ids.map(() => '?').join(',')})`, + ids + ); + nameMap = (nameRows || []).reduce((acc, row) => { + acc[Number(row.id)] = row.title; // CHANGED: use title + return acc; + }, {}); + } + + // Attach coffee_name to each pack_breakdown item + const enriched = rows.map(r => { + const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : []; + const withNames = breakdown.map(item => ({ + ...item, + coffee_name: nameMap[Number(item.coffee_table_id)] || null, + })); + // Return a plain object with enriched breakdown + return { + ...r, + pack_breakdown: withNames, + }; + }); + + return enriched; + } +} + +module.exports = AbonemmentService; \ No newline at end of file