CentralBackend/services/pool/PoolInflowService.js

227 lines
7.6 KiB
JavaScript

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();