- Introduced language normalization utility functions to standardize language codes across the application. - Updated ContractUploadController to resolve requested language from contract data and user settings. - Enhanced authMiddleware to set preferred language in user object based on user settings. - Added preferred_language column to user_settings table in the database. - Implemented UserSettingsRepository to manage user settings, including preferred language updates. - Updated DocumentTemplateService and AboContractService to support language-specific templates. - Enhanced InvoiceService to select invoice templates based on normalized language codes. - Added new script to compare versions of ABO contract documents. - Refactored various services and repositories to utilize the new language normalization logic.
413 lines
14 KiB
JavaScript
413 lines
14 KiB
JavaScript
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();
|