feat: enhance invoice and pool management with new status updates, detailed invoice retrieval, and pool statistics
This commit is contained in:
parent
002dbc78c1
commit
2c239ad331
@ -45,4 +45,28 @@ module.exports = {
|
|||||||
return res.status(400).json({ success: false, message: e.message });
|
return res.status(400).json({ success: false, message: e.message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!status) return res.status(400).json({ success: false, message: 'status is required' });
|
||||||
|
const data = await service.updateStatus(req.params.id, status);
|
||||||
|
const poolResult = data?._poolResult ?? null;
|
||||||
|
if (data?._poolResult) delete data._poolResult;
|
||||||
|
return res.json({ success: true, data, poolResult });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVOICE UPDATE STATUS]', e);
|
||||||
|
return res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDetail(req, res) {
|
||||||
|
try {
|
||||||
|
const data = await service.getInvoiceDetail(req.params.id);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVOICE DETAIL]', e);
|
||||||
|
return res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -154,5 +154,43 @@ module.exports = {
|
|||||||
console.error('[PoolController.inflowDiagnostics]', e);
|
console.error('[PoolController.inflowDiagnostics]', e);
|
||||||
return res.status(500).json({ success: false, message: 'Internal server error' });
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async poolStats(req, res) {
|
||||||
|
try {
|
||||||
|
const poolId = Number(req.params?.id);
|
||||||
|
if (!Number.isFinite(poolId) || poolId <= 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid pool ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = require('../../database/database');
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10);
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const [[totals]] = await db.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(amount_net), 0) AS total_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN created_at >= ? THEN amount_net ELSE 0 END), 0) AS amount_this_year,
|
||||||
|
COALESCE(SUM(CASE WHEN created_at >= ? THEN amount_net ELSE 0 END), 0) AS amount_this_month,
|
||||||
|
COUNT(*) AS total_inflows
|
||||||
|
FROM pool_inflows
|
||||||
|
WHERE pool_id = ?`,
|
||||||
|
[yearStart, monthStart, poolId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
total_amount: Number(totals?.total_amount ?? 0),
|
||||||
|
amount_this_year: Number(totals?.amount_this_year ?? 0),
|
||||||
|
amount_this_month: Number(totals?.amount_this_month ?? 0),
|
||||||
|
total_inflows: Number(totals?.total_inflows ?? 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PoolController.poolStats]', e);
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -54,6 +54,14 @@ if (NODE_ENV === 'development') {
|
|||||||
|
|
||||||
const allowCreateDb = String(process.env.DB_ALLOW_CREATE_DB || 'false').toLowerCase() === 'true';
|
const allowCreateDb = String(process.env.DB_ALLOW_CREATE_DB || 'false').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
const SYSTEM_POOLS = [
|
||||||
|
{ pool_name: 'ABO 60', description: 'System pool for ABO 60 capsule distribution', price: 0.01, pool_type: 'coffee', price_per_capsule_gross: 0.01 },
|
||||||
|
{ pool_name: 'ABO 120', description: 'System pool for ABO 120 capsule distribution', price: 0.01, pool_type: 'coffee', price_per_capsule_gross: 0.01 },
|
||||||
|
{ pool_name: 'Business', description: 'System pool for Business capsule distribution', price: 0.02, pool_type: 'other', price_per_capsule_gross: 0.02 },
|
||||||
|
{ pool_name: 'Gigantea', description: 'System pool for Gigantea capsule distribution', price: 0.02, pool_type: 'other', price_per_capsule_gross: 0.02 },
|
||||||
|
{ pool_name: 'Core', description: 'Every member receives 1 cent per capsule sold — the amount multiplies with each member, not divided.', price: 0.01, pool_type: 'other', price_per_capsule_gross: 0.01 },
|
||||||
|
];
|
||||||
|
|
||||||
// --- Performance Helpers ---
|
// --- Performance Helpers ---
|
||||||
async function ensureIndex(conn, table, indexName, indexDDL) {
|
async function ensureIndex(conn, table, indexName, indexDDL) {
|
||||||
const [rows] = await conn.query(`SHOW INDEX FROM \`${table}\` WHERE Key_name = ?`, [indexName]);
|
const [rows] = await conn.query(`SHOW INDEX FROM \`${table}\` WHERE Key_name = ?`, [indexName]);
|
||||||
@ -1093,6 +1101,72 @@ const createDatabase = async () => {
|
|||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_capsule_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
pool_id INT NOT NULL,
|
||||||
|
price_per_capsule_gross DECIMAL(10,4) NOT NULL,
|
||||||
|
applies_to_all_capsules TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_pool_capsule_rules_pool UNIQUE (pool_id),
|
||||||
|
CONSTRAINT fk_pool_capsule_rules_pool FOREIGN KEY (pool_id) REFERENCES pools(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_capsule_rules_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_capsule_rules_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
INDEX idx_pool_capsule_rules_active (is_active)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ pool_capsule_rules table created/verified');
|
||||||
|
|
||||||
|
for (const cfg of SYSTEM_POOLS) {
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO pools (pool_name, description, price, subscription_coffee_id, pool_type, is_active, created_by, updated_by)
|
||||||
|
VALUES (?, ?, ?, NULL, ?, 1, NULL, NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
description = VALUES(description),
|
||||||
|
price = VALUES(price),
|
||||||
|
subscription_coffee_id = NULL,
|
||||||
|
pool_type = VALUES(pool_type),
|
||||||
|
is_active = 1,
|
||||||
|
updated_by = NULL,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[cfg.pool_name, cfg.description, cfg.price, cfg.pool_type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [poolRows] = await connection.query(
|
||||||
|
`SELECT id FROM pools WHERE pool_name = ? LIMIT 1`,
|
||||||
|
[cfg.pool_name]
|
||||||
|
);
|
||||||
|
const poolId = poolRows?.[0]?.id;
|
||||||
|
if (!poolId) continue;
|
||||||
|
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO pool_capsule_rules (pool_id, price_per_capsule_gross, applies_to_all_capsules, is_active, created_by, updated_by)
|
||||||
|
VALUES (?, ?, 1, 1, NULL, NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
price_per_capsule_gross = VALUES(price_per_capsule_gross),
|
||||||
|
applies_to_all_capsules = 1,
|
||||||
|
is_active = 1,
|
||||||
|
updated_by = NULL,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[poolId, cfg.price_per_capsule_gross]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPoolNames = SYSTEM_POOLS.map((x) => x.pool_name);
|
||||||
|
const systemPoolPlaceholders = systemPoolNames.map(() => '?').join(',');
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE pools
|
||||||
|
SET is_active = 0,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE pool_name NOT IN (${systemPoolPlaceholders})`,
|
||||||
|
systemPoolNames
|
||||||
|
);
|
||||||
|
console.log('✅ System pools synchronized (ABO 60, ABO 120, Business, Gigantea, Core)');
|
||||||
|
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS pool_members (
|
CREATE TABLE IF NOT EXISTS pool_members (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@ -1111,6 +1185,30 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ pool_members table created/verified');
|
console.log('✅ pool_members table created/verified');
|
||||||
|
|
||||||
|
// Track sold capsules per paid invoice as calculation basis for pools
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS capsule_sales (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
invoice_id BIGINT NOT NULL,
|
||||||
|
abonement_id BIGINT NOT NULL,
|
||||||
|
coffee_table_id BIGINT NOT NULL,
|
||||||
|
capsules_count INT NOT NULL,
|
||||||
|
sold_at DATETIME NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
details JSON NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_capsule_sales_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_capsule_sales_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_capsule_sales_coffee FOREIGN KEY (coffee_table_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT uq_capsule_sales_invoice_coffee UNIQUE (invoice_id, coffee_table_id),
|
||||||
|
INDEX idx_capsule_sales_invoice (invoice_id),
|
||||||
|
INDEX idx_capsule_sales_abon (abonement_id),
|
||||||
|
INDEX idx_capsule_sales_sold_at (sold_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ capsule_sales table created/verified');
|
||||||
|
|
||||||
// Track money inflow into pools from subscriptions/invoices
|
// Track money inflow into pools from subscriptions/invoices
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS pool_inflows (
|
CREATE TABLE IF NOT EXISTS pool_inflows (
|
||||||
|
|||||||
@ -195,6 +195,26 @@ class InvoiceRepository {
|
|||||||
);
|
);
|
||||||
return rows.map((r) => new Invoice(r));
|
return rows.map((r) => new Invoice(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateStatus(invoiceId, newStatus) {
|
||||||
|
const allowed = ['draft', 'issued', 'paid', 'overdue', 'canceled'];
|
||||||
|
if (!allowed.includes(newStatus)) {
|
||||||
|
throw new Error(`Invalid status '${newStatus}'. Allowed: ${allowed.join(', ')}`);
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE invoices SET status = ?, updated_at = NOW() WHERE id = ?`,
|
||||||
|
[newStatus, invoiceId],
|
||||||
|
);
|
||||||
|
return this.getById(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentsByInvoiceId(invoiceId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT * FROM invoice_payments WHERE invoice_id = ? ORDER BY created_at DESC`,
|
||||||
|
[invoiceId],
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = InvoiceRepository;
|
module.exports = InvoiceRepository;
|
||||||
|
|||||||
@ -9,6 +9,25 @@ class PoolMemberRepository {
|
|||||||
const conn = this.uow.connection;
|
const conn = this.uow.connection;
|
||||||
try {
|
try {
|
||||||
logger.info('PoolMemberRepository.listMembers:start', { poolId });
|
logger.info('PoolMemberRepository.listMembers:start', { poolId });
|
||||||
|
|
||||||
|
// 1) Get the pool info (is_core flag, member count, total inflows)
|
||||||
|
const [poolMeta] = await conn.execute(
|
||||||
|
`SELECT
|
||||||
|
p.pool_name,
|
||||||
|
(SELECT COUNT(*) FROM pool_members WHERE pool_id = p.id) AS member_count,
|
||||||
|
COALESCE((SELECT SUM(pi.amount_net) FROM pool_inflows pi WHERE pi.pool_id = p.id), 0) AS total_pool_amount
|
||||||
|
FROM pools p
|
||||||
|
WHERE p.id = ?`,
|
||||||
|
[poolId]
|
||||||
|
);
|
||||||
|
const meta = poolMeta[0] || { pool_name: '', member_count: 0, total_pool_amount: 0 };
|
||||||
|
const isCore = meta.pool_name === 'Core';
|
||||||
|
const memberCount = Number(meta.member_count) || 1;
|
||||||
|
const totalPoolAmount = Number(meta.total_pool_amount) || 0;
|
||||||
|
// For Core: every member gets the full total. Others: equal share.
|
||||||
|
const perMemberShare = isCore ? totalPoolAmount : totalPoolAmount / memberCount;
|
||||||
|
|
||||||
|
// 2) Fetch member rows
|
||||||
const [rows] = await conn.execute(
|
const [rows] = await conn.execute(
|
||||||
`SELECT
|
`SELECT
|
||||||
u.id,
|
u.id,
|
||||||
@ -27,8 +46,12 @@ class PoolMemberRepository {
|
|||||||
ORDER BY pm.joined_at DESC`,
|
ORDER BY pm.joined_at DESC`,
|
||||||
[poolId]
|
[poolId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 3) Attach per-member share
|
||||||
|
const enriched = rows.map(r => ({ ...r, share: Number(perMemberShare.toFixed(2)) }));
|
||||||
|
|
||||||
logger.info('PoolMemberRepository.listMembers:success', { poolId, count: rows.length });
|
logger.info('PoolMemberRepository.listMembers:success', { poolId, count: rows.length });
|
||||||
return rows;
|
return enriched;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('PoolMemberRepository.listMembers:error', { poolId, error: error.message });
|
logger.error('PoolMemberRepository.listMembers:error', { poolId, error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -35,8 +35,9 @@ class PoolRepository {
|
|||||||
FROM pools p
|
FROM pools p
|
||||||
LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_id
|
LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_id
|
||||||
LEFT JOIN pool_members pm ON pm.pool_id = p.id
|
LEFT JOIN pool_members pm ON pm.pool_id = p.id
|
||||||
|
WHERE p.pool_name IN ('ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core')
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY p.created_at DESC`;
|
ORDER BY FIELD(p.pool_name, 'ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core')`;
|
||||||
const [rows] = await conn.execute(sql);
|
const [rows] = await conn.execute(sql);
|
||||||
console.info('PoolRepository.findAll:success', { count: rows.length });
|
console.info('PoolRepository.findAll:success', { count: rows.length });
|
||||||
return rows.map(r => new Pool(r));
|
return rows.map(r => new Pool(r));
|
||||||
|
|||||||
@ -137,6 +137,8 @@ router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list);
|
|||||||
router.get('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.listMembers);
|
router.get('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.listMembers);
|
||||||
// NEW: Admin diagnose pool inflow for invoice
|
// NEW: Admin diagnose pool inflow for invoice
|
||||||
router.get('/admin/pools/inflow-diagnostics', authMiddleware, adminOnly, PoolController.inflowDiagnostics);
|
router.get('/admin/pools/inflow-diagnostics', authMiddleware, adminOnly, PoolController.inflowDiagnostics);
|
||||||
|
// NEW: Admin pool inflow stats
|
||||||
|
router.get('/admin/pools/:id/stats', authMiddleware, adminOnly, PoolController.poolStats);
|
||||||
|
|
||||||
// NEW: User matrices list and per-instance overview
|
// NEW: User matrices list and per-instance overview
|
||||||
router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices);
|
router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices);
|
||||||
@ -172,6 +174,7 @@ router.get('/news/:slug', NewsController.getPublic);
|
|||||||
// NEW: Invoice GETs
|
// NEW: Invoice GETs
|
||||||
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
|
router.get('/invoices/mine', authMiddleware, InvoiceController.listMine);
|
||||||
router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList);
|
router.get('/admin/invoices', authMiddleware, adminOnly, InvoiceController.adminList);
|
||||||
|
router.get('/admin/invoices/:id/detail', authMiddleware, adminOnly, InvoiceController.getDetail);
|
||||||
|
|
||||||
// NOTE: Contract signing uses UnitOfWork; any DB cleanup must happen before commit() closes the connection.
|
// NOTE: Contract signing uses UnitOfWork; any DB cleanup must happen before commit() closes the connection.
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // <-
|
|||||||
const AffiliateController = require('../controller/affiliate/AffiliateController'); // <-- new
|
const AffiliateController = require('../controller/affiliate/AffiliateController'); // <-- new
|
||||||
const NewsController = require('../controller/news/NewsController');
|
const NewsController = require('../controller/news/NewsController');
|
||||||
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
const AbonemmentController = require('../controller/abonemments/AbonemmentController');
|
||||||
|
const InvoiceController = require('../controller/invoice/InvoiceController');
|
||||||
|
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
@ -56,6 +57,9 @@ router.patch('/admin/affiliates/:id/status', authMiddleware, adminOnly, Affiliat
|
|||||||
router.patch('/admin/news/:id', authMiddleware, adminOnly, upload.single('image'), NewsController.update);
|
router.patch('/admin/news/:id', authMiddleware, adminOnly, upload.single('image'), NewsController.update);
|
||||||
router.patch('/admin/news/:id/status', authMiddleware, adminOnly, NewsController.updateStatus);
|
router.patch('/admin/news/:id/status', authMiddleware, adminOnly, NewsController.updateStatus);
|
||||||
|
|
||||||
|
// Admin: update invoice status
|
||||||
|
router.patch('/admin/invoices/:id/status', authMiddleware, adminOnly, InvoiceController.updateStatus);
|
||||||
|
|
||||||
// Personal profile (self-service) - no admin guard
|
// Personal profile (self-service) - no admin guard
|
||||||
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);
|
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);
|
||||||
router.patch('/profile/personal/bank', authMiddleware, PersonalProfileController.updateBank);
|
router.patch('/profile/personal/bank', authMiddleware, PersonalProfileController.updateBank);
|
||||||
|
|||||||
@ -416,20 +416,27 @@ class InvoiceService {
|
|||||||
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
|
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
|
||||||
const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
|
const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
|
||||||
|
|
||||||
|
let poolResult = null;
|
||||||
try {
|
try {
|
||||||
const inflowResult = await PoolInflowService.bookForPaidInvoice({
|
const inflowResult = await PoolInflowService.bookForPaidInvoice({
|
||||||
invoiceId: paidInvoice?.id,
|
invoiceId: paidInvoice?.id,
|
||||||
paidAt: paid_at,
|
paidAt: paid_at,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
});
|
});
|
||||||
|
poolResult = inflowResult;
|
||||||
console.log('[INVOICE PAID] Pool inflow booking result:', {
|
console.log('[INVOICE PAID] Pool inflow booking result:', {
|
||||||
invoiceId: paidInvoice?.id,
|
invoiceId: paidInvoice?.id,
|
||||||
...inflowResult,
|
...inflowResult,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
poolResult = { error: e?.message || 'Pool inflow booking failed' };
|
||||||
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
|
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach pool result to returned data so the frontend can display it
|
||||||
|
if (paidInvoice) {
|
||||||
|
paidInvoice._poolResult = poolResult;
|
||||||
|
}
|
||||||
return paidInvoice;
|
return paidInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,6 +451,30 @@ class InvoiceService {
|
|||||||
async adminList({ status, limit = 200, offset = 0 } = {}) {
|
async adminList({ status, limit = 200, offset = 0 } = {}) {
|
||||||
return this.repo.listAll({ status, limit, offset });
|
return this.repo.listAll({ status, limit, offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateStatus(invoiceId, newStatus) {
|
||||||
|
const invoice = await this.repo.getById(invoiceId);
|
||||||
|
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
||||||
|
|
||||||
|
// If transitioning to 'paid', use the full markPaid flow for pool inflow booking
|
||||||
|
if (newStatus === 'paid' && invoice.status !== 'paid') {
|
||||||
|
return this.markPaid(invoiceId, {
|
||||||
|
payment_method: 'admin_manual',
|
||||||
|
amount: invoice.total_gross ?? 0,
|
||||||
|
paid_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repo.updateStatus(invoiceId, newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoiceDetail(invoiceId) {
|
||||||
|
const invoice = await this.repo.getById(invoiceId);
|
||||||
|
if (!invoice) throw new Error(`Invoice ${invoiceId} not found.`);
|
||||||
|
const items = await this.repo.getItemsByInvoiceId(invoiceId);
|
||||||
|
const payments = await this.repo.getPaymentsByInvoiceId(invoiceId);
|
||||||
|
return { invoice, items, payments };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = InvoiceService;
|
module.exports = InvoiceService;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
const db = require('../../database/database');
|
const db = require('../../database/database');
|
||||||
|
|
||||||
|
const SYSTEM_POOL_NAMES = ['ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core'];
|
||||||
|
|
||||||
function toTwo(value) {
|
function toTwo(value) {
|
||||||
return Number(Number(value || 0).toFixed(2));
|
return Number(Number(value || 0).toFixed(2));
|
||||||
}
|
}
|
||||||
@ -9,6 +11,60 @@ function toFour(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PoolInflowService {
|
class PoolInflowService {
|
||||||
|
async upsertCapsuleSalesForInvoice({ conn, invoiceId, abonementId, paidAtDate, currency, byCoffee }) {
|
||||||
|
const entries = Array.from(byCoffee.entries());
|
||||||
|
for (const [coffeeId, capsulesCountRaw] of entries) {
|
||||||
|
const capsulesCount = Number(capsulesCountRaw || 0);
|
||||||
|
if (!Number.isFinite(capsulesCount) || capsulesCount <= 0) continue;
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
source: 'invoice_paid',
|
||||||
|
formula: 'packs * 10',
|
||||||
|
};
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO capsule_sales
|
||||||
|
(invoice_id, abonement_id, coffee_table_id, capsules_count, sold_at, currency, details, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
abonement_id = VALUES(abonement_id),
|
||||||
|
capsules_count = VALUES(capsules_count),
|
||||||
|
sold_at = VALUES(sold_at),
|
||||||
|
currency = VALUES(currency),
|
||||||
|
details = VALUES(details),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
Number(invoiceId),
|
||||||
|
Number(abonementId),
|
||||||
|
Number(coffeeId),
|
||||||
|
capsulesCount,
|
||||||
|
paidAtDate,
|
||||||
|
currency || 'EUR',
|
||||||
|
JSON.stringify(details),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCapsuleSalesMap({ conn, invoiceId }) {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT coffee_table_id, capsules_count
|
||||||
|
FROM capsule_sales
|
||||||
|
WHERE invoice_id = ?`,
|
||||||
|
[Number(invoiceId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const byCoffee = new Map();
|
||||||
|
for (const row of rows || []) {
|
||||||
|
const coffeeId = Number(row.coffee_table_id);
|
||||||
|
const capsulesCount = Number(row.capsules_count || 0);
|
||||||
|
if (!Number.isFinite(coffeeId) || coffeeId <= 0) continue;
|
||||||
|
if (!Number.isFinite(capsulesCount) || capsulesCount <= 0) continue;
|
||||||
|
byCoffee.set(coffeeId, (byCoffee.get(coffeeId) || 0) + capsulesCount);
|
||||||
|
}
|
||||||
|
return byCoffee;
|
||||||
|
}
|
||||||
|
|
||||||
async analyzePaidInvoice({ invoiceId, paidAt }) {
|
async analyzePaidInvoice({ invoiceId, paidAt }) {
|
||||||
const normalizedInvoiceId = Number(invoiceId);
|
const normalizedInvoiceId = Number(invoiceId);
|
||||||
if (!Number.isFinite(normalizedInvoiceId) || normalizedInvoiceId <= 0) {
|
if (!Number.isFinite(normalizedInvoiceId) || normalizedInvoiceId <= 0) {
|
||||||
@ -82,17 +138,23 @@ class PoolInflowService {
|
|||||||
byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount);
|
byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coffeeIds = Array.from(byCoffee.keys());
|
const placeholders = SYSTEM_POOL_NAMES.map(() => '?').join(',');
|
||||||
const placeholders = coffeeIds.map(() => '?').join(',');
|
|
||||||
const [pools] = await db.query(
|
const [pools] = await db.query(
|
||||||
`SELECT id, pool_name, subscription_coffee_id, price
|
`SELECT p.id,
|
||||||
FROM pools
|
p.pool_name,
|
||||||
WHERE is_active = 1 AND subscription_coffee_id IN (${placeholders})`,
|
MAX(COALESCE(r.price_per_capsule_gross, p.price, 0)) AS price_per_capsule_gross,
|
||||||
coffeeIds
|
COUNT(DISTINCT pm.user_id) AS members_count
|
||||||
|
FROM pools p
|
||||||
|
LEFT JOIN pool_capsule_rules r ON r.pool_id = p.id AND r.is_active = 1
|
||||||
|
LEFT JOIN pool_members pm ON pm.pool_id = p.id
|
||||||
|
WHERE p.is_active = 1
|
||||||
|
AND p.pool_name IN (${placeholders})
|
||||||
|
GROUP BY p.id, p.pool_name`,
|
||||||
|
SYSTEM_POOL_NAMES
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!Array.isArray(pools) || pools.length === 0) {
|
if (!Array.isArray(pools) || pools.length === 0) {
|
||||||
return { ok: false, reason: 'no_linked_pools', invoice, abonementId, normalizedLines };
|
return { ok: false, reason: 'no_active_system_pools', invoice, abonementId, normalizedLines };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -125,17 +187,42 @@ class PoolInflowService {
|
|||||||
try {
|
try {
|
||||||
let alreadyExists = 0;
|
let alreadyExists = 0;
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
await this.upsertCapsuleSalesForInvoice({
|
||||||
|
conn,
|
||||||
|
invoiceId: normalizedInvoiceId,
|
||||||
|
abonementId,
|
||||||
|
paidAtDate,
|
||||||
|
currency,
|
||||||
|
byCoffee,
|
||||||
|
});
|
||||||
|
|
||||||
|
const capsuleSalesByCoffee = await this.getCapsuleSalesMap({ conn, invoiceId: normalizedInvoiceId });
|
||||||
|
const coffeeEntries = Array.from(capsuleSalesByCoffee.entries());
|
||||||
|
const totalCandidates = pools.reduce((acc, pool) => {
|
||||||
|
const memberMultiplier = pool.pool_name === 'Core' ? Number(pool.members_count || 0) : 1;
|
||||||
|
if (memberMultiplier <= 0) return acc;
|
||||||
|
return acc + coffeeEntries.length;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
for (const pool of pools) {
|
for (const pool of pools) {
|
||||||
const coffeeId = Number(pool.subscription_coffee_id);
|
const pricePerCapsuleGross = toFour(pool.price_per_capsule_gross);
|
||||||
const capsulesCount = Number(byCoffee.get(coffeeId) || 0);
|
const memberMultiplier = pool.pool_name === 'Core' ? Number(pool.members_count || 0) : 1;
|
||||||
|
if (memberMultiplier <= 0) continue;
|
||||||
|
|
||||||
|
for (const [coffeeId, capsulesCountRaw] of coffeeEntries) {
|
||||||
|
const capsulesCount = Number(capsulesCountRaw || 0);
|
||||||
if (!capsulesCount) continue;
|
if (!capsulesCount) continue;
|
||||||
|
|
||||||
const pricePerCapsuleNet = toFour(pool.price);
|
const amountGross = toTwo(capsulesCount * pricePerCapsuleGross * memberMultiplier);
|
||||||
const amountNet = toTwo(capsulesCount * pricePerCapsuleNet);
|
|
||||||
const details = {
|
const details = {
|
||||||
source: 'invoice_paid',
|
source: 'invoice_paid',
|
||||||
formula: 'capsules_count * price_per_capsule_net',
|
formula: 'capsules_count * price_per_capsule_gross * member_multiplier',
|
||||||
paid_at: paidAtDate,
|
paid_at: paidAtDate,
|
||||||
|
booking_basis: 'gross',
|
||||||
|
compatibility_note: 'gross values stored in existing net columns',
|
||||||
|
member_multiplier: memberMultiplier,
|
||||||
|
core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [res] = await conn.query(
|
const [res] = await conn.query(
|
||||||
@ -147,10 +234,10 @@ class PoolInflowService {
|
|||||||
Number(pool.id),
|
Number(pool.id),
|
||||||
normalizedInvoiceId,
|
normalizedInvoiceId,
|
||||||
abonementId,
|
abonementId,
|
||||||
coffeeId,
|
Number(coffeeId),
|
||||||
capsulesCount,
|
capsulesCount,
|
||||||
pricePerCapsuleNet,
|
pricePerCapsuleGross,
|
||||||
amountNet,
|
amountGross,
|
||||||
currency,
|
currency,
|
||||||
JSON.stringify(details),
|
JSON.stringify(details),
|
||||||
actorUserId || null,
|
actorUserId || null,
|
||||||
@ -160,9 +247,10 @@ class PoolInflowService {
|
|||||||
if (res && Number(res.affectedRows) === 1) inserted += 1;
|
if (res && Number(res.affectedRows) === 1) inserted += 1;
|
||||||
else alreadyExists += 1;
|
else alreadyExists += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await conn.commit();
|
await conn.commit();
|
||||||
return { inserted, alreadyExists, skipped: Math.max(0, pools.length - inserted), reason: 'ok' };
|
return { inserted, alreadyExists, skipped: Math.max(0, totalCandidates - inserted - alreadyExists), reason: 'ok' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
throw err;
|
throw err;
|
||||||
@ -183,31 +271,53 @@ class PoolInflowService {
|
|||||||
|
|
||||||
const invoiceIdNum = Number(analysis.invoice.id);
|
const invoiceIdNum = Number(analysis.invoice.id);
|
||||||
const poolEntries = [];
|
const poolEntries = [];
|
||||||
|
let coffeeEntries = [];
|
||||||
|
|
||||||
|
const [salesRows] = await db.query(
|
||||||
|
`SELECT coffee_table_id, capsules_count
|
||||||
|
FROM capsule_sales
|
||||||
|
WHERE invoice_id = ?`,
|
||||||
|
[invoiceIdNum]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(salesRows) && salesRows.length) {
|
||||||
|
coffeeEntries = salesRows
|
||||||
|
.map((row) => [Number(row.coffee_table_id), Number(row.capsules_count || 0)])
|
||||||
|
.filter(([coffeeId, capsulesCount]) => Number.isFinite(coffeeId) && coffeeId > 0 && Number.isFinite(capsulesCount) && capsulesCount > 0);
|
||||||
|
} else {
|
||||||
|
coffeeEntries = Array.from(analysis.byCoffee.entries());
|
||||||
|
}
|
||||||
|
|
||||||
for (const pool of analysis.pools) {
|
for (const pool of analysis.pools) {
|
||||||
const coffeeId = Number(pool.subscription_coffee_id);
|
const pricePerCapsuleGross = toFour(pool.price_per_capsule_gross);
|
||||||
const capsulesCount = Number(analysis.byCoffee.get(coffeeId) || 0);
|
const memberMultiplier = pool.pool_name === 'Core' ? Number(pool.members_count || 0) : 1;
|
||||||
const pricePerCapsuleNet = toFour(pool.price);
|
if (memberMultiplier <= 0) continue;
|
||||||
const amountNet = toTwo(capsulesCount * pricePerCapsuleNet);
|
|
||||||
|
for (const [coffeeId, capsulesCountRaw] of coffeeEntries) {
|
||||||
|
const capsulesCount = Number(capsulesCountRaw || 0);
|
||||||
|
const amountGross = toTwo(capsulesCount * pricePerCapsuleGross * memberMultiplier);
|
||||||
const [existingRows] = await db.query(
|
const [existingRows] = await db.query(
|
||||||
`SELECT id, created_at
|
`SELECT id, created_at
|
||||||
FROM pool_inflows
|
FROM pool_inflows
|
||||||
WHERE pool_id = ? AND invoice_id = ? AND coffee_table_id = ? AND event_type = 'invoice_paid'
|
WHERE pool_id = ? AND invoice_id = ? AND coffee_table_id = ? AND event_type = 'invoice_paid'
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[Number(pool.id), invoiceIdNum, coffeeId]
|
[Number(pool.id), invoiceIdNum, Number(coffeeId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
poolEntries.push({
|
poolEntries.push({
|
||||||
pool_id: Number(pool.id),
|
pool_id: Number(pool.id),
|
||||||
pool_name: pool.pool_name,
|
pool_name: pool.pool_name,
|
||||||
coffee_table_id: coffeeId,
|
coffee_table_id: Number(coffeeId),
|
||||||
capsules_count: capsulesCount,
|
capsules_count: capsulesCount,
|
||||||
price_per_capsule_net: pricePerCapsuleNet,
|
price_per_capsule_gross: pricePerCapsuleGross,
|
||||||
amount_net: amountNet,
|
member_multiplier: memberMultiplier,
|
||||||
|
core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null,
|
||||||
|
amount_gross: amountGross,
|
||||||
already_booked: !!existingRows?.length,
|
already_booked: !!existingRows?.length,
|
||||||
existing_inflow_id: existingRows?.[0]?.id || null,
|
existing_inflow_id: existingRows?.[0]?.id || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -2,50 +2,12 @@ const UnitOfWork = require('../../database/UnitOfWork');
|
|||||||
const PoolRepository = require('../../repositories/pool/poolRepository');
|
const PoolRepository = require('../../repositories/pool/poolRepository');
|
||||||
const db = require('../../database/database');
|
const db = require('../../database/database');
|
||||||
|
|
||||||
function isValidPoolType(pool_type) {
|
|
||||||
return pool_type === 'coffee' || pool_type === 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPool({ pool_name, description = null, price = 0.00, subscription_coffee_id = null, pool_type = 'other', is_active = true, created_by = null }) {
|
async function createPool({ pool_name, description = null, price = 0.00, subscription_coffee_id = null, pool_type = 'other', is_active = true, created_by = null }) {
|
||||||
if (!isValidPoolType(pool_type)) {
|
const err = new Error('Manual pool creation is disabled. System supports only: ABO 60, ABO 120, Business, Gigantea, Core.');
|
||||||
const err = new Error('Invalid pool_type. Allowed: coffee, other');
|
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalizedSubscriptionCoffeeId = null;
|
|
||||||
if (subscription_coffee_id !== null && subscription_coffee_id !== undefined && String(subscription_coffee_id).trim() !== '') {
|
|
||||||
const sid = Number(subscription_coffee_id);
|
|
||||||
if (!Number.isFinite(sid) || sid <= 0) {
|
|
||||||
const err = new Error('Invalid subscription_coffee_id');
|
|
||||||
err.status = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const [rows] = await db.query('SELECT id FROM coffee_table WHERE id = ? LIMIT 1', [sid]);
|
|
||||||
if (!rows.length) {
|
|
||||||
const err = new Error('Selected subscription not found');
|
|
||||||
err.status = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
normalizedSubscriptionCoffeeId = sid;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uow = new UnitOfWork();
|
|
||||||
try {
|
|
||||||
console.debug('[PoolService.createPool] start', { pool_name, pool_type, subscription_coffee_id: normalizedSubscriptionCoffeeId });
|
|
||||||
await uow.start();
|
|
||||||
const repo = new PoolRepository(uow);
|
|
||||||
const pool = await repo.create({ pool_name, description, price, subscription_coffee_id: normalizedSubscriptionCoffeeId, pool_type, is_active, created_by });
|
|
||||||
await uow.commit();
|
|
||||||
console.debug('[PoolService.createPool] success', { id: pool.id });
|
|
||||||
return pool;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[PoolService.createPool] error', err);
|
|
||||||
try { await uow.rollback(); } catch (_) { console.warn('[PoolService.createPool] rollback failed'); }
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPools() {
|
async function listPools() {
|
||||||
const uow = new UnitOfWork();
|
const uow = new UnitOfWork();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user