feat: abo + profile section

This commit is contained in:
DeathKaioken 2025-12-13 11:15:20 +01:00
parent d408ba89dd
commit 0c78f2c869
8 changed files with 929 additions and 33 deletions

View File

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

View File

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

35
models/Abonemment.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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$%';

View File

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