const db = require('../../database/database'); function toTwo(value) { return Number(Number(value || 0).toFixed(2)); } function toFour(value) { return Number(Number(value || 0).toFixed(4)); } class PoolInflowService { 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), capsulesCount: Number(line?.packs || 0) * 10, })) .filter((line) => Number.isFinite(line.coffeeId) && line.coffeeId > 0 && Number.isFinite(line.capsulesCount) && line.capsulesCount > 0) : []; if (!normalizedLines.length) return { ok: false, reason: 'no_breakdown_lines', invoice, abonementId }; const byCoffee = new Map(); for (const line of normalizedLines) { 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 ); if (!Array.isArray(pools) || pools.length === 0) { return { ok: false, reason: 'no_linked_pools', invoice, abonementId, normalizedLines }; } return { ok: true, reason: 'ok', invoice, abonementId, paidAtDate, byCoffee, pools, normalizedLines, 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 conn = await db.getConnection(); let inserted = 0; try { let alreadyExists = 0; await conn.beginTransaction(); for (const pool of pools) { const coffeeId = Number(pool.subscription_coffee_id); const capsulesCount = Number(byCoffee.get(coffeeId) || 0); if (!capsulesCount) 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, }; 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, ] ); 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' }; } 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 = []; 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] ); 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, }); } 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();