feat: integra dados de merchant/checkout no Client 360

Merchants (via br_cb_empresas) agora mostram dados de CambioCheckout:
- fetchMerchantProfile detecta merchant e retorna lifetime checkout stats
- fetchMerchantData retorna KPIs, monthly, top payers e transacoes por periodo
- fetchTopClients inclui checkout volume (merchants sobem no ranking)
- fetchClientSearch inclui merchants nos resultados de busca
- Profile/data endpoints fazem merge automatico dos dados checkout
- UI: badge MERCHANT roxo, 6 hero cards checkout, chart mensal, top 10 payers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-16 16:47:11 -05:00
parent 600a8044c2
commit a76ab30730
3 changed files with 435 additions and 20 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, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData, fetchMerchantProfile, fetchMerchantData } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
@@ -421,8 +421,32 @@ app.get('/admin/api/cliente/:id/profile', requireRole('admin'), async (req, res)
try {
const clienteId = parseInt(req.params.id);
if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' });
const data = await fetchClientProfile(clienteId);
res.json(data);
const [profile, merchant] = await Promise.all([
fetchClientProfile(clienteId),
fetchMerchantProfile(clienteId)
]);
if (merchant.is_merchant) {
const ck = merchant.checkout;
profile.merchant = { empresa_id: merchant.empresa_id, nome_empresa: merchant.nome_empresa };
profile.total_ops += ck.tx_count;
profile.total_vol_usd += ck.vol_usd;
profile.total_spread_revenue += ck.revenue;
profile.ltv = profile.total_spread_revenue;
// Extend date ranges
const dates = [profile.first_op, ck.first_op].filter(Boolean);
const lastDates = [profile.last_op, ck.last_op].filter(Boolean);
if (dates.length) profile.first_op = dates.sort()[0];
if (lastDates.length) {
profile.last_op = lastDates.sort().pop();
profile.days_inactive = Math.round((Date.now() - new Date(profile.last_op).getTime()) / 86400000);
}
profile.months_active = Math.max(profile.months_active, ck.months_active);
profile.avg_monthly_vol = profile.months_active > 0 ? Math.round(profile.total_vol_usd / profile.months_active) : 0;
profile.avg_monthly_ops = profile.months_active > 0 ? Math.round(profile.total_ops / profile.months_active * 10) / 10 : 0;
profile.avg_monthly_revenue = profile.months_active > 0 ? Math.round(profile.total_spread_revenue / profile.months_active * 100) / 100 : 0;
profile.checkout = ck;
}
res.json(profile);
} catch (err) {
console.error('Client profile API error:', err);
res.status(500).json({ error: err.message });
@@ -434,8 +458,39 @@ app.get('/admin/api/cliente/:id/data', requireRole('admin'), async (req, res) =>
const clienteId = parseInt(req.params.id);
const { start, end } = req.query;
if (!clienteId || !start || !end) return res.status(400).json({ error: 'client ID, start and end required' });
const data = await fetchClientData(clienteId, start, end);
res.json(data);
const merchant = await fetchMerchantProfile(clienteId);
if (merchant.is_merchant) {
const [data, mData] = await Promise.all([
fetchClientData(clienteId, start, end),
fetchMerchantData(merchant.empresa_id, start, end)
]);
// Add checkout KPIs
data.kpis.checkout = mData.kpis;
// Merge totals
data.kpis.total.qtd += mData.kpis.qtd;
data.kpis.total.vol_usd += mData.kpis.vol_usd;
data.kpis.total.spread_revenue += mData.kpis.revenue;
const totalQtd = data.kpis.total.qtd;
data.kpis.total.ticket_medio = totalQtd > 0 ? Math.round(data.kpis.total.vol_usd / totalQtd) : 0;
// Merge comparison
data.comparison.prev_qtd += mData.comparison.prev_qtd;
data.comparison.prev_vol_usd += mData.comparison.prev_vol_usd;
data.comparison.prev_spread += mData.comparison.prev_revenue;
// Merchant-specific data
data.merchant = {
monthly: mData.monthly,
topPayers: mData.topPayers,
comparison: mData.comparison
};
// Merge transactions (checkout txs get flow="Checkout")
data.transactions = data.transactions.concat(mData.transactions)
.sort((a, b) => b.date.localeCompare(a.date));
res.json(data);
} else {
const data = await fetchClientData(clienteId, start, end);
res.json(data);
}
} catch (err) {
console.error('Client data API error:', err);
res.status(500).json({ error: err.message });

View File

@@ -327,6 +327,29 @@ function buildAdminClienteHTML(user) {
[data-theme="dark"] .date-inputs input[type="date"] { background: var(--card); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .data-table tr:hover td { background: rgba(255,255,255,0.03); }
/* === Merchant / Checkout === */
.merchant-badge {
display: none; padding: 4px 12px; border-radius: 8px; font-size: 11px; font-weight: 800;
letter-spacing: 0.5px; background: var(--purple-bg); color: var(--purple, #7B1FA2);
text-transform: uppercase; margin-top: 4px;
}
.merchant-badge.visible { display: inline-block; }
.hero-card.checkout::before { background: linear-gradient(90deg, var(--purple, #7B1FA2), #AB47BC); }
.checkout-section { display: none; }
.checkout-section.visible { display: block; }
.flow-tag.checkout { background: var(--purple-bg); color: var(--purple, #7B1FA2); }
.top-payer-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.1s; }
.top-payer-row:last-child { border-bottom: none; }
.top-payer-row:hover { background: var(--bg); }
[data-theme="dark"] .top-payer-row:hover { background: rgba(0,255,136,0.04); }
.top-payer-rank { width: 28px; font-size: 11px; font-weight: 800; color: var(--text-muted); text-align: center; }
.top-payer-info { flex: 1; min-width: 0; }
.top-payer-name { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.top-payer-stats { font-size: 11px; color: var(--text-muted); }
.top-payer-vol { font-size: 14px; font-weight: 700; color: var(--purple, #7B1FA2); font-variant-numeric: tabular-nums; }
[data-theme="dark"] .top-payer-vol { color: #BC8CFF; }
[data-theme="dark"] .top-payer-name { font-family: 'SF Mono','Fira Code','Consolas',monospace; }
/* === Responsive === */
@media (max-width: 1200px) { .hero-grid { grid-template-columns: repeat(3, 1fr); } .intel-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 900px) { .charts-row, .charts-row.wide-left { grid-template-columns: 1fr; } .charts-row.triple { grid-template-columns: 1fr 1fr; } .charts-row.triple > :last-child { grid-column: span 2; } .intel-grid { grid-template-columns: 1fr; } .filter-divider { display: none; } .profile-card { flex-direction: column; text-align: center; } .profile-left { flex-direction: column; } .profile-stats { justify-content: center; } .health-gauge-wrap { flex-direction: column; align-items: center; } }
@@ -375,6 +398,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
<div>
<div class="profile-name" id="profileName">--</div>
<div class="profile-id" id="profileId">--</div>
<div class="merchant-badge" id="merchantBadge">MERCHANT</div>
</div>
</div>
<div class="profile-stats">
@@ -419,6 +443,36 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
<div class="hero-card avgspread"><div class="hero-label">Spread Medio</div><div class="hero-value" id="kpiAvgSpread">--</div><div class="hero-sub">% ponderado</div></div>
</div>
<!-- Checkout KPIs (merchant only) -->
<div class="checkout-section" id="checkoutKpiSection">
<div class="section-title" id="sectionCheckout">
<span class="icon">&#x1F6D2;</span>
CambioCheckout (Merchant)
</div>
<div class="hero-grid" id="checkoutHeroGrid">
<div class="hero-card checkout"><div class="hero-label">Checkout Volume</div><div class="hero-value" id="ckVolume">--</div><span class="hero-badge neutral" id="ckVolBadge">--</span></div>
<div class="hero-card checkout"><div class="hero-label">Payers Unicos</div><div class="hero-value" id="ckPayers">--</div><div class="hero-sub">pagadores distintos</div></div>
<div class="hero-card checkout"><div class="hero-label">Receita Checkout</div><div class="hero-value" id="ckRevenue">--</div><span class="hero-badge neutral" id="ckRevBadge">--</span></div>
<div class="hero-card checkout"><div class="hero-label">Checkout Ops</div><div class="hero-value" id="ckOps">--</div><span class="hero-badge neutral" id="ckOpsBadge">--</span></div>
<div class="hero-card checkout"><div class="hero-label">Ticket Checkout</div><div class="hero-value" id="ckTicket">--</div><div class="hero-sub">USD / operacao</div></div>
<div class="hero-card checkout"><div class="hero-label">Spread Checkout</div><div class="hero-value" id="ckSpread">--</div><div class="hero-sub">% medio</div></div>
</div>
</div>
<!-- Checkout Analytics (merchant only) -->
<div class="checkout-section" id="checkoutAnalyticsSection">
<div class="charts-row wide-left">
<div class="chart-card">
<h3>Checkout Mensal: Volume + Payers</h3>
<div class="chart-wrap"><canvas id="chartCheckoutMonthly"></canvas></div>
</div>
<div class="chart-card">
<h3>Top 10 Pagadores</h3>
<div id="topPayersList" style="max-height:310px;overflow-y:auto;"></div>
</div>
</div>
</div>
<!-- Section: Saude & Risco -->
<div class="section-title" id="sectionHealth">
<span class="icon">&#x1F3AF;</span>
@@ -566,6 +620,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
<nav class="console-nav" id="consoleNav" style="display:none;">
<a href="#searchWrap" class="console-nav-btn" data-section="searchWrap"><span class="nav-icon">&#x1F50D;</span><span class="nav-label">Busca</span></a>
<a href="#heroGrid" class="console-nav-btn" data-section="heroGrid"><span class="nav-icon">&#x1F4CA;</span><span class="nav-label">KPIs</span></a>
<a href="#sectionCheckout" class="console-nav-btn checkout-section" data-section="sectionCheckout"><span class="nav-icon">&#x1F6D2;</span><span class="nav-label">Checkout</span></a>
<a href="#sectionHealth" class="console-nav-btn" data-section="sectionHealth"><span class="nav-icon">&#x1F3AF;</span><span class="nav-label">Saude</span></a>
<a href="#sectionRevenue" class="console-nav-btn" data-section="sectionRevenue"><span class="nav-icon">&#x1F4B0;</span><span class="nav-label">Revenue</span></a>
<a href="#sectionTimeline" class="console-nav-btn" data-section="sectionTimeline"><span class="nav-icon">&#x1F4C8;</span><span class="nav-label">Timeline</span></a>
@@ -627,10 +682,10 @@ function applyChartDefaults(t) {
}
// === Charts ===
var chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM;
var chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM, chartCheckoutMonthly;
function destroyAllCharts() {
[chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM].forEach(function(c){if(c)c.destroy();});
chartTimeline = chartFlowDonut = chartSpreadTrend = chartDow = chartAvgSize = chartProvider = chartMonthlyRev = chartMoM = null;
[chartTimeline, chartFlowDonut, chartSpreadTrend, chartDow, chartAvgSize, chartProvider, chartMonthlyRev, chartMoM, chartCheckoutMonthly].forEach(function(c){if(c)c.destroy();});
chartTimeline = chartFlowDonut = chartSpreadTrend = chartDow = chartAvgSize = chartProvider = chartMonthlyRev = chartMoM = chartCheckoutMonthly = null;
}
// === Health Score ===
@@ -839,6 +894,8 @@ function clearClient() {
document.getElementById('emptyState').style.display = '';
document.getElementById('contentArea').classList.remove('visible');
document.getElementById('consoleNav').style.display = 'none';
document.getElementById('merchantBadge').classList.remove('visible');
document.querySelectorAll('.checkout-section').forEach(function(el){ el.classList.remove('visible'); });
destroyAllCharts();
var url = new URL(window.location); url.searchParams.delete('id'); history.replaceState(null, '', url);
}
@@ -860,6 +917,19 @@ function renderProfile(p) {
document.getElementById('profileLTV').textContent = fmtUSD(p.ltv || p.total_spread_revenue);
document.getElementById('profileVolume').textContent = fmtUSD(p.total_vol_usd);
document.getElementById('profileOps').textContent = p.total_ops;
// Merchant badge
var isMerchant = !!(p.merchant && p.merchant.nome_empresa);
var badge = document.getElementById('merchantBadge');
if (isMerchant) {
badge.textContent = 'MERCHANT: ' + p.merchant.nome_empresa;
badge.classList.add('visible');
} else {
badge.classList.remove('visible');
}
// Show/hide checkout sections
document.querySelectorAll('.checkout-section').forEach(function(el) {
el.classList.toggle('visible', isMerchant);
});
}
// === Data Loading ===
@@ -873,6 +943,11 @@ function loadData() {
function renderAll(d) {
var t = getChartTheme(); applyChartDefaults(t);
renderKPIs(d); renderIntelligence(d, t); renderCharts(d, t); renderTable(d); updatePeriodInfo();
if (profileData && profileData.merchant) {
renderCheckoutKPIs(d, t);
renderCheckoutMonthly(d, t);
renderTopPayers(d);
}
}
function updatePeriodInfo() {
var s = new Date(currentStart), e = new Date(currentEnd);
@@ -1077,6 +1152,55 @@ function renderProviderChart(d, t) {
}); } catch(e) {}
}
// === Checkout Renders (Merchant) ===
function renderCheckoutKPIs(d) {
var ck = d.kpis.checkout; if (!ck) return;
var cmp = (d.merchant && d.merchant.comparison) || {};
document.getElementById('ckVolume').textContent = fmtUSD(ck.vol_usd);
document.getElementById('ckPayers').textContent = ck.unique_payers;
document.getElementById('ckRevenue').textContent = fmtUSD(ck.revenue);
document.getElementById('ckOps').textContent = ck.qtd;
document.getElementById('ckTicket').textContent = fmtUSD(ck.ticket_medio);
document.getElementById('ckSpread').textContent = fmtPct(ck.avg_spread_pct);
var bv = badge(ck.vol_usd, cmp.prev_vol_usd);
document.getElementById('ckVolBadge').className = 'hero-badge ' + bv.cls; document.getElementById('ckVolBadge').textContent = bv.text;
var br = badge(ck.revenue, cmp.prev_revenue);
document.getElementById('ckRevBadge').className = 'hero-badge ' + br.cls; document.getElementById('ckRevBadge').textContent = br.text;
var bo = badge(ck.qtd, cmp.prev_qtd);
document.getElementById('ckOpsBadge').className = 'hero-badge ' + bo.cls; document.getElementById('ckOpsBadge').textContent = bo.text;
}
function renderCheckoutMonthly(d, t) {
if (!d.merchant || !d.merchant.monthly || !d.merchant.monthly.length) return;
var m = d.merchant.monthly;
if (chartCheckoutMonthly) chartCheckoutMonthly.destroy();
try {
chartCheckoutMonthly = new Chart(document.getElementById('chartCheckoutMonthly'), {
type: 'bar', data: {
labels: m.map(function(x){return x.mes;}),
datasets: [
{ label: 'Volume USD', data: m.map(function(x){return x.vol_usd;}), backgroundColor: 'rgba(188,140,255,0.2)', borderColor: 'rgba(188,140,255,0.6)', borderWidth: 1, borderRadius: 4, yAxisID: 'y', order: 2 },
{ label: 'Payers', data: m.map(function(x){return x.unique_payers;}), type: 'line', borderColor: t.lineQtd, backgroundColor: 'transparent', borderWidth: 2, pointRadius: 3, tension: 0.3, yAxisID: 'y1', order: 1 }
]
}, options: {
responsive: true, maintainAspectRatio: false, interaction: {mode:'index', intersect:false},
plugins: { legend: {position:'top', labels:{font:{size:11,weight:600},padding:12}},
tooltip: { callbacks: { label: function(ctx) { return ctx.dataset.label === 'Volume USD' ? 'Volume: ' + fmtUSD(ctx.raw) : 'Payers: ' + ctx.raw; }}}},
scales: {
y: { position:'left', grid:{color:t.grid}, ticks:{callback:function(v){return fmtUSD(v);}, font:{size:10}} },
y1: { position:'right', grid:{display:false}, ticks:{font:{size:10}, stepSize:1}, min:0 },
x: { grid:{display:false}, ticks:{font:{size:10}} }
}
}
}); } catch(e) { console.warn('chartCheckoutMonthly:', e.message); }
}
function renderTopPayers(d) {
var el = document.getElementById('topPayersList');
if (!d.merchant || !d.merchant.topPayers || !d.merchant.topPayers.length) { el.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:20px;">Sem dados</div>'; return; }
el.innerHTML = d.merchant.topPayers.map(function(p, i) {
return '<div class="top-payer-row" onclick="selectClient('+p.id_conta+',\\''+esc(p.nome).replace(/'/g,"\\\\'")+'\\')"><span class="top-payer-rank">#'+(i+1)+'</span><div class="top-payer-info"><div class="top-payer-name" title="'+esc(p.nome)+'">'+esc(p.nome)+'</div><div class="top-payer-stats">'+p.tx_count+' ops</div></div><span class="top-payer-vol">'+fmtUSD(p.vol_usd)+'</span></div>';
}).join('');
}
// === Transaction Table ===
function renderTable(d) { if (!d.transactions) return; currentPage = 1; _renderTablePage(); }
function _getSortedTx() {
@@ -1095,8 +1219,9 @@ function _renderTablePage() {
if (!page.length) { tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:var(--text-muted)">Nenhuma transacao</td></tr>'; }
else {
tbody.innerHTML = page.map(function(r) {
var fc = r.flow === 'BRL\\u2192USD' ? 'brl-usd' : 'usd-brl';
return '<tr><td>'+r.date.slice(0,16)+'</td><td><span class="flow-tag '+fc+'">'+r.flow+'</span></td><td>'+fmtUSD(r.usd)+'</td><td>'+fmtBRL(r.brl)+'</td><td>'+Number(r.rate).toFixed(4)+'</td><td>'+Number(r.ptax).toFixed(4)+'</td><td>'+fmtPct(r.spread_pct)+'</td><td>'+(r.iof?fmtPct(r.iof):'--')+'</td><td>'+(r.status||'--')+'</td><td>'+(r.provider||'--')+'</td></tr>';
var fc = r.flow === 'Checkout' ? 'checkout' : (r.flow === 'BRL\\u2192USD' ? 'brl-usd' : 'usd-brl');
var provCol = r.flow === 'Checkout' && r.payer_name ? esc(r.payer_name) : (r.provider||'--');
return '<tr><td>'+r.date.slice(0,16)+'</td><td><span class="flow-tag '+fc+'">'+r.flow+'</span></td><td>'+fmtUSD(r.usd)+'</td><td>'+fmtBRL(r.brl)+'</td><td>'+Number(r.rate).toFixed(4)+'</td><td>'+Number(r.ptax).toFixed(4)+'</td><td>'+fmtPct(r.spread_pct)+'</td><td>'+(r.iof?fmtPct(r.iof):'--')+'</td><td>'+(r.status||'--')+'</td><td>'+provCol+'</td></tr>';
}).join('');
}
var foot = document.getElementById('txTableFoot');
@@ -1163,7 +1288,7 @@ document.querySelectorAll('[data-tl-gran]').forEach(function(btn) {
});
// Console Nav
var _navSections = ['searchWrap','heroGrid','sectionHealth','sectionRevenue','sectionTimeline','sectionFlows','sectionTable','sectionInsights'];
var _navSections = ['searchWrap','heroGrid','sectionCheckout','sectionHealth','sectionRevenue','sectionTimeline','sectionFlows','sectionTable','sectionInsights'];
function updateConsoleNav() {
var sy = window.scrollY + 120, active = _navSections[0];
_navSections.forEach(function(id){var el=document.getElementById(id); if(el&&el.offsetTop<=sy) active=id;});

View File

@@ -955,16 +955,16 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
}
}
// Top 20 clients by total USD volume
// Top 20 clients by total USD volume (including checkout volume for merchants)
async function fetchTopClients() {
const conn = await pool.getConnection();
try {
const [rows] = await conn.execute(`
SELECT c.id_conta, c.nome,
COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) AS total_vol_usd,
COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) AS total_ops,
GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0)) AS months_active,
COALESCE(t1.last_op, t2.last_op) AS last_op
COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) + COALESCE(t3.vol_usd, 0) AS total_vol_usd,
COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) AS total_ops,
GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0), COALESCE(t3.months_active, 0)) AS months_active,
GREATEST(COALESCE(t1.last_op, '1970-01-01'), COALESCE(t2.last_op, '1970-01-01'), COALESCE(t3.last_op, '1970-01-01')) AS last_op
FROM conta c
LEFT JOIN (
SELECT id_conta,
@@ -982,7 +982,19 @@ async function fetchTopClients() {
MAX(created_at) AS last_op
FROM pagamento_br GROUP BY id_conta
) t2 ON t2.id_conta = c.id_conta
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) > 0
LEFT JOIN (
SELECT e.id_conta,
SUM(t.amount_usd) AS vol_usd,
COUNT(*) AS cnt,
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) AS months_active,
MAX(t.created_at) AS last_op
FROM br_cb_empresas e
INNER JOIN br_cb_cobranca cb ON cb.empresa_id = e.id
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
WHERE e.active = 1
GROUP BY e.id_conta
) t3 ON t3.id_conta = c.id_conta
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) > 0
ORDER BY total_vol_usd DESC
LIMIT 20
`);
@@ -992,14 +1004,14 @@ async function fetchTopClients() {
vol: Math.round(r.total_vol_usd || 0),
ops: r.total_ops || 0,
months: r.months_active || 0,
lastOp: r.last_op ? r.last_op.toISOString().slice(0, 10) : null
lastOp: r.last_op && String(r.last_op) !== '1970-01-01' ? (r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 10)) : null
}));
} finally {
conn.release();
}
}
// Search clients by name (server-side, max 15 results)
// Search clients by name (server-side, max 15 results) — includes merchants
async function fetchClientSearch(query) {
const conn = await pool.getConnection();
try {
@@ -1008,6 +1020,7 @@ async function fetchClientSearch(query) {
WHERE c.id_conta IN (
SELECT DISTINCT id_conta FROM br_transaction_to_usa
UNION SELECT DISTINCT id_conta FROM pagamento_br
UNION SELECT DISTINCT id_conta FROM br_cb_empresas WHERE active = 1
) AND c.nome LIKE CONCAT('%', ?, '%')
ORDER BY c.nome LIMIT 15
`, [query]);
@@ -1531,6 +1544,226 @@ async function fetchBIStrategic(dataInicio, dataFim) {
}
}
// Detect if client is a merchant (has active empresa in br_cb_empresas)
async function fetchMerchantProfile(clienteId) {
const conn = await pool.getConnection();
try {
// Check if this id_conta is a merchant
const [empresa] = await conn.execute(
'SELECT id, nome_empresa FROM br_cb_empresas WHERE id_conta = ? AND active = 1 LIMIT 1',
[clienteId]
);
if (!empresa.length) return { is_merchant: false };
const empresaId = empresa[0].id;
const nomeEmpresa = empresa[0].nome_empresa;
// Lifetime checkout stats via cobranca -> transactions
const [stats] = await conn.execute(`
SELECT
COUNT(*) as tx_count,
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
COUNT(DISTINCT t.id_conta) as unique_payers,
MIN(t.created_at) as first_op,
MAX(t.created_at) as last_op,
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) as months_active
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
WHERE cb.empresa_id = ?
`, [empresaId]);
// Revenue: same formula as fetchRevenueAnalytics for BR→US Checkout
const [rev] = await conn.execute(`
SELECT ROUND(COALESCE(SUM(
(
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
- COALESCE(t.pfee, 0)
) - (
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
)
), 0), 2) as revenue
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
WHERE cb.empresa_id = ?
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
AND t.ptax IS NOT NULL AND t.ptax > 0
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
`, [empresaId]);
const s = stats[0] || {};
return {
is_merchant: true,
empresa_id: empresaId,
nome_empresa: nomeEmpresa,
checkout: {
tx_count: Number(s.tx_count) || 0,
vol_usd: Number(s.vol_usd) || 0,
unique_payers: Number(s.unique_payers) || 0,
revenue: Number(rev[0]?.revenue) || 0,
first_op: s.first_op ? (s.first_op instanceof Date ? s.first_op.toISOString().slice(0, 10) : String(s.first_op).slice(0, 10)) : null,
last_op: s.last_op ? (s.last_op instanceof Date ? s.last_op.toISOString().slice(0, 10) : String(s.last_op).slice(0, 10)) : null,
months_active: Number(s.months_active) || 0
}
};
} finally {
conn.release();
}
}
// Merchant checkout data for a period
async function fetchMerchantData(empresaId, dataInicio, dataFim) {
const conn = await pool.getConnection();
try {
// Previous period for comparison
const start = new Date(dataInicio);
const end = new Date(dataFim);
const periodDays = Math.round((end - start) / 86400000) + 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);
// KPIs current period
const [kpi] = await conn.execute(`
SELECT
COUNT(*) as qtd,
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
COUNT(DISTINCT t.id_conta) as unique_payers,
ROUND(COALESCE(AVG((t.exchange_rate - t.ptax) / t.exchange_rate * 100), 0), 2) as avg_spread_pct
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
`, [empresaId, dataInicio, dataFim]);
// Revenue current period
const [rev] = await conn.execute(`
SELECT ROUND(COALESCE(SUM(
(
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
- COALESCE(t.pfee, 0)
) - (
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
)
), 0), 2) as revenue
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
AND t.ptax IS NOT NULL AND t.ptax > 0
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
`, [empresaId, dataInicio, dataFim]);
// KPIs previous period
const [prevKpi] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
`, [empresaId, prevStartStr, prevEndStr]);
const [prevRev] = await conn.execute(`
SELECT ROUND(COALESCE(SUM(
(
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
- COALESCE(t.pfee, 0)
) - (
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
)
), 0), 2) as revenue
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
AND t.ptax IS NOT NULL AND t.ptax > 0
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
`, [empresaId, prevStartStr, prevEndStr]);
// Monthly breakdown
const [monthly] = await conn.execute(`
SELECT DATE_FORMAT(t.created_at, '%Y-%m') as mes,
COUNT(*) as qtd,
ROUND(SUM(t.amount_usd), 2) as vol_usd,
COUNT(DISTINCT t.id_conta) as unique_payers
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY DATE_FORMAT(t.created_at, '%Y-%m') ORDER BY mes
`, [empresaId, dataInicio, dataFim]);
// Top 10 payers
const [topPayers] = await conn.execute(`
SELECT c.nome, t.id_conta, COUNT(*) as tx_count, ROUND(SUM(t.amount_usd), 2) as vol_usd
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
INNER JOIN conta c ON c.id_conta = t.id_conta
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY t.id_conta, c.nome
ORDER BY vol_usd DESC LIMIT 10
`, [empresaId, dataInicio, dataFim]);
// Transactions (latest 500)
const [txRows] = await conn.execute(`
SELECT t.created_at as date, t.amount_usd as usd, t.amount_brl as brl,
ROUND(t.exchange_rate, 4) as rate, ROUND(t.ptax, 4) as ptax,
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) as spread_pct,
t.iof, t.status, COALESCE(pm.provider, '') as provider,
c.nome as payer_name, t.id_conta as payer_id
FROM br_cb_cobranca cb
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
LEFT JOIN conta c ON c.id_conta = t.id_conta
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
ORDER BY t.created_at DESC LIMIT 500
`, [empresaId, dataInicio, dataFim]);
const fmtDT = (d) => { try { const dt = d instanceof Date ? d : new Date(d); return dt.toISOString().slice(0, 16).replace('T', ' '); } catch(e) { return String(d); } };
const k = kpi[0] || {};
const qtd = Number(k.qtd) || 0;
const volUsd = Number(k.vol_usd) || 0;
const revenue = Number(rev[0]?.revenue) || 0;
return {
kpis: {
qtd,
vol_usd: volUsd,
unique_payers: Number(k.unique_payers) || 0,
avg_spread_pct: Number(k.avg_spread_pct) || 0,
revenue,
ticket_medio: qtd > 0 ? Math.round(volUsd / qtd) : 0
},
comparison: {
prev_qtd: Number(prevKpi[0]?.qtd) || 0,
prev_vol_usd: Number(prevKpi[0]?.vol_usd) || 0,
prev_revenue: Number(prevRev[0]?.revenue) || 0
},
monthly: monthly.map(r => ({
mes: r.mes,
qtd: Number(r.qtd),
vol_usd: Number(r.vol_usd),
unique_payers: Number(r.unique_payers)
})),
topPayers: topPayers.map(r => ({
nome: r.nome,
id_conta: r.id_conta,
tx_count: Number(r.tx_count),
vol_usd: Number(r.vol_usd)
})),
transactions: txRows.map(r => ({
date: fmtDT(r.date), flow: 'Checkout', usd: Number(r.usd), brl: Number(r.brl),
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
iof: Number(r.iof), status: r.status, provider: r.provider,
payer_name: r.payer_name || '', payer_id: r.payer_id
}))
};
} finally {
conn.release();
}
}
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
@@ -1547,5 +1780,7 @@ module.exports = {
fetchTopClients,
fetchClientSearch,
fetchClientProfile,
fetchClientData
fetchClientData,
fetchMerchantProfile,
fetchMerchantData
};