feat: abo + profile section
This commit is contained in:
parent
d408ba89dd
commit
0c78f2c869
114
controller/abonemments/AbonemmentController.js
Normal file
114
controller/abonemments/AbonemmentController.js
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -617,52 +617,68 @@ async function createDatabase() {
|
|||||||
object_storage_id VARCHAR(255) NULL,
|
object_storage_id VARCHAR(255) NULL,
|
||||||
original_filename VARCHAR(255) NULL,
|
original_filename VARCHAR(255) NULL,
|
||||||
state BOOLEAN NOT NULL DEFAULT TRUE,
|
state BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
pack_group VARCHAR(100) NULL,
|
||||||
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,
|
||||||
|
INDEX idx_pack_group (pack_group)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
console.log('✅ Coffee table (simplified) created/verified');
|
console.log('✅ Coffee table (simplified) created/verified');
|
||||||
|
|
||||||
// --- Pools Table ---
|
// --- Coffee Abonements (subscriptions) ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS pools (
|
CREATE TABLE IF NOT EXISTS coffee_abonements (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
pool_name VARCHAR(255) NOT NULL,
|
pack_group VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
description TEXT,
|
status ENUM('active','paused','canceled','expired') NOT NULL DEFAULT 'active',
|
||||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
started_at DATETIME NOT NULL,
|
||||||
pool_type ENUM('coffee', 'other') NOT NULL DEFAULT 'other',
|
ended_at DATETIME NULL,
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
next_billing_at DATETIME NULL,
|
||||||
created_by INT NULL,
|
billing_interval ENUM('day','week','month','year') NOT NULL,
|
||||||
updated_by INT 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,
|
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,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
CONSTRAINT fk_abon_referred_by FOREIGN KEY (referred_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_pack_group (pack_group),
|
||||||
INDEX idx_pool_type (pool_type),
|
INDEX idx_abon_status (status),
|
||||||
INDEX idx_is_active (is_active)
|
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(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS affiliates (
|
CREATE TABLE IF NOT EXISTS coffee_abonement_history (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
abonement_id BIGINT NOT NULL,
|
||||||
description TEXT,
|
event_type VARCHAR(50) NOT NULL,
|
||||||
url VARCHAR(512) NOT NULL,
|
event_at DATETIME NOT NULL,
|
||||||
object_storage_id VARCHAR(255) NULL,
|
actor_user_id INT NULL,
|
||||||
original_filename VARCHAR(255) NULL,
|
details JSON NULL,
|
||||||
category VARCHAR(100) NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
commission_rate VARCHAR(50),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
CONSTRAINT fk_hist_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
INDEX idx_category (category),
|
CONSTRAINT fk_hist_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
INDEX idx_is_active (is_active)
|
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 ---
|
// --- Matrix: Global 5-ary tree config and relations ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
|
|||||||
35
models/Abonemment.js
Normal file
35
models/Abonemment.js
Normal 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;
|
||||||
353
repositories/abonemments/AbonemmentRepository.js
Normal file
353
repositories/abonemments/AbonemmentRepository.js
Normal 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;
|
||||||
@ -19,6 +19,7 @@ const CoffeeController = require('../controller/admin/CoffeeController');
|
|||||||
const PoolController = require('../controller/pool/PoolController');
|
const PoolController = require('../controller/pool/PoolController');
|
||||||
const TaxController = require('../controller/tax/taxController');
|
const TaxController = require('../controller/tax/taxController');
|
||||||
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||||
|
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
||||||
|
|
||||||
// small helpers copied from original files
|
// small helpers copied from original files
|
||||||
function adminOnly(req, res, next) {
|
function adminOnly(req, res, next) {
|
||||||
@ -147,5 +148,9 @@ router.get('/admin/affiliates', authMiddleware, adminOnly, AffiliateController.l
|
|||||||
// Public Affiliates Route (Active only)
|
// Public Affiliates Route (Active only)
|
||||||
router.get('/affiliates/active', AffiliateController.listActive);
|
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;
|
module.exports = router;
|
||||||
@ -25,6 +25,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // Ma
|
|||||||
const PoolController = require('../controller/pool/PoolController');
|
const PoolController = require('../controller/pool/PoolController');
|
||||||
const TaxController = require('../controller/tax/taxController');
|
const TaxController = require('../controller/tax/taxController');
|
||||||
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
const AffiliateController = require('../controller/affiliate/AffiliateController');
|
||||||
|
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
||||||
|
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
@ -118,6 +119,27 @@ function forceCompanyForAdmin(req, res, next) {
|
|||||||
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
|
// Company-stamp POST
|
||||||
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
|
router.post('/company-stamps', authMiddleware, adminOnly, forceCompanyForAdmin, CompanyStampController.upload);
|
||||||
// Admin: create coffee product (supports multipart file 'picture')
|
// 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
|
// NEW: Admin create affiliate with logo upload
|
||||||
router.post('/admin/affiliates', authMiddleware, adminOnly, upload.single('logo'), AffiliateController.create);
|
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)
|
// Existing registration handlers (keep)
|
||||||
router.post('/register/personal', (req, res) => {
|
router.post('/register/personal', (req, res) => {
|
||||||
console.log('🔗 POST /register/personal route accessed');
|
console.log('🔗 POST /register/personal route accessed');
|
||||||
|
|||||||
@ -3,7 +3,7 @@ const UnitOfWork = require('../database/UnitOfWork');
|
|||||||
const argon2 = require('argon2');
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
async function createAdminUser() {
|
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 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 || 'W.profit-planet.com.2025';
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%';
|
const adminPassword = process.env.ADMIN_PASSWORD || 'Chalanger75$%';
|
||||||
|
|||||||
342
services/abonemments/AbonemmentService.js
Normal file
342
services/abonemments/AbonemmentService.js
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user