feat: BI estrategico — cohort retention, revenue expansion, cross-sell, maturity + receita em USD

- Cohort retention heatmap com matriz de retencao por mes de aquisicao
- Revenue expansion/contraction waterfall (new/expansion/stable/contraction/churned)
- Cross-sell analysis (CambioPay only vs CambioCheckout only vs Both)
- Client maturity segmentation (new/growing/mature/declining)
- Nova query fetchBIStrategic + endpoint /admin/api/bi/strategic
- Todas receitas de spread convertidas de BRL para USD em todas as queries
- Labels e formatacao atualizados para USD em admin-bi e admin-cliente

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-16 15:36:39 -05:00
parent 4595be0b07
commit 175e21b2d4
4 changed files with 1987 additions and 1176 deletions

View File

@@ -11,7 +11,7 @@ const express = require('express');
const session = require('express-session');
const path = require('path');
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
@@ -370,6 +370,18 @@ app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
}
});
app.get('/admin/api/bi/strategic', requireRole('admin'), async (req, res) => {
try {
const { start, end } = req.query;
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
const data = await fetchBIStrategic(start, end);
res.json(data);
} catch (err) {
console.error('Strategic BI API error:', err);
res.status(500).json({ error: err.message });
}
});
// --- Admin Cliente Dashboard (admin only) ---
app.get('/admin/cliente', requireRole('admin'), (req, res) => {
try {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -524,7 +524,7 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
COUNT(*) as qtd,
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd), 0), 2) as spread_revenue,
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd), 0), 2) as spread_revenue,
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
COUNT(DISTINCT id_conta) as clientes
FROM br_transaction_to_usa
@@ -537,7 +537,7 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
COUNT(*) as qtd,
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor), 0), 2) as spread_revenue,
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor), 0), 2) as spread_revenue,
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
COUNT(DISTINCT id_conta) as clientes
FROM pagamento_br
@@ -571,12 +571,12 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
// 5. Previous period totals for comparison
const [prevBrlUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [prevStartStr, prevEndStr]);
const [prevUsdBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
`, [prevStartStr, prevEndStr]);
@@ -679,14 +679,14 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
FROM (
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
SUM((t.exchange_rate - t.ptax) * t.amount_usd) as spread_rev
SUM((t.exchange_rate - t.ptax) / t.exchange_rate * t.amount_usd) as spread_rev
FROM br_transaction_to_usa t
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY ac.agente_id, t.id_conta
UNION ALL
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
SUM((p.ptax - p.cotacao) * p.valor) as spread_rev
SUM((p.ptax - p.cotacao) / p.ptax * p.valor) as spread_rev
FROM pagamento_br p
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
@@ -984,7 +984,7 @@ async function fetchClientProfile(clienteId) {
const [brl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
MIN(created_at) as first_op, MAX(created_at) as last_op
FROM br_transaction_to_usa WHERE id_conta = ?
`, [clienteId]);
@@ -993,7 +993,7 @@ async function fetchClientProfile(clienteId) {
const [usd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
MIN(created_at) as first_op, MAX(created_at) as last_op
FROM pagamento_br WHERE id_conta = ?
AND cotacao IS NOT NULL AND cotacao > 0
@@ -1011,6 +1011,20 @@ async function fetchClientProfile(clienteId) {
const brlQtd = Number(brlData.qtd) || 0;
const usdQtd = Number(usdData.qtd) || 0;
const totalOps = brlQtd + usdQtd;
const totalVolUsd = (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0);
const totalSpreadRev = (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0);
// Months active (distinct months with transactions)
const [monthsRows] = await conn.execute(`
SELECT COUNT(DISTINCT mes) as months_active FROM (
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM br_transaction_to_usa WHERE id_conta = ?
UNION
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM pagamento_br WHERE id_conta = ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
) m
`, [clienteId, clienteId]);
const monthsActive = Number(monthsRows[0]?.months_active) || 0;
return {
id: clienteId,
@@ -1018,10 +1032,15 @@ async function fetchClientProfile(clienteId) {
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
days_inactive: daysInactive,
total_ops: brlQtd + usdQtd,
total_vol_usd: (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0),
total_ops: totalOps,
total_vol_usd: totalVolUsd,
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
total_spread_revenue: (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0),
total_spread_revenue: totalSpreadRev,
months_active: monthsActive,
avg_monthly_vol: monthsActive > 0 ? Math.round(totalVolUsd / monthsActive) : 0,
avg_monthly_ops: monthsActive > 0 ? Math.round(totalOps / monthsActive * 10) / 10 : 0,
avg_monthly_revenue: monthsActive > 0 ? Math.round(totalSpreadRev / monthsActive * 100) / 100 : 0,
ltv: totalSpreadRev,
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
};
@@ -1047,7 +1066,7 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
const [kpiBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100),0),2) as avg_spread_pct
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [clienteId, dataInicio, dataFim]);
@@ -1056,7 +1075,7 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
const [kpiUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),0),2) as avg_spread_pct
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
@@ -1065,12 +1084,12 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
// Previous period
const [prevBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [clienteId, prevStartStr, prevEndStr]);
const [prevUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
`, [clienteId, prevStartStr, prevEndStr]);
@@ -1151,14 +1170,18 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
GROUP BY p.tipo_envio
`, [clienteId, dataInicio, dataFim]);
// Monthly average
// Monthly breakdown (volume + revenue + qty)
const [monthlyBrl] = await conn.execute(`
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(amount_usd),2) as avg_usd, COUNT(*) as qtd
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
ROUND(SUM(amount_usd),2) as vol_usd,
ROUND(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),2) as spread_revenue
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
`, [clienteId, dataInicio, dataFim]);
const [monthlyUsd] = await conn.execute(`
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(valor),2) as avg_usd, COUNT(*) as qtd
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
ROUND(SUM(valor),2) as vol_usd,
ROUND(SUM((ptax - cotacao) / ptax * valor),2) as spread_revenue
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
@@ -1193,12 +1216,13 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
provMap[n].vol_usd += Number(r.vol_usd);
});
// Merge monthly avg
// Merge monthly data
const monthMap = {};
[...monthlyBrl, ...monthlyUsd].forEach(r => {
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, total_usd: 0, total_qtd: 0 };
monthMap[r.mes].total_usd += Number(r.avg_usd) * Number(r.qtd);
monthMap[r.mes].total_qtd += Number(r.qtd);
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, qtd: 0, vol_usd: 0, spread_revenue: 0 };
monthMap[r.mes].qtd += Number(r.qtd);
monthMap[r.mes].vol_usd += Number(r.vol_usd);
monthMap[r.mes].spread_revenue += Number(r.spread_revenue);
});
const transactions = [
@@ -1234,13 +1258,234 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
transactions,
dayOfWeek: dowMap,
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
monthlyAvg: Object.values(monthMap).map(m => ({ mes: m.mes, avg_usd: m.total_qtd > 0 ? Math.round(m.total_usd / m.total_qtd) : 0, qtd: m.total_qtd })).sort((a, b) => a.mes.localeCompare(b.mes))
monthly: Object.values(monthMap).map(m => ({ mes: m.mes, qtd: m.qtd, vol_usd: Math.round(m.vol_usd * 100) / 100, spread_revenue: Math.round(m.spread_revenue * 100) / 100, avg_usd: m.qtd > 0 ? Math.round(m.vol_usd / m.qtd) : 0 })).sort((a, b) => a.mes.localeCompare(b.mes))
};
} finally {
conn.release();
}
}
async function fetchBIStrategic(dataInicio, dataFim) {
const conn = await pool.getConnection();
try {
const start = new Date(dataInicio);
const end = new Date(dataFim);
const periodDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
const prevEnd = new Date(start);
prevEnd.setDate(prevEnd.getDate() - 1);
const prevStart = new Date(prevEnd);
prevStart.setDate(prevStart.getDate() - periodDays + 1);
const prevStartStr = prevStart.toISOString().slice(0, 10);
const prevEndStr = prevEnd.toISOString().slice(0, 10);
// === 1. COHORT RETENTION ===
const [cohortClients] = await conn.execute(`
SELECT id_conta, DATE_FORMAT(MIN(first_op), '%Y-%m') as cohort_month FROM (
SELECT id_conta, MIN(created_at) as first_op FROM br_transaction_to_usa GROUP BY id_conta
UNION ALL
SELECT id_conta, MIN(created_at) as first_op FROM pagamento_br
WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
) f GROUP BY id_conta
`);
const [activeMonths] = await conn.execute(`
SELECT id_conta, active_month FROM (
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
FROM br_transaction_to_usa GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
UNION
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
) m
`);
const clientCohort = {};
cohortClients.forEach(r => { clientCohort[r.id_conta] = r.cohort_month; });
const clientMonths = {};
activeMonths.forEach(r => {
if (!clientMonths[r.id_conta]) clientMonths[r.id_conta] = new Set();
clientMonths[r.id_conta].add(r.active_month);
});
const allMonths = [...new Set([...cohortClients.map(r => r.cohort_month), ...activeMonths.map(r => r.active_month)])].sort();
const cohortMap = {};
cohortClients.forEach(r => {
const cm = r.cohort_month;
if (!cohortMap[cm]) cohortMap[cm] = { size: 0, months: {} };
cohortMap[cm].size++;
});
Object.keys(clientCohort).forEach(clientId => {
const cm = clientCohort[clientId];
const months = clientMonths[clientId] || new Set();
const cmIdx = allMonths.indexOf(cm);
months.forEach(am => {
const amIdx = allMonths.indexOf(am);
const offset = amIdx - cmIdx;
if (offset >= 0) {
if (!cohortMap[cm].months[offset]) cohortMap[cm].months[offset] = 0;
cohortMap[cm].months[offset]++;
}
});
});
const cohortKeys = Object.keys(cohortMap).sort().slice(-12);
const cohorts = cohortKeys.map(cm => {
const c = cohortMap[cm];
const maxOff = allMonths.length - allMonths.indexOf(cm);
const retention = [];
for (let i = 0; i < Math.min(maxOff, 13); i++) {
retention.push(c.size > 0 ? Math.round((c.months[i] || 0) / c.size * 100) : 0);
}
return { month: cm, size: c.size, retention };
});
// === 2. REVENUE EXPANSION / CONTRACTION ===
const [currRevenue] = await conn.execute(`
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY id_conta
UNION ALL
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
) c GROUP BY id_conta
`, [dataInicio, dataFim, dataInicio, dataFim]);
const [prevRevenue] = await conn.execute(`
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY id_conta
UNION ALL
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
) p GROUP BY id_conta
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
const currMap = {};
currRevenue.forEach(r => { currMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
const prevMap = {};
prevRevenue.forEach(r => { prevMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
const allClientIds = new Set([...Object.keys(currMap), ...Object.keys(prevMap)]);
const expansion = {
new_clients: { count: 0, revenue: 0 },
expansion: { count: 0, revenue: 0 },
stable: { count: 0, revenue: 0 },
contraction: { count: 0, revenue: 0 },
churned: { count: 0, revenue: 0 }
};
allClientIds.forEach(id => {
const curr = currMap[id];
const prev = prevMap[id];
if (curr && !prev) {
expansion.new_clients.count++; expansion.new_clients.revenue += curr.revenue;
} else if (!curr && prev) {
expansion.churned.count++; expansion.churned.revenue -= Math.abs(prev.revenue);
} else if (curr && prev) {
const absP = Math.abs(prev.revenue);
const change = absP > 0 ? (curr.revenue - prev.revenue) / absP : 0;
if (change > 0.1) {
expansion.expansion.count++; expansion.expansion.revenue += (curr.revenue - prev.revenue);
} else if (change < -0.1) {
expansion.contraction.count++; expansion.contraction.revenue += (curr.revenue - prev.revenue);
} else {
expansion.stable.count++; expansion.stable.revenue += curr.revenue;
}
}
});
// === 3. CROSS-SELL ===
const [crossSellData] = await conn.execute(`
SELECT c.id_conta, t.vol_usd as pay_vol, p.vol_usd as checkout_vol
FROM (
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
UNION
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
) c
LEFT JOIN (
SELECT id_conta, ROUND(SUM(amount_usd), 2) as vol_usd
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY id_conta
) t ON t.id_conta = c.id_conta
LEFT JOIN (
SELECT id_conta, ROUND(SUM(valor), 2) as vol_usd
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
) p ON p.id_conta = c.id_conta
`, [dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim]);
const crossSell = { pay_only: { count: 0, vol: 0 }, checkout_only: { count: 0, vol: 0 }, both: { count: 0, vol: 0 } };
crossSellData.forEach(r => {
const pv = Number(r.pay_vol) || 0;
const cv = Number(r.checkout_vol) || 0;
if (pv > 0 && cv > 0) { crossSell.both.count++; crossSell.both.vol += pv + cv; }
else if (pv > 0) { crossSell.pay_only.count++; crossSell.pay_only.vol += pv; }
else if (cv > 0) { crossSell.checkout_only.count++; crossSell.checkout_only.vol += cv; }
});
// === 4. CLIENT MATURITY SEGMENTS ===
const [maturityData] = await conn.execute(`
SELECT id_conta,
MIN(first_op) as first_op,
FLOOR(DATEDIFF(CURDATE(), MIN(first_op)) / 30.44) as months_active,
SUM(vol_usd) as lifetime_vol,
SUM(CASE WHEN period = 'curr' THEN vol_usd ELSE 0 END) as curr_vol,
SUM(CASE WHEN period = 'prev' THEN vol_usd ELSE 0 END) as prev_vol
FROM (
SELECT id_conta, MIN(created_at) as first_op, SUM(amount_usd) as vol_usd, 'all' as period
FROM br_transaction_to_usa GROUP BY id_conta
UNION ALL
SELECT id_conta, MIN(created_at) as first_op, SUM(valor) as vol_usd, 'all' as period
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
UNION ALL
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'curr' as period
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
UNION ALL
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'curr' as period
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
UNION ALL
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'prev' as period
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
UNION ALL
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'prev' as period
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY id_conta
) combined GROUP BY id_conta
`, [dataInicio, dataFim, dataInicio, dataFim, prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
const maturity = { new_client: { count: 0, vol: 0 }, growing: { count: 0, vol: 0 }, mature: { count: 0, vol: 0 }, declining: { count: 0, vol: 0 } };
maturityData.forEach(r => {
const months = Number(r.months_active) || 0;
const cv = Number(r.curr_vol) || 0;
const pv = Number(r.prev_vol) || 0;
const lv = Number(r.lifetime_vol) || 0;
if (months < 3) {
maturity.new_client.count++; maturity.new_client.vol += lv;
} else if (pv > 0 && cv < pv * 0.85) {
maturity.declining.count++; maturity.declining.vol += lv;
} else if (months >= 12) {
maturity.mature.count++; maturity.mature.vol += lv;
} else {
maturity.growing.count++; maturity.growing.vol += lv;
}
});
return { cohorts, expansion, crossSell, maturity };
} finally {
conn.release();
}
}
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
@@ -1253,6 +1498,7 @@ module.exports = {
fetchKPIsByPeriod,
fetchBIData,
fetchRevenueAnalytics,
fetchBIStrategic,
fetchClientList,
fetchClientProfile,
fetchClientData