feat: invoice

This commit is contained in:
DeathKaioken 2025-12-15 16:58:55 +01:00
parent 92a54866b4
commit 80e7a96bce
16 changed files with 876 additions and 113 deletions

View File

@ -4,8 +4,13 @@ const service = new AbonemmentService();
module.exports = {
async subscribe(req, res) {
try {
const rawUser = req.user || {};
console.log('[CONTROLLER SUBSCRIBE] Raw req.user:', { id: rawUser.id, userId: rawUser.userId, email: rawUser.email, role: rawUser.role });
const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null };
console.log('[CONTROLLER SUBSCRIBE] Normalized actorUser:', { id: actorUser.id, email: actorUser.email, role: actorUser.role });
const result = await service.subscribeOrder({
userId: req.user?.id || null,
userId: actorUser.id || null,
items: req.body.items,
billingInterval: req.body.billing_interval,
intervalCount: req.body.interval_count,
@ -19,8 +24,8 @@ module.exports = {
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
actorUser, // normalized to include id
referredBy: req.body.referred_by,
});
return res.json({ success: true, data: result });
} catch (err) {
@ -31,7 +36,9 @@ module.exports = {
async pause(req, res) {
try {
const data = await service.pause({ abonementId: req.params.id, actorUser: req.user });
const rawUser = req.user || {};
const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null };
const data = await service.pause({ abonementId: req.params.id, actorUser });
return res.json({ success: true, data });
} catch (err) {
console.error('[ABONEMENT PAUSE]', err);
@ -41,7 +48,9 @@ module.exports = {
async resume(req, res) {
try {
const data = await service.resume({ abonementId: req.params.id, actorUser: req.user });
const rawUser = req.user || {};
const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null };
const data = await service.resume({ abonementId: req.params.id, actorUser });
return res.json({ success: true, data });
} catch (err) {
console.error('[ABONEMENT RESUME]', err);
@ -51,7 +60,9 @@ module.exports = {
async cancel(req, res) {
try {
const data = await service.cancel({ abonementId: req.params.id, actorUser: req.user });
const rawUser = req.user || {};
const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null };
const data = await service.cancel({ abonementId: req.params.id, actorUser });
return res.json({ success: true, data });
} catch (err) {
console.error('[ABONEMENT CANCEL]', err);
@ -61,7 +72,9 @@ module.exports = {
async renew(req, res) {
try {
const data = await service.adminRenew({ abonementId: req.params.id, actorUser: req.user });
const rawUser = req.user || {};
const actorUser = { ...rawUser, id: rawUser.id ?? rawUser.userId ?? null };
const data = await service.adminRenew({ abonementId: req.params.id, actorUser });
return res.json({ success: true, data });
} catch (err) {
console.error('[ABONEMENT RENEW]', err);
@ -71,7 +84,10 @@ module.exports = {
async getMine(req, res) {
try {
const data = await service.getMyAbonements({ userId: req.user.id });
const rawUser = req.user || {};
const id = rawUser.id ?? rawUser.userId;
console.log('[CONTROLLER GET MINE] Using user id:', id);
const data = await service.getMyAbonements({ userId: id });
return res.json({ success: true, data });
} catch (err) {
console.error('[ABONEMENT MINE]', err);
@ -81,8 +97,7 @@ module.exports = {
async getHistory(req, res) {
try {
const data = await service.getHistory({ abonementId: req.params.id });
return res.json({ success: true, data });
return res.json({ success: true, data: await service.getHistory({ abonementId: req.params.id }) });
} catch (err) {
console.error('[ABONEMENT HISTORY]', err);
return res.status(400).json({ success: false, message: err.message });
@ -91,8 +106,7 @@ module.exports = {
async adminList(req, res) {
try {
const data = await service.adminList({ status: req.query.status });
return res.json({ success: true, data });
return res.json({ success: true, data: await service.adminList({ status: req.query.status }) });
} catch (err) {
console.error('[ABONEMENT ADMIN LIST]', err);
return res.status(403).json({ success: false, message: err.message });
@ -101,11 +115,9 @@ module.exports = {
async getReferredSubscriptions(req, res) {
try {
const data = await service.getReferredSubscriptions({
userId: req.user.id,
email: req.user.email,
});
return res.json({ success: true, data });
const rawUser = req.user || {};
const id = rawUser.id ?? rawUser.userId;
return res.json({ success: true, data: await service.getReferredSubscriptions({ userId: id, email: rawUser.email }) });
} catch (err) {
console.error('[ABONEMENT REFERRED SUBSCRIPTIONS]', err);
return res.status(400).json({ success: false, message: err.message });

View File

@ -0,0 +1,48 @@
const InvoiceService = require('../../services/invoice/InvoiceService');
const service = new InvoiceService();
module.exports = {
async listMine(req, res) {
try {
const data = await service.listMine(req.user.id, {
status: req.query.status,
limit: Number(req.query.limit || 50),
offset: Number(req.query.offset || 0),
});
return res.json({ success: true, data });
} catch (e) {
console.error('[INVOICE LIST MINE]', e);
return res.status(400).json({ success: false, message: e.message });
}
},
async adminList(req, res) {
try {
const data = await service.adminList({
status: req.query.status,
limit: Number(req.query.limit || 200),
offset: Number(req.query.offset || 0),
});
return res.json({ success: true, data });
} catch (e) {
console.error('[INVOICE ADMIN LIST]', e);
return res.status(403).json({ success: false, message: e.message });
}
},
async pay(req, res) {
try {
const data = await service.markPaid(req.params.id, {
payment_method: req.body.payment_method,
transaction_id: req.body.transaction_id,
amount: req.body.amount,
paid_at: req.body.paid_at ? new Date(req.body.paid_at) : undefined,
details: req.body.details,
});
return res.json({ success: true, data });
} catch (e) {
console.error('[INVOICE PAY]', e);
return res.status(400).json({ success: false, message: e.message });
}
},
};

View File

@ -473,6 +473,27 @@ async function createDatabase() {
`);
console.log('✅ Referral token usage table created/verified');
// --- Affiliates table (for Affiliate Manager) ---
await connection.query(`
CREATE TABLE IF NOT EXISTS affiliates (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
url VARCHAR(1024) NOT NULL,
object_storage_id VARCHAR(255) NULL,
original_filename VARCHAR(255) NULL,
category VARCHAR(128) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
commission_rate DECIMAL(5,2) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_affiliates_created (created_at),
INDEX idx_affiliates_active (is_active),
INDEX idx_affiliates_category (category)
);
`);
console.log('✅ Affiliates table created/verified');
// --- Authorization Tables ---
// 14. permissions table: Defines granular permissions
@ -699,6 +720,101 @@ async function createDatabase() {
`);
console.log('✅ Coffee abonement history table created/verified');
// --- Invoices: unified for subscriptions and shop orders ---
await connection.query(`
CREATE TABLE IF NOT EXISTS invoices (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_number VARCHAR(64) NOT NULL UNIQUE,
user_id INT NULL,
source_type ENUM('subscription','shop') NOT NULL,
source_id BIGINT NOT NULL,
buyer_name VARCHAR(255) NULL,
buyer_email VARCHAR(255) NULL,
buyer_street VARCHAR(255) NULL,
buyer_postal_code VARCHAR(20) NULL,
buyer_city VARCHAR(100) NULL,
buyer_country VARCHAR(100) NULL,
currency CHAR(3) NOT NULL,
total_net DECIMAL(12,2) NOT NULL DEFAULT 0.00,
total_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00,
total_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00,
vat_rate DECIMAL(6,3) NULL,
status ENUM('draft','issued','paid','canceled') NOT NULL DEFAULT 'draft',
issued_at DATETIME NULL,
due_at DATETIME NULL,
pdf_storage_key VARCHAR(255) NULL,
context JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_invoice_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_invoice_source (source_type, source_id),
INDEX idx_invoice_issued (issued_at),
INDEX idx_invoice_status (status)
);
`);
console.log('✅ Invoices table created/verified');
await connection.query(`
CREATE TABLE IF NOT EXISTS invoice_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
product_id BIGINT NULL,
sku VARCHAR(128) NULL,
description VARCHAR(512) NOT NULL,
quantity DECIMAL(12,3) NOT NULL DEFAULT 1.000,
unit_price DECIMAL(12,2) NOT NULL DEFAULT 0.00,
tax_rate DECIMAL(6,3) NULL,
line_net DECIMAL(12,2) NOT NULL DEFAULT 0.00,
line_tax DECIMAL(12,2) NOT NULL DEFAULT 0.00,
line_gross DECIMAL(12,2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_item_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_item_invoice (invoice_id)
);
`);
console.log('✅ Invoice items table created/verified');
await connection.query(`
CREATE TABLE IF NOT EXISTS invoice_payments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
payment_method VARCHAR(64) NOT NULL,
transaction_id VARCHAR(128) NULL,
amount DECIMAL(12,2) NOT NULL,
paid_at DATETIME NULL,
status ENUM('succeeded','pending','failed','refunded') NOT NULL DEFAULT 'pending',
details JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_payment_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_payment_invoice (invoice_id),
INDEX idx_payment_status (status),
INDEX idx_payment_paid_at (paid_at)
);
`);
console.log('✅ Invoice payments table created/verified');
// Extend coffee_abonement_history with optional related_invoice_id
try {
await connection.query(`ALTER TABLE coffee_abonement_history ADD COLUMN related_invoice_id BIGINT NULL`);
console.log('🆕 Added coffee_abonement_history.related_invoice_id');
} catch (e) {
if (!/Duplicate column name|exists/i.test(e.message)) {
console.log(' related_invoice_id add skipped or not required:', e.message);
} else {
console.log(' coffee_abonement_history.related_invoice_id already exists');
}
}
try {
await connection.query(`
ALTER TABLE coffee_abonement_history
ADD CONSTRAINT fk_hist_invoice FOREIGN KEY (related_invoice_id)
REFERENCES invoices(id) ON DELETE SET NULL ON UPDATE CASCADE
`);
console.log('🆕 Added FK fk_hist_invoice on coffee_abonement_history.related_invoice_id');
} catch (e) {
console.log(' fk_hist_invoice already exists or failed:', e.message);
}
// --- Matrix: Global 5-ary tree config and relations ---
await connection.query(`
CREATE TABLE IF NOT EXISTS matrix_config (

View File

@ -0,0 +1 @@
safdasdffasdafd

View File

@ -0,0 +1 @@
safdasdffasdafd

View File

@ -0,0 +1 @@
safdasdffasdafd

View File

@ -22,6 +22,8 @@ class Abonemment {
this.country = row.country;
this.frequency = row.frequency;
this.referred_by = row.referred_by; // NEW
this.purchaser_user_id = row.purchaser_user_id ?? null; // NEW
this.user_id = row.user_id ?? null; // NEW: map owner user_id
this.created_at = row.created_at;
this.updated_at = row.updated_at;
}

29
models/Invoice.js Normal file
View File

@ -0,0 +1,29 @@
class Invoice {
constructor(row) {
this.id = row.id;
this.invoice_number = row.invoice_number;
this.user_id = row.user_id;
this.source_type = row.source_type;
this.source_id = row.source_id;
this.buyer_name = row.buyer_name;
this.buyer_email = row.buyer_email;
this.buyer_street = row.buyer_street;
this.buyer_postal_code = row.buyer_postal_code;
this.buyer_city = row.buyer_city;
this.buyer_country = row.buyer_country;
this.currency = row.currency;
this.total_net = Number(row.total_net);
this.total_tax = Number(row.total_tax);
this.total_gross = Number(row.total_gross);
this.vat_rate = row.vat_rate != null ? Number(row.vat_rate) : null;
this.status = row.status;
this.issued_at = row.issued_at;
this.due_at = row.due_at;
this.pdf_storage_key = row.pdf_storage_key;
this.context = row.context ? (typeof row.context === 'string' ? JSON.parse(row.context) : row.context) : null;
this.created_at = row.created_at;
this.updated_at = row.updated_at;
}
}
module.exports = Invoice;

View File

@ -2,15 +2,38 @@ const pool = require('../../database/database');
const Abonemment = require('../../models/Abonemment');
class AbonemmentRepository {
// NEW: cache table columns once per process
static _columnsCache = null;
// NEW: load columns for coffee_abonements
async loadColumns() {
if (AbonemmentRepository._columnsCache) return AbonemmentRepository._columnsCache;
const [rows] = await pool.query(
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'coffee_abonements'`
);
const set = new Set(rows.map(r => r.COLUMN_NAME));
AbonemmentRepository._columnsCache = set;
return set;
}
// NEW: helpers to include fields conditionally
async hasColumn(name) {
const cols = await this.loadColumns();
return cols.has(name);
}
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())`,
[
// NEW: dynamically assemble column list
const cols = [
'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'
];
const vals = [
snapshot.pack_group || '',
snapshot.status || 'active',
snapshot.started_at,
@ -31,10 +54,42 @@ class AbonemmentRepository {
snapshot.city || null,
snapshot.country || null,
snapshot.frequency || null,
referredBy || null, // Ensure referred_by is stored
],
referredBy || null,
];
console.log('[CREATE ABONEMENT] Incoming snapshot user linking:', {
actor_user_id: snapshot.actor_user_id,
user_id: snapshot.user_id,
purchaser_user_id: snapshot.purchaser_user_id,
referred_by: referredBy,
});
// NEW: optionally include user_id and purchaser_user_id
if (await this.hasColumn('user_id')) {
cols.push('user_id');
vals.push(snapshot.user_id ?? null);
}
if (await this.hasColumn('purchaser_user_id')) {
cols.push('purchaser_user_id');
vals.push(snapshot.purchaser_user_id ?? null);
}
const placeholders = cols.map(() => '?').join(', ');
console.log('[CREATE ABONEMENT] Final columns:', cols);
console.log('[CREATE ABONEMENT] Final values preview:', {
pack_group: vals[0],
status: vals[1],
user_id: cols.includes('user_id') ? vals[cols.indexOf('user_id')] : undefined,
purchaser_user_id: cols.includes('purchaser_user_id') ? vals[cols.indexOf('purchaser_user_id')] : undefined,
});
const [res] = await conn.query(
`INSERT INTO coffee_abonements (${cols.join(', ')}, created_at, updated_at)
VALUES (${placeholders}, NOW(), NOW())`,
vals
);
const abonementId = res.insertId;
console.log('[CREATE ABONEMENT] Inserted abonement ID:', abonementId);
const historyDetails = { ...(snapshot.details || {}), pack_group: snapshot.pack_group };
await conn.query(
@ -45,13 +100,21 @@ class AbonemmentRepository {
abonementId,
'created',
snapshot.started_at,
referredBy || snapshot.actor_user_id || null, // Use referredBy as actor_user_id if available
referredBy || snapshot.actor_user_id || null,
JSON.stringify(historyDetails),
],
);
await conn.commit();
return this.getAbonementById(abonementId);
const created = await this.getAbonementById(abonementId);
console.log('[CREATE ABONEMENT] Loaded abonement row after insert:', {
id: created?.id,
user_id: created?.user_id,
purchaser_user_id: created?.purchaser_user_id,
pack_group: created?.pack_group,
status: created?.status,
});
return created;
} catch (err) {
await conn.rollback();
throw err;
@ -218,24 +281,27 @@ class AbonemmentRepository {
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 = ?`,
[
// NEW: detect optional purchaser_user_id column
const hasPurchaser = await this.hasColumn('purchaser_user_id');
// Build SET clause dynamically
const sets = [
`status = 'active'`,
`pack_group = ?`,
`started_at = IFNULL(started_at, ?)`,
`next_billing_at = ?`,
`billing_interval = ?`,
`interval_count = ?`,
`price = ?`,
`currency = ?`,
`is_auto_renew = ?`,
`notes = ?`,
`recipient_email = ?`,
`pack_breakdown = ?`,
`updated_at = NOW()`,
];
const params = [
snapshot.pack_group || '',
snapshot.started_at,
snapshot.next_billing_at,
@ -246,11 +312,21 @@ class AbonemmentRepository {
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,
],
];
if (hasPurchaser) {
sets.splice(11, 0, `purchaser_user_id = IFNULL(purchaser_user_id, ?)`); // before pack_breakdown
params.splice(11, 0, snapshot.purchaser_user_id ?? null);
}
await conn.query(
`UPDATE coffee_abonements
SET ${sets.join(', ')}
WHERE id = ?`,
[...params, id],
);
const historyDetails = { ...(snapshot.details || {}), reused_existing: true, pack_group: snapshot.pack_group };
if (snapshot.recipient) historyDetails.recipient = snapshot.recipient;
await conn.query(

View File

@ -0,0 +1,184 @@
const pool = require('../../database/database');
const Invoice = require('../../models/Invoice');
function genInvoiceNumber() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const rand = Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
return `INV-${y}${m}${d}-${rand}`;
}
class InvoiceRepository {
async createInvoiceWithItems({
source_type,
source_id,
user_id,
buyer_name,
buyer_email,
buyer_street,
buyer_postal_code,
buyer_city,
buyer_country,
currency,
items = [],
status = 'draft',
issued_at = null,
due_at = null,
context = null,
vat_rate = null, // NEW: default VAT for invoice and lines
}) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const invoice_number = genInvoiceNumber();
// compute totals
let total_net = 0;
let total_tax = 0;
let total_gross = 0;
for (const it of items) {
const qty = Number(it.quantity || 1);
const unit = Number(it.unit_price || 0);
const rate = it.tax_rate != null ? Number(it.tax_rate) : (vat_rate != null ? Number(vat_rate) : null); // CHANGED
const line_net = qty * unit;
const line_tax = rate != null ? +(line_net * (rate / 100)).toFixed(2) : 0;
const line_gross = +(line_net + line_tax).toFixed(2);
total_net += line_net;
total_tax += line_tax;
total_gross += line_gross;
}
total_net = +total_net.toFixed(2);
total_tax = +total_tax.toFixed(2);
total_gross = +total_gross.toFixed(2);
const [res] = await conn.query(
`INSERT INTO invoices
(invoice_number, user_id, source_type, source_id, buyer_name, buyer_email, buyer_street, buyer_postal_code, buyer_city, buyer_country,
currency, total_net, total_tax, total_gross, vat_rate, status, issued_at, due_at, pdf_storage_key, context, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, NOW(), NOW())`,
[
invoice_number,
user_id || null,
source_type,
source_id,
buyer_name || null,
buyer_email || null,
buyer_street || null,
buyer_postal_code || null,
buyer_city || null,
buyer_country || null,
currency,
total_net,
total_tax,
total_gross,
vat_rate != null ? Number(vat_rate) : null, // CHANGED
status,
issued_at || null,
due_at || null,
context ? JSON.stringify(context) : null,
],
);
const invoiceId = res.insertId;
for (const it of items) {
const qty = Number(it.quantity || 1);
const unit = Number(it.unit_price || 0);
const rate = it.tax_rate != null ? Number(it.tax_rate) : (vat_rate != null ? Number(vat_rate) : null); // CHANGED
const line_net = +(qty * unit).toFixed(2);
const line_tax = rate != null ? +(line_net * (rate / 100)).toFixed(2) : 0;
const line_gross = +(line_net + line_tax).toFixed(2);
await conn.query(
`INSERT INTO invoice_items
(invoice_id, product_id, sku, description, quantity, unit_price, tax_rate, line_net, line_tax, line_gross, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
[
invoiceId,
it.product_id || null,
it.sku || null,
it.description || 'Subscription',
qty,
unit,
rate, // CHANGED
line_net,
line_tax,
line_gross,
],
);
}
await conn.commit();
return this.getById(invoiceId);
} catch (e) {
await conn.rollback();
throw e;
} finally {
conn.release();
}
}
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details }) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(`UPDATE invoices SET status='paid', updated_at=NOW() WHERE id = ?`, [invoiceId]);
await conn.query(
`INSERT INTO invoice_payments
(invoice_id, payment_method, transaction_id, amount, paid_at, status, details, created_at)
VALUES (?, ?, ?, ?, ?, 'succeeded', ?, NOW())`,
[invoiceId, payment_method || 'manual', transaction_id || null, amount || null, paid_at || new Date(), details ? JSON.stringify(details) : null],
);
await conn.commit();
return this.getById(invoiceId);
} catch (e) {
await conn.rollback();
throw e;
} finally {
conn.release();
}
}
async getById(id) {
const [rows] = await pool.query(`SELECT * FROM invoices WHERE id = ?`, [id]);
return rows[0] ? new Invoice(rows[0]) : null;
}
async listByUser(userId, { status, limit = 50, offset = 0 } = {}) {
const params = [userId];
let sql = `SELECT * FROM invoices WHERE user_id = ?`;
if (status) {
sql += ` AND status = ?`;
params.push(status);
}
sql += ` ORDER BY issued_at DESC, created_at DESC LIMIT ? OFFSET ?`;
params.push(Number(limit), Number(offset));
const [rows] = await pool.query(sql, params);
return rows.map((r) => new Invoice(r));
}
async listAll({ status, limit = 200, offset = 0 } = {}) {
const params = [];
let sql = `SELECT * FROM invoices`;
if (status) {
sql += ` WHERE status = ?`;
params.push(status);
}
sql += ` ORDER BY issued_at DESC, created_at DESC LIMIT ? OFFSET ?`;
params.push(Number(limit), Number(offset));
const [rows] = await pool.query(sql, params);
return rows.map((r) => new Invoice(r));
}
async findByAbonement(abonementId) {
const [rows] = await pool.query(
`SELECT * FROM invoices WHERE source_type='subscription' AND source_id = ? ORDER BY issued_at DESC, id DESC`,
[abonementId],
);
return rows.map((r) => new Invoice(r));
}
}
module.exports = InvoiceRepository;

View File

@ -563,14 +563,23 @@ async function addUserToMatrix({
err.status = 400;
throw err;
}
}
}
if (!parentId) {
// No referral parent found
fallback_reason = 'referrer_not_in_matrix';
} else {
// Depth-policy check: placing under non-root referrer must not exceed max depth
if (maxDepthPolicy != null) {
const parentDepthFromRoot = Number(inMatrixRows[0].depth || 0);
if (parentDepthFromRoot + 1 > maxDepthPolicy) {
if (forceParentFallback) {
fallback_reason = 'referrer_depth_limit';
parentId = rid;
rogueFlag = true;
} else {
const err = new Error('Referrer depth limit would be exceeded');
err.status = 400;
throw err;
}
}
}
}
}
// Duplicate check (already in matrix)
@ -661,7 +670,8 @@ async function addUserToMatrix({
if (!assignPos) {
// no slot in subtree -> decide fallback
if (forceParentFallback) {
fallback_reason = 'referrer_full';
// If maxDepthPolicy prevented placement anywhere under referrer subtree, mark depth-limit reason when applicable
fallback_reason = (maxDepthPolicy != null) ? 'referrer_depth_limit' : 'referrer_full';
parentId = rid;
rogueFlag = true;
// root sequential
@ -672,7 +682,7 @@ async function addUserToMatrix({
const maxPos = Number(rootPosRows[0]?.maxPos || 0);
assignPos = maxPos + 1;
} else {
const err = new Error('Parent subtree is full (5-ary); no available position');
const err = new Error('Parent subtree is full (5-ary) or depth limit reached; no available position');
err.status = 409;
throw err;
}

View File

@ -7,27 +7,35 @@ class PoolRepository {
async create({ pool_name, description = null, price = 0.00, pool_type = 'other', is_active = true, created_by = null }) {
const conn = this.uow.connection;
const [res] = await conn.execute(
`INSERT INTO pools (pool_name, description, price, pool_type, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[pool_name, description, price, pool_type, is_active, created_by]
);
try {
console.info('PoolRepository.create:start', { pool_name, pool_type, is_active, price, created_by });
const sql = `INSERT INTO pools (pool_name, description, price, pool_type, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [pool_name, description, price, pool_type, is_active, created_by];
const [res] = await conn.execute(sql, params);
console.info('PoolRepository.create:success', { insertId: res?.insertId });
return new Pool({ id: res.insertId, pool_name, description, price, pool_type, is_active, created_by });
} catch (err) {
console.error('PoolRepository.create:error', { code: err?.code, errno: err?.errno, sqlMessage: err?.sqlMessage, message: err?.message });
const e = new Error('Failed to create pool');
e.status = 500;
e.cause = err;
throw e;
}
}
async findAll() {
const conn = this.uow.connection; // switched to connection
try {
console.debug('[PoolRepository.findAll] querying pools');
const [rows] = await conn.execute(
`SELECT id, pool_name, description, price, pool_type, is_active, created_by, updated_by, created_at, updated_at
console.info('PoolRepository.findAll:start');
const sql = `SELECT id, pool_name, description, price, pool_type, is_active, created_by, updated_by, created_at, updated_at
FROM pools
ORDER BY created_at DESC`
);
console.debug('[PoolRepository.findAll] rows fetched', { count: rows.length });
ORDER BY created_at DESC`;
const [rows] = await conn.execute(sql);
console.info('PoolRepository.findAll:success', { count: rows.length });
return rows.map(r => new Pool(r));
} catch (err) {
console.error('[PoolRepository.findAll] query failed', err);
console.error('PoolRepository.findAll:error', { code: err?.code, errno: err?.errno, sqlMessage: err?.sqlMessage, message: err?.message });
// Surface a consistent error up the stack
const e = new Error('Failed to fetch pools');
e.status = 500;
@ -39,8 +47,11 @@ class PoolRepository {
// Update is_active flag (replaces old state transitions)
async updateActive(id, is_active, updated_by = null) {
const conn = this.uow.connection;
try {
console.info('PoolRepository.updateActive:start', { id, is_active, updated_by });
const [rows] = await conn.execute(`SELECT id FROM pools WHERE id = ?`, [id]);
if (!rows || rows.length === 0) {
console.warn('PoolRepository.updateActive:not_found', { id });
const err = new Error('Pool not found');
err.status = 404;
throw err;
@ -53,7 +64,18 @@ class PoolRepository {
`SELECT id, pool_name, description, price, pool_type, is_active, created_by, updated_by, created_at, updated_at FROM pools WHERE id = ?`,
[id]
);
console.info('PoolRepository.updateActive:success', { id, is_active });
return new Pool(updated[0]);
} catch (err) {
console.error('PoolRepository.updateActive:error', { id, is_active, code: err?.code, errno: err?.errno, sqlMessage: err?.sqlMessage, message: err?.message });
if (!err.status) {
const e = new Error('Failed to update pool active state');
e.status = 500;
e.cause = err;
throw e;
}
throw err;
}
}
}

View File

@ -21,6 +21,7 @@ const TaxController = require('../controller/tax/taxController');
const AffiliateController = require('../controller/affiliate/AffiliateController');
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
const NewsController = require('../controller/news/NewsController');
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
// small helpers copied from original files
function adminOnly(req, res, next) {
@ -160,5 +161,9 @@ router.get('/admin/abonements', authMiddleware, adminOnly, AbonemmentController.
router.get('/admin/news', authMiddleware, adminOnly, NewsController.list);
router.get('/news/active', NewsController.listActive);
// NEW: Invoice GETs
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList);
// export
module.exports = router;

View File

@ -27,6 +27,7 @@ const TaxController = require('../controller/tax/taxController');
const AffiliateController = require('../controller/affiliate/AffiliateController');
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
const NewsController = require('../controller/news/NewsController');
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@ -169,6 +170,9 @@ router.post('/admin/abonements/:id/renew', authMiddleware, adminOnly, Abonemment
// CHANGED: ensure req.user has id/email from body for this route
router.post('/abonements/referred', authMiddleware, ensureUserFromBody, AbonemmentController.getReferredSubscriptions);
// NEW: Invoice POSTs
router.post('/invoices/:id/pay', authMiddleware, adminOnly, InvoiceController.pay);
// Existing registration handlers (keep)
router.post('/register/personal', (req, res) => {
console.log('🔗 POST /register/personal route accessed');

View File

@ -1,9 +1,11 @@
const pool = require('../../database/database');
const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository');
const InvoiceService = require('../invoice/InvoiceService'); // NEW
class AbonemmentService {
constructor() {
this.repo = new AbonemmentRepository();
this.invoiceService = new InvoiceService(); // NEW
}
isAdmin(user) {
@ -51,6 +53,7 @@ class AbonemmentService {
referredBy, // NEW: referred_by field
}) {
console.log('[SUBSCRIBE ORDER] Start processing subscription order');
console.log('[SUBSCRIBE ORDER] Actor user:', actorUser ? { id: actorUser.id, role: actorUser.role, email: actorUser.email } : actorUser);
console.log('[SUBSCRIBE ORDER] Payload:', {
firstName,
lastName,
@ -116,9 +119,53 @@ class AbonemmentService {
country,
frequency,
referred_by: referredBy || null, // Pass referred_by to snapshot
user_id: actorUser?.id ?? null, // NEW: set owner (purchaser acts as owner here)
purchaser_user_id: actorUser?.id ?? null, // NEW: also store purchaser
};
return this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository
console.log('[SUBSCRIBE ORDER] Snapshot user linking:', {
actor_user_id: snapshot.actor_user_id,
user_id: snapshot.user_id,
purchaser_user_id: snapshot.purchaser_user_id,
referred_by: snapshot.referred_by,
});
const abonement = await this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository
console.log('[SUBSCRIBE ORDER] Created abonement:', {
id: abonement?.id,
user_id: abonement?.user_id,
purchaser_user_id: abonement?.purchaser_user_id,
status: abonement?.status,
pack_group: abonement?.pack_group,
});
// NEW: issue invoice for first period and append history
try {
const invoice = await this.invoiceService.issueForAbonement(
abonement,
snapshot.started_at,
snapshot.next_billing_at,
{ actorUserId: actorUser?.id || null }
);
console.log('[SUBSCRIBE ORDER] Issued invoice:', {
id: invoice?.id,
user_id: invoice?.user_id,
total_gross: invoice?.total_gross,
vat_rate: invoice?.vat_rate,
});
await this.repo.appendHistory(
abonement.id,
'invoice_issued',
actorUser?.id || null,
{ pack_group: abonement.pack_group, invoiceId: invoice.id },
new Date()
);
} catch (e) {
console.error('[SUBSCRIBE ORDER] Invoice issue failed:', e);
// intentionally not throwing to avoid blocking subscription; adjust if you want transactional consistency
}
return abonement;
}
async subscribe({
@ -135,7 +182,8 @@ class AbonemmentService {
referredBy, // NEW: referred_by field
}) {
console.log('[SUBSCRIBE] Start processing single subscription'); // NEW
console.log('[SUBSCRIBE] Recipient email:', recipientEmail); // NEW
console.log('[SUBSCRIBE] Actor user:', actorUser ? { id: actorUser.id, role: actorUser.role, email: actorUser.email } : actorUser);
console.log('[SUBSCRIBE] Incoming userId:', userId, 'targetUserId:', targetUserId); // NEW
const normalizedRecipientEmail = this.normalizeEmail(recipientEmail);
console.log('[SUBSCRIBE] Normalized recipient email:', normalizedRecipientEmail); // NEW
@ -150,6 +198,7 @@ class AbonemmentService {
const isForMe = !targetUserId && !hasRecipientFields;
const ownerUserId = targetUserId ?? (hasRecipientFields ? null : safeUserId);
const purchaserUserId = isForMe ? null : actorUser?.id || null;
console.log('[SUBSCRIBE] Resolved ownership:', { isForMe, ownerUserId, purchaserUserId }); // NEW
const recipientMeta = targetUserId
? { target_user_id: targetUserId }
@ -186,15 +235,57 @@ class AbonemmentService {
recipient_email: normalizedRecipientEmail || null, // CHANGED
details,
recipient: recipientMeta || undefined,
purchaser_user_id: purchaserUserId, // NEW
purchaser_user_id, // NEW
user_id: ownerUserId ?? null, // NEW: persist owner
referred_by: referredBy || null, // Pass referred_by to snapshot
};
console.log('[SUBSCRIBE] Snapshot user linking:', {
actor_user_id: snapshot.actor_user_id,
user_id: snapshot.user_id,
purchaser_user_id: snapshot.purchaser_user_id,
referred_by: snapshot.referred_by,
});
const existing = await this.repo.findActiveOrPausedByUserAndProduct(ownerUserId ?? null, canonicalPackGroup);
console.log('[SUBSCRIBE] Existing abonement candidate:', existing ? { id: existing.id, user_id: existing.user_id } : null); // NEW
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] Upserted abonement:', {
id: abonement?.id,
user_id: abonement?.user_id,
purchaser_user_id: abonement?.purchaser_user_id,
status: abonement?.status,
pack_group: abonement?.pack_group,
});
// NEW: issue invoice for first period and append history
try {
const invoice = await this.invoiceService.issueForAbonement(
abonement,
snapshot.started_at,
snapshot.next_billing_at,
{ actorUserId: actorUser?.id || null }
);
console.log('[SUBSCRIBE] Issued invoice:', {
id: invoice?.id,
user_id: invoice?.user_id,
total_gross: invoice?.total_gross,
vat_rate: invoice?.vat_rate,
});
await this.repo.appendHistory(
abonement.id,
'invoice_issued',
actorUser?.id || null,
{ pack_group: abonement.pack_group, invoiceId: invoice.id },
new Date()
);
} catch (e) {
console.error('[SUBSCRIBE] Invoice issue failed:', e);
}
console.log('[SUBSCRIBE] Single subscription completed successfully'); // NEW
return abonement;
}
@ -251,11 +342,32 @@ class AbonemmentService {
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, {
const renewed = await this.repo.transitionBilling(abonementId, next, {
event_type: 'renewed',
actor_user_id: actorUser?.id || null,
details: { pack_group: abon.pack_group, ...(invoiceId ? { invoiceId } : {}) },
});
// NEW: issue invoice for next cycle and attach to history
try {
const invoice = await this.invoiceService.issueForAbonement(
renewed,
new Date(abon.next_billing_at || new Date()),
next,
{ actorUserId: actorUser?.id || null }
);
await this.repo.appendHistory(
abonementId,
'invoice_issued',
actorUser?.id || null,
{ pack_group: renewed.pack_group, invoiceId: invoice.id },
new Date()
);
} catch (e) {
console.error('[RENEW] Invoice issue failed:', e);
}
return renewed;
}
async getMyAbonements({ userId }) {

View File

@ -0,0 +1,140 @@
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
class InvoiceService {
constructor() {
this.repo = new InvoiceRepository();
}
// NEW: resolve current standard VAT rate for a buyer country code
async resolveVatRateForCountry(countryCode) {
if (!countryCode) return null;
const uow = new UnitOfWork();
await uow.start();
const taxRepo = new TaxRepository(uow);
try {
const country = await taxRepo.getCountryByCode(String(countryCode).toUpperCase());
if (!country) {
await uow.commit();
return null;
}
// get current vat row for this country
const [rows] = await taxRepo.conn.query(
`SELECT standard_rate FROM vat_rates WHERE country_id = ? AND effective_to IS NULL LIMIT 1`,
[country.id]
);
await uow.commit();
const rate = rows?.[0]?.standard_rate;
return rate == null ? null : Number(rate);
} catch (e) {
await uow.rollback();
throw e;
}
}
// Issue invoice for a subscription period, with items from pack_breakdown
async issueForAbonement(abonement, periodStart, periodEnd, { actorUserId } = {}) {
console.log('[INVOICE ISSUE] Inputs:', {
abonement_id: abonement?.id,
abonement_user_id: abonement?.user_id,
abonement_purchaser_user_id: abonement?.purchaser_user_id,
actorUserId,
periodStart,
periodEnd,
});
const buyerName = [abonement.first_name, abonement.last_name].filter(Boolean).join(' ') || null;
const buyerEmail = abonement.email || null;
const addr = {
street: abonement.street || null,
postal_code: abonement.postal_code || null,
city: abonement.city || null,
country: abonement.country || null,
};
const currency = abonement.currency || 'EUR';
// NEW: resolve invoice vat_rate (standard) from buyer country
const vat_rate = await this.resolveVatRateForCountry(addr.country);
const breakdown = Array.isArray(abonement.pack_breakdown) ? abonement.pack_breakdown : [];
const items = breakdown.length
? breakdown.map((b) => ({
product_id: Number(b.coffee_table_id) || null,
sku: `COFFEE-${b.coffee_table_id || 'N/A'}`,
description: `Coffee subscription: ${b.coffee_table_id}`,
quantity: Number(b.packs || 1),
unit_price: Number(b.price_per_pack || 0),
tax_rate: b.tax_rate != null ? Number(b.tax_rate) : vat_rate, // CHANGED: default to invoice vat_rate
}))
: [
{
product_id: null,
sku: 'SUBSCRIPTION',
description: `Subscription ${abonement.pack_group || ''}`,
quantity: 1,
unit_price: Number(abonement.price || 0),
tax_rate: vat_rate, // CHANGED
},
];
const context = {
source: 'abonement',
pack_group: abonement.pack_group || null,
period_start: periodStart,
period_end: periodEnd,
referred_by: abonement.referred_by || null,
};
// CHANGED: prioritize token user id for invoice ownership
const userIdForInvoice =
actorUserId ?? abonement.user_id ?? abonement.purchaser_user_id ?? null;
console.log('[INVOICE ISSUE] Resolved user_id for invoice:', userIdForInvoice);
const invoice = await this.repo.createInvoiceWithItems({
source_type: 'subscription',
source_id: abonement.id,
user_id: userIdForInvoice,
buyer_name: buyerName,
buyer_email: buyerEmail,
buyer_street: addr.street,
buyer_postal_code: addr.postal_code,
buyer_city: addr.city,
buyer_country: addr.country,
currency,
items,
status: 'issued',
issued_at: new Date(),
due_at: periodEnd,
context,
vat_rate, // NEW: persist on invoice
});
console.log('[INVOICE ISSUE] Created invoice:', {
id: invoice?.id,
user_id: invoice?.user_id,
source_type: invoice?.source_type,
source_id: invoice?.source_id,
total_net: invoice?.total_net,
total_tax: invoice?.total_tax,
total_gross: invoice?.total_gross,
});
return invoice;
}
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
return this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
}
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
return this.repo.listByUser(userId, { status, limit, offset });
}
async adminList({ status, limit = 200, offset = 0 } = {}) {
return this.repo.listAll({ status, limit, offset });
}
}
module.exports = InvoiceService;