diff --git a/controller/abonemments/AbonemmentController.js b/controller/abonemments/AbonemmentController.js index 13d9528..b68d468 100644 --- a/controller/abonemments/AbonemmentController.js +++ b/controller/abonemments/AbonemmentController.js @@ -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 }); diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js new file mode 100644 index 0000000..c7b81f9 --- /dev/null +++ b/controller/invoice/InvoiceController.js @@ -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 }); + } + }, +}; diff --git a/database/createDb.js b/database/createDb.js index a1b551a..90c059d 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -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 ( diff --git a/debug-pdf/template_1_html_full.html b/debug-pdf/template_1_html_full.html new file mode 100644 index 0000000..6a402d4 --- /dev/null +++ b/debug-pdf/template_1_html_full.html @@ -0,0 +1 @@ +safdasdffasdafd \ No newline at end of file diff --git a/debug-pdf/template_1_html_raw.bin b/debug-pdf/template_1_html_raw.bin new file mode 100644 index 0000000..6a402d4 --- /dev/null +++ b/debug-pdf/template_1_html_raw.bin @@ -0,0 +1 @@ +safdasdffasdafd \ No newline at end of file diff --git a/debug-pdf/template_1_sanitized_preview.html b/debug-pdf/template_1_sanitized_preview.html new file mode 100644 index 0000000..6a402d4 --- /dev/null +++ b/debug-pdf/template_1_sanitized_preview.html @@ -0,0 +1 @@ +safdasdffasdafd \ No newline at end of file diff --git a/models/Abonemment.js b/models/Abonemment.js index 5873906..96e57cc 100644 --- a/models/Abonemment.js +++ b/models/Abonemment.js @@ -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; } diff --git a/models/Invoice.js b/models/Invoice.js new file mode 100644 index 0000000..1cfde3f --- /dev/null +++ b/models/Invoice.js @@ -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; diff --git a/repositories/abonemments/AbonemmentRepository.js b/repositories/abonemments/AbonemmentRepository.js index 7720c20..673d189 100644 --- a/repositories/abonemments/AbonemmentRepository.js +++ b/repositories/abonemments/AbonemmentRepository.js @@ -2,39 +2,94 @@ 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(); + + // 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, + 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, + ]; + + 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 - (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 - ], + `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,39 +281,52 @@ class AbonemmentRepository { const conn = await pool.getConnection(); try { await conn.beginTransaction(); + + // 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, + snapshot.billing_interval, + snapshot.interval_count, + snapshot.price, + snapshot.currency, + snapshot.is_auto_renew ? 1 : 0, + snapshot.notes || null, + snapshot.recipient_email || null, + snapshot.pack_breakdown ? JSON.stringify(snapshot.pack_breakdown) : null, + ]; + + 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 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() + SET ${sets.join(', ')} 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, - ], + [...params, id], ); + const historyDetails = { ...(snapshot.details || {}), reused_existing: true, pack_group: snapshot.pack_group }; if (snapshot.recipient) historyDetails.recipient = snapshot.recipient; await conn.query( diff --git a/repositories/invoice/InvoiceRepository.js b/repositories/invoice/InvoiceRepository.js new file mode 100644 index 0000000..202110f --- /dev/null +++ b/repositories/invoice/InvoiceRepository.js @@ -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; diff --git a/repositories/matrix/MatrixRepository.js b/repositories/matrix/MatrixRepository.js index 78b8723..dbfe997 100644 --- a/repositories/matrix/MatrixRepository.js +++ b/repositories/matrix/MatrixRepository.js @@ -563,16 +563,25 @@ async function addUserToMatrix({ err.status = 400; throw err; } + } 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; + } + } + } } } - if (!parentId) { - // No referral parent found - fallback_reason = 'referrer_not_in_matrix'; - parentId = rid; - rogueFlag = true; - } - // Duplicate check (already in matrix) const [dupRows] = await conn.query( ` @@ -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; } diff --git a/repositories/pool/poolRepository.js b/repositories/pool/poolRepository.js index 95f09fd..947d459 100644 --- a/repositories/pool/poolRepository.js +++ b/repositories/pool/poolRepository.js @@ -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] - ); - return new Pool({ id: res.insertId, 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,21 +47,35 @@ class PoolRepository { // Update is_active flag (replaces old state transitions) async updateActive(id, is_active, updated_by = null) { const conn = this.uow.connection; - const [rows] = await conn.execute(`SELECT id FROM pools WHERE id = ?`, [id]); - if (!rows || rows.length === 0) { - const err = new Error('Pool not found'); - err.status = 404; + 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; + } + await conn.execute( + `UPDATE pools SET is_active = ?, updated_by = ?, updated_at = NOW() WHERE id = ?`, + [is_active, updated_by, id] + ); + const [updated] = await conn.execute( + `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; } - await conn.execute( - `UPDATE pools SET is_active = ?, updated_by = ?, updated_at = NOW() WHERE id = ?`, - [is_active, updated_by, id] - ); - const [updated] = await conn.execute( - `SELECT id, pool_name, description, price, pool_type, is_active, created_by, updated_by, created_at, updated_at FROM pools WHERE id = ?`, - [id] - ); - return new Pool(updated[0]); } } diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 4628e10..2d18c6b 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -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; \ No newline at end of file diff --git a/routes/postRoutes.js b/routes/postRoutes.js index c3dbcc5..c4b214d 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -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'); diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 44d6fe1..4c4793e 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -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 }) { diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js new file mode 100644 index 0000000..527ef4e --- /dev/null +++ b/services/invoice/InvoiceService.js @@ -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;