const db = require('../../database/database'); const SYSTEM_POOL_NAMES = ['ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core']; const ABO_60_POOL_NAME = 'ABO 60'; const ABO_120_POOL_NAME = 'ABO 120'; function resolveAboPoolName(totalPacks) { const safeTotalPacks = Number(totalPacks || 0); if (!Number.isFinite(safeTotalPacks) || safeTotalPacks <= 0) return ABO_60_POOL_NAME; return safeTotalPacks <= 11 ? ABO_60_POOL_NAME : ABO_120_POOL_NAME; } function selectPoolsForAbonement(pools, totalPacks) { if (!Array.isArray(pools) || !pools.length) return []; const targetAboPoolName = resolveAboPoolName(totalPacks); return pools.filter((pool) => { const poolName = String(pool?.pool_name || '').trim(); if (poolName === ABO_60_POOL_NAME || poolName === ABO_120_POOL_NAME) { return poolName === targetAboPoolName; } return true; }); } function toTwo(value) { return Number(Number(value || 0).toFixed(2)); } function toFour(value) { return Number(Number(value || 0).toFixed(4)); } class PoolInflowService { async removeStaleAboPoolInflowsForInvoice({ conn, invoiceId, selectedAboPoolName }) { if (!conn || !invoiceId || !selectedAboPoolName) return 0; const [res] = await conn.query( `DELETE pi FROM pool_inflows pi INNER JOIN pools p ON p.id = pi.pool_id WHERE pi.invoice_id = ? AND pi.event_type = 'invoice_paid' AND p.pool_name IN (?, ?) AND p.pool_name <> ?`, [ Number(invoiceId), ABO_60_POOL_NAME, ABO_120_POOL_NAME, selectedAboPoolName, ] ); return Number(res?.affectedRows || 0); } 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) { return { ok: false, reason: 'invalid_invoice_id' }; } const [invoiceRows] = await db.query( `SELECT id, source_type, source_id, currency, status, context FROM invoices WHERE id = ? LIMIT 1`, [normalizedInvoiceId] ); const invoice = invoiceRows?.[0]; if (!invoice) return { ok: false, reason: 'invoice_not_found' }; if (String(invoice.status) !== 'paid') return { ok: false, reason: 'invoice_not_paid', invoice }; if (String(invoice.source_type) !== 'subscription') return { ok: false, reason: 'unsupported_source_type', invoice }; const abonementId = Number(invoice.source_id); if (!Number.isFinite(abonementId) || abonementId <= 0) { return { ok: false, reason: 'missing_abonement_relation', invoice }; } const paidAtCandidate = paidAt ? new Date(paidAt) : new Date(); const paidAtDate = Number.isFinite(paidAtCandidate.getTime()) ? paidAtCandidate : new Date(); let context = {}; try { context = invoice.context && typeof invoice.context === 'string' ? JSON.parse(invoice.context) : (invoice.context || {}); } catch (_) { context = {}; } const periodStart = context?.period_start ? new Date(context.period_start) : null; if (periodStart && Number.isFinite(periodStart.getTime()) && paidAtDate < periodStart) { return { ok: false, reason: 'paid_before_period_start', invoice, abonementId, paidAtDate, periodStart }; } const [abonRows] = await db.query( `SELECT id, pack_breakdown, currency FROM coffee_abonements WHERE id = ? LIMIT 1`, [abonementId] ); const abonement = abonRows?.[0]; if (!abonement) return { ok: false, reason: 'abonement_not_found', invoice, abonementId }; const breakdownRaw = abonement.pack_breakdown; let breakdown = []; try { breakdown = breakdownRaw ? (typeof breakdownRaw === 'string' ? JSON.parse(breakdownRaw) : breakdownRaw) : []; } catch (_) { breakdown = []; } const normalizedLines = Array.isArray(breakdown) ? breakdown .map((line) => ({ coffeeId: Number(line?.coffee_table_id), packsCount: Number(line?.packs || 0), capsulesCount: Number(line?.packs || 0) * 10, })) .filter((line) => ( Number.isFinite(line.coffeeId) && line.coffeeId > 0 && Number.isFinite(line.packsCount) && line.packsCount > 0 && Number.isFinite(line.capsulesCount) && line.capsulesCount > 0 )) : []; if (!normalizedLines.length) return { ok: false, reason: 'no_breakdown_lines', invoice, abonementId }; const totalPacks = normalizedLines.reduce((sum, line) => sum + Number(line.packsCount || 0), 0); const byCoffee = new Map(); for (const line of normalizedLines) { byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount); } 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_active_system_pools', invoice, abonementId, normalizedLines }; } const selectedPools = selectPoolsForAbonement(pools, totalPacks); if (!selectedPools.length) { return { ok: false, reason: 'no_matching_system_pools', invoice, abonementId, normalizedLines, totalPacks }; } return { ok: true, reason: 'ok', invoice, abonementId, paidAtDate, byCoffee, pools: selectedPools, normalizedLines, totalPacks, selectedAboPoolName: resolveAboPoolName(totalPacks), currency: invoice.currency || abonement.currency || 'EUR', }; } async bookForPaidInvoice({ invoiceId, paidAt, actorUserId = null }) { const analysis = await this.analyzePaidInvoice({ invoiceId, paidAt }); if (!analysis.ok) { return { inserted: 0, skipped: 0, reason: analysis.reason }; } const normalizedInvoiceId = Number(analysis.invoice.id); const abonementId = Number(analysis.abonementId); const paidAtDate = analysis.paidAtDate; const byCoffee = analysis.byCoffee; const pools = analysis.pools; const currency = analysis.currency; const selectedAboPoolName = analysis.selectedAboPoolName; const conn = await db.getConnection(); let inserted = 0; try { let alreadyExists = 0; let staleRemoved = 0; await conn.beginTransaction(); staleRemoved = await this.removeStaleAboPoolInflowsForInvoice({ conn, invoiceId: normalizedInvoiceId, selectedAboPoolName, }); 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 pricePerCapsuleGross = toFour(pool.price_per_capsule_gross); 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; 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', total_packs: analysis.totalPacks, selected_abo_pool: resolveAboPoolName(analysis.totalPacks), member_multiplier: memberMultiplier, core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null, }; 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, staleRemoved, skipped: Math.max(0, totalCandidates - inserted - alreadyExists), reason: 'ok', }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async getInvoiceInflowDiagnostics({ invoiceId, paidAt }) { const analysis = await this.analyzePaidInvoice({ invoiceId, paidAt }); if (!analysis.ok) { return { ok: false, reason: analysis.reason, invoiceId: Number(invoiceId), }; } 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 pricePerCapsuleGross = toFour(pool.price_per_capsule_gross); 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); 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 { ok: true, reason: 'ok', invoice_id: invoiceIdNum, abonement_id: Number(analysis.abonementId), paid_at: analysis.paidAtDate, currency: analysis.currency, candidates: poolEntries, will_book_count: poolEntries.filter((x) => !x.already_booked).length, already_booked_count: poolEntries.filter((x) => x.already_booked).length, }; } } module.exports = new PoolInflowService();