feat: invoice
This commit is contained in:
parent
92a54866b4
commit
80e7a96bce
@ -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 });
|
||||
|
||||
48
controller/invoice/InvoiceController.js
Normal file
48
controller/invoice/InvoiceController.js
Normal 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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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 (
|
||||
|
||||
1
debug-pdf/template_1_html_full.html
Normal file
1
debug-pdf/template_1_html_full.html
Normal file
@ -0,0 +1 @@
|
||||
safdasdffasdafd
|
||||
1
debug-pdf/template_1_html_raw.bin
Normal file
1
debug-pdf/template_1_html_raw.bin
Normal file
@ -0,0 +1 @@
|
||||
safdasdffasdafd
|
||||
1
debug-pdf/template_1_sanitized_preview.html
Normal file
1
debug-pdf/template_1_sanitized_preview.html
Normal file
@ -0,0 +1 @@
|
||||
safdasdffasdafd
|
||||
@ -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
29
models/Invoice.js
Normal 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;
|
||||
@ -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(
|
||||
|
||||
184
repositories/invoice/InvoiceRepository.js
Normal file
184
repositories/invoice/InvoiceRepository.js
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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');
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
140
services/invoice/InvoiceService.js
Normal file
140
services/invoice/InvoiceService.js
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user