From 2c239ad331406d5df2f66a6469f303f21e93c873 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 8 Mar 2026 16:28:46 +0100 Subject: [PATCH] feat: enhance invoice and pool management with new status updates, detailed invoice retrieval, and pool statistics --- controller/invoice/InvoiceController.js | 24 +++ controller/pool/PoolController.js | 38 ++++ database/createDb.js | 98 +++++++++ repositories/invoice/InvoiceRepository.js | 20 ++ repositories/pool/poolMemberRepository.js | 25 ++- repositories/pool/poolRepository.js | 3 +- routes/getRoutes.js | 3 + routes/patchRoutes.js | 4 + services/invoice/InvoiceService.js | 31 +++ services/pool/PoolInflowService.js | 232 ++++++++++++++++------ services/pool/PoolService.js | 44 +--- 11 files changed, 418 insertions(+), 104 deletions(-) diff --git a/controller/invoice/InvoiceController.js b/controller/invoice/InvoiceController.js index c7b81f9..d4a7628 100644 --- a/controller/invoice/InvoiceController.js +++ b/controller/invoice/InvoiceController.js @@ -45,4 +45,28 @@ module.exports = { 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 }); + } + }, }; diff --git a/controller/pool/PoolController.js b/controller/pool/PoolController.js index a95afa3..02985e2 100644 --- a/controller/pool/PoolController.js +++ b/controller/pool/PoolController.js @@ -154,5 +154,43 @@ module.exports = { console.error('[PoolController.inflowDiagnostics]', e); 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' }); + } } }; \ No newline at end of file diff --git a/database/createDb.js b/database/createDb.js index c5c0c45..4998b6b 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -54,6 +54,14 @@ if (NODE_ENV === 'development') { 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 --- async function ensureIndex(conn, table, indexName, indexDDL) { 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(` CREATE TABLE IF NOT EXISTS pool_members ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -1111,6 +1185,30 @@ const createDatabase = async () => { `); 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 await connection.query(` CREATE TABLE IF NOT EXISTS pool_inflows ( diff --git a/repositories/invoice/InvoiceRepository.js b/repositories/invoice/InvoiceRepository.js index 4a6cfa2..580814b 100644 --- a/repositories/invoice/InvoiceRepository.js +++ b/repositories/invoice/InvoiceRepository.js @@ -195,6 +195,26 @@ class InvoiceRepository { ); 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; diff --git a/repositories/pool/poolMemberRepository.js b/repositories/pool/poolMemberRepository.js index de9cbbd..083c4e1 100644 --- a/repositories/pool/poolMemberRepository.js +++ b/repositories/pool/poolMemberRepository.js @@ -9,6 +9,25 @@ class PoolMemberRepository { const conn = this.uow.connection; try { 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( `SELECT u.id, @@ -27,8 +46,12 @@ class PoolMemberRepository { ORDER BY pm.joined_at DESC`, [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 }); - return rows; + return enriched; } catch (error) { logger.error('PoolMemberRepository.listMembers:error', { poolId, error: error.message }); throw error; diff --git a/repositories/pool/poolRepository.js b/repositories/pool/poolRepository.js index 5e5bb51..b0340d8 100644 --- a/repositories/pool/poolRepository.js +++ b/repositories/pool/poolRepository.js @@ -35,8 +35,9 @@ class PoolRepository { FROM pools p LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_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 - 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); console.info('PoolRepository.findAll:success', { count: rows.length }); return rows.map(r => new Pool(r)); diff --git a/routes/getRoutes.js b/routes/getRoutes.js index c13fb45..dfe66a3 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -137,6 +137,8 @@ router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list); router.get('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.listMembers); // NEW: Admin diagnose pool inflow for invoice 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 router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices); @@ -172,6 +174,7 @@ router.get('/news/:slug', NewsController.getPublic); // NEW: Invoice GETs router.get('/invoices/mine', authMiddleware, InvoiceController.listMine); 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. diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index b325b8a..43fd94e 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -13,6 +13,7 @@ const MatrixController = require('../controller/matrix/MatrixController'); // <- const AffiliateController = require('../controller/affiliate/AffiliateController'); // <-- new const NewsController = require('../controller/news/NewsController'); const AbonemmentController = require('../controller/abonemments/AbonemmentController'); +const InvoiceController = require('../controller/invoice/InvoiceController'); const multer = require('multer'); 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/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 router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic); router.patch('/profile/personal/bank', authMiddleware, PersonalProfileController.updateBank); diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 143e10a..76f170d 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -416,20 +416,27 @@ class InvoiceService { 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 }); + let poolResult = null; try { const inflowResult = await PoolInflowService.bookForPaidInvoice({ invoiceId: paidInvoice?.id, paidAt: paid_at, actorUserId: null, }); + poolResult = inflowResult; console.log('[INVOICE PAID] Pool inflow booking result:', { invoiceId: paidInvoice?.id, ...inflowResult, }); } catch (e) { + poolResult = { error: e?.message || 'Pool inflow booking failed' }; 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; } @@ -444,6 +451,30 @@ class InvoiceService { async adminList({ status, limit = 200, offset = 0 } = {}) { 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; diff --git a/services/pool/PoolInflowService.js b/services/pool/PoolInflowService.js index cdef633..7813a43 100644 --- a/services/pool/PoolInflowService.js +++ b/services/pool/PoolInflowService.js @@ -1,5 +1,7 @@ const db = require('../../database/database'); +const SYSTEM_POOL_NAMES = ['ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core']; + function toTwo(value) { return Number(Number(value || 0).toFixed(2)); } @@ -9,6 +11,60 @@ function toFour(value) { } 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 }) { const normalizedInvoiceId = Number(invoiceId); if (!Number.isFinite(normalizedInvoiceId) || normalizedInvoiceId <= 0) { @@ -82,17 +138,23 @@ class PoolInflowService { byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount); } - const coffeeIds = Array.from(byCoffee.keys()); - const placeholders = coffeeIds.map(() => '?').join(','); - const [pools] = await db.query( - `SELECT id, pool_name, subscription_coffee_id, price - FROM pools - WHERE is_active = 1 AND subscription_coffee_id IN (${placeholders})`, - coffeeIds + const placeholders = SYSTEM_POOL_NAMES.map(() => '?').join(','); + const [pools] = await db.query( + `SELECT p.id, + p.pool_name, + MAX(COALESCE(r.price_per_capsule_gross, p.price, 0)) AS price_per_capsule_gross, + 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) { - return { ok: false, reason: 'no_linked_pools', invoice, abonementId, normalizedLines }; + return { ok: false, reason: 'no_active_system_pools', invoice, abonementId, normalizedLines }; } return { @@ -123,46 +185,72 @@ class PoolInflowService { const conn = await db.getConnection(); let inserted = 0; try { - let alreadyExists = 0; + let alreadyExists = 0; 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) { - const coffeeId = Number(pool.subscription_coffee_id); - const capsulesCount = Number(byCoffee.get(coffeeId) || 0); - if (!capsulesCount) continue; + const pricePerCapsuleGross = toFour(pool.price_per_capsule_gross); + const memberMultiplier = pool.pool_name === 'Core' ? Number(pool.members_count || 0) : 1; + if (memberMultiplier <= 0) continue; - const pricePerCapsuleNet = toFour(pool.price); - const amountNet = toTwo(capsulesCount * pricePerCapsuleNet); - const details = { - source: 'invoice_paid', - formula: 'capsules_count * price_per_capsule_net', - paid_at: paidAtDate, - }; + for (const [coffeeId, capsulesCountRaw] of coffeeEntries) { + const capsulesCount = Number(capsulesCountRaw || 0); + if (!capsulesCount) continue; - const [res] = await conn.query( - `INSERT INTO pool_inflows - (pool_id, invoice_id, abonement_id, coffee_table_id, event_type, capsules_count, price_per_capsule_net, amount_net, currency, details, created_by_user_id, created_at) - VALUES (?, ?, ?, ?, 'invoice_paid', ?, ?, ?, ?, ?, ?, NOW()) - ON DUPLICATE KEY UPDATE id = id`, - [ - Number(pool.id), - normalizedInvoiceId, - abonementId, - coffeeId, - capsulesCount, - pricePerCapsuleNet, - amountNet, - currency, - JSON.stringify(details), - actorUserId || null, - ] - ); + const amountGross = toTwo(capsulesCount * pricePerCapsuleGross * memberMultiplier); + const details = { + source: 'invoice_paid', + formula: 'capsules_count * price_per_capsule_gross * member_multiplier', + 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, + }; - if (res && Number(res.affectedRows) === 1) inserted += 1; - else alreadyExists += 1; + const [res] = await conn.query( + `INSERT INTO pool_inflows + (pool_id, invoice_id, abonement_id, coffee_table_id, event_type, capsules_count, price_per_capsule_net, amount_net, currency, details, created_by_user_id, created_at) + VALUES (?, ?, ?, ?, 'invoice_paid', ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE id = id`, + [ + Number(pool.id), + normalizedInvoiceId, + abonementId, + Number(coffeeId), + capsulesCount, + pricePerCapsuleGross, + amountGross, + currency, + JSON.stringify(details), + actorUserId || null, + ] + ); + + if (res && Number(res.affectedRows) === 1) inserted += 1; + else alreadyExists += 1; + } } 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) { await conn.rollback(); throw err; @@ -183,30 +271,52 @@ class PoolInflowService { const invoiceIdNum = Number(analysis.invoice.id); 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) { - const coffeeId = Number(pool.subscription_coffee_id); - const capsulesCount = Number(analysis.byCoffee.get(coffeeId) || 0); - const pricePerCapsuleNet = toFour(pool.price); - const amountNet = toTwo(capsulesCount * pricePerCapsuleNet); - const [existingRows] = await db.query( - `SELECT id, created_at - FROM pool_inflows - WHERE pool_id = ? AND invoice_id = ? AND coffee_table_id = ? AND event_type = 'invoice_paid' - LIMIT 1`, - [Number(pool.id), invoiceIdNum, coffeeId] - ); + const pricePerCapsuleGross = toFour(pool.price_per_capsule_gross); + const memberMultiplier = pool.pool_name === 'Core' ? Number(pool.members_count || 0) : 1; + if (memberMultiplier <= 0) continue; - poolEntries.push({ - pool_id: Number(pool.id), - pool_name: pool.pool_name, - coffee_table_id: coffeeId, - capsules_count: capsulesCount, - price_per_capsule_net: pricePerCapsuleNet, - amount_net: amountNet, - already_booked: !!existingRows?.length, - existing_inflow_id: existingRows?.[0]?.id || null, - }); + for (const [coffeeId, capsulesCountRaw] of coffeeEntries) { + const capsulesCount = Number(capsulesCountRaw || 0); + const amountGross = toTwo(capsulesCount * pricePerCapsuleGross * memberMultiplier); + const [existingRows] = await db.query( + `SELECT id, created_at + FROM pool_inflows + WHERE pool_id = ? AND invoice_id = ? AND coffee_table_id = ? AND event_type = 'invoice_paid' + LIMIT 1`, + [Number(pool.id), invoiceIdNum, Number(coffeeId)] + ); + + poolEntries.push({ + pool_id: Number(pool.id), + pool_name: pool.pool_name, + coffee_table_id: Number(coffeeId), + capsules_count: capsulesCount, + price_per_capsule_gross: pricePerCapsuleGross, + member_multiplier: memberMultiplier, + core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null, + amount_gross: amountGross, + already_booked: !!existingRows?.length, + existing_inflow_id: existingRows?.[0]?.id || null, + }); + } } return { diff --git a/services/pool/PoolService.js b/services/pool/PoolService.js index 2a54ac7..6154fb0 100644 --- a/services/pool/PoolService.js +++ b/services/pool/PoolService.js @@ -2,48 +2,10 @@ const UnitOfWork = require('../../database/UnitOfWork'); const PoolRepository = require('../../repositories/pool/poolRepository'); 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 }) { - if (!isValidPoolType(pool_type)) { - const err = new Error('Invalid pool_type. Allowed: coffee, other'); - err.status = 400; - 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; - } + const err = new Error('Manual pool creation is disabled. System supports only: ABO 60, ABO 120, Business, Gigantea, Core.'); + err.status = 400; + throw err; } async function listPools() {