// AGENTFORGE-IA — Billing: Planes (cliente) + Facturación (admin) function ScreenBilling({ initialTab = 'planes' }) { const normalizedInitialTab = initialTab === 'facturacion' ? 'facturacion' : 'planes'; const [activeTab, setActiveTab] = React.useState(normalizedInitialTab); const routeLockedTab = initialTab === 'planes' || initialTab === 'facturacion'; const [subData, setSubData] = React.useState(undefined); // undefined=loading, null=error const [isAdmin, setIsAdmin] = React.useState(false); const [adminData, setAdminData] = React.useState(null); // { stats, records } const [adminLoading, setAdminLoading] = React.useState(false); const [planUpdating, setPlanUpdating] = React.useState(''); // plan name being updated const [portalLoading, setPortalLoading] = React.useState(false); const [planError, setPlanError] = React.useState(''); const [planSuccess, setPlanSuccess] = React.useState(''); const [searchTerm, setSearchTerm] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState(''); const [planFilter, setPlanFilter] = React.useState(''); const [openMenu, setOpenMenu] = React.useState(null); // { customerId, top, left } const [patchLoading, setPatchLoading] = React.useState(''); // customer_id:action // ─── Helpers ──────────────────────────────────────────────────────────────── const getToken = () => sessionStorage.getItem('af_token') || ''; const authHeaders = () => ({ 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json', }); /** * Formats a date string to "DD MMM YYYY". */ const formatDate = (value) => { if (!value) return '—'; try { return new Date(value).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric', }); } catch (_) { return String(value); } }; /** * Formats a date to "DD/MM/YY" for compact table cells. */ const formatDateShort = (value) => { if (!value) return '—'; try { return new Date(value).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit', }); } catch (_) { return String(value); } }; /** * Returns badge metadata for a subscription status string. */ const getStatusMeta = (status) => { const map = { active: { label: 'Activa', color: 'var(--ok)', bg: 'rgba(52,211,153,0.12)' }, past_due: { label: 'Impago', color: 'var(--bad)', bg: 'rgba(248,113,113,0.12)' }, canceled: { label: 'Cancelada', color: 'var(--ink-3)', bg: 'var(--bg-3)' }, trialing: { label: 'Prueba', color: '#60a5fa', bg: 'rgba(96,165,250,0.12)' }, }; return map[status] || { label: status || '—', color: 'var(--ink-3)', bg: 'var(--bg-3)' }; }; /** * Returns progress bar color based on token usage percentage. */ const getBarColor = (pct) => { if (pct >= 100) return 'var(--bad)'; if (pct >= 80) return 'var(--warn)'; return 'var(--ok)'; }; // ─── Data loading ──────────────────────────────────────────────────────────── /** * Loads current user subscription and checks admin status. */ const loadAdminData = async () => { setAdminLoading(true); const params = new URLSearchParams(); if (statusFilter) params.set('status', statusFilter); if (planFilter) params.set('plan', planFilter); if (searchTerm) params.set('search', searchTerm); try { const res = await fetch('/api/v1/billing/admin/customers?' + params.toString(), { headers: { 'Authorization': 'Bearer ' + getToken() }, }); if (res.ok) { const rows = await res.json(); const records = Array.isArray(rows) ? rows : []; const totalActive = records.filter((r) => r.status === 'active').length; const totalPastDue = records.filter((r) => r.status === 'past_due').length; const totalPaused = records.filter((r) => !!r.is_paused).length; const planPriceMap = { starter: 25, professional: 69, enterprise: 179 }; const mrrEur = records.reduce((sum, r) => { if (r.status !== 'active') return sum; return sum + (planPriceMap[String(r.plan).toLowerCase()] || 0); }, 0); setAdminData({ stats: { mrr_eur: mrrEur, total_active: totalActive, total_past_due: totalPastDue, total_paused: totalPaused, total_records: records.length, }, records, }); } else { window.afNotify && window.afNotify('Error al cargar datos de facturación'); } } catch (_) { window.afNotify && window.afNotify('Error de red'); } finally { setAdminLoading(false); } }; // Mount: load subscription + check admin React.useEffect(() => { const token = sessionStorage.getItem('af_token') || ''; const headers = { 'Authorization': 'Bearer ' + token }; Promise.all([ fetch('/api/v1/billing/my-subscription', { headers }).then(r => r.json()), fetch('/api/v1/customers/me', { headers }).then(r => r.json()), ]).then(([sub, me]) => { setSubData(sub); setIsAdmin(!!me.is_admin); }).catch(() => setSubData(null)); }, []); // Admin tab: reload on tab change or filter change (debounced) React.useEffect(() => { if (activeTab !== 'facturacion' || !isAdmin) return; const timer = setTimeout(loadAdminData, 300); return () => clearTimeout(timer); }, [activeTab, isAdmin, searchTerm, statusFilter, planFilter]); React.useEffect(() => { setActiveTab(normalizedInitialTab); }, [normalizedInitialTab]); // Close dropdown on outside click React.useEffect(() => { if (!openMenu) return; const handler = () => setOpenMenu(null); const onResize = () => setOpenMenu(null); document.addEventListener('click', handler); window.addEventListener('resize', onResize); window.addEventListener('scroll', onResize, true); return () => { document.removeEventListener('click', handler); window.removeEventListener('resize', onResize); window.removeEventListener('scroll', onResize, true); }; }, [openMenu]); // ─── Actions ───────────────────────────────────────────────────────────────── const handleChangePlan = async (planName) => { setPlanUpdating(planName); setPlanError(''); setPlanSuccess(''); const prev = subData; // Optimistic update to make the UX deterministic even with API latency. setSubData((current) => current ? { ...current, plan: planName } : current); try { const res = await fetch('/api/v1/billing/plan', { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ plan: planName }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); const msg = err.detail || 'No se pudo actualizar el plan'; setPlanError(msg); setSubData(prev); window.afNotify && window.afNotify(msg); try { // Re-sync suave por si hay estado intermedio en backend const fresh = await fetch('/api/v1/billing/my-subscription?_ts=' + Date.now(), { headers: { 'Authorization': 'Bearer ' + getToken() }, cache: 'no-store', }); if (fresh.ok) setSubData(await fresh.json()); } catch (_) {} return; } const updated = await res.json(); setSubData(updated); setPlanSuccess('Plan actualizado a ' + planName); window.afNotify && window.afNotify('Plan actualizado a ' + planName); try { // Hard re-sync after success to avoid stale state from intermediaries/proxies. const fresh = await fetch('/api/v1/billing/my-subscription?_ts=' + Date.now(), { headers: { 'Authorization': 'Bearer ' + getToken() }, cache: 'no-store', }); if (fresh.ok) setSubData(await fresh.json()); } catch (_) {} } catch (_) { setSubData(prev); window.afNotify && window.afNotify('Error de red al actualizar plan'); } finally { setPlanUpdating(''); } }; const handlePortal = async () => { setPortalLoading(true); try { const res = await fetch('/api/v1/billing/portal', { headers: { 'Authorization': 'Bearer ' + getToken() }, }); if (!res.ok) { window.afNotify && window.afNotify('Sin cuenta de facturación activa'); return; } const payload = await res.json().catch(() => ({})); if (payload.portal_url) { window.open(payload.portal_url, '_blank'); } else { window.afNotify && window.afNotify('Portal no disponible'); } } catch (_) { window.afNotify && window.afNotify('Error de red al abrir portal'); } finally { setPortalLoading(false); } }; const handlePatch = async (customerId, action, successMsg) => { const key = customerId + ':' + action; setPatchLoading(key); try { const res = await fetch('/api/v1/billing/admin/customers/' + encodeURIComponent(customerId) + '/action', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ action }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); window.afNotify && window.afNotify(err.detail || 'Error al aplicar cambio'); return; } const updated = await res.json(); setAdminData(prev => { if (!prev) return prev; const nextRecords = prev.records.map((r) => r.customer_id === customerId ? { ...r, ...updated } : r ); const totalActive = nextRecords.filter((r) => r.status === 'active').length; const totalPastDue = nextRecords.filter((r) => r.status === 'past_due').length; const totalPaused = nextRecords.filter((r) => !!r.is_paused).length; const planPriceMap = { starter: 25, professional: 69, enterprise: 179 }; const mrrEur = nextRecords.reduce((sum, r) => { if (r.status !== 'active') return sum; return sum + (planPriceMap[String(r.plan).toLowerCase()] || 0); }, 0); return { ...prev, stats: { mrr_eur: mrrEur, total_active: totalActive, total_past_due: totalPastDue, total_paused: totalPaused, total_records: nextRecords.length, }, records: nextRecords, }; }); window.afNotify && window.afNotify(successMsg || 'Cambio aplicado'); } catch (_) { window.afNotify && window.afNotify('Error de red'); } finally { setPatchLoading(''); setOpenMenu(null); } }; const exportCSV = () => { if (!adminData || !adminData.records) return; const BOM = ''; const headers = [ 'ID Cliente', 'Nombre', 'Email', 'Plan', 'Estado', 'Tokens Permitidos', 'Tokens Usados', '% Consumo', 'Stripe Customer ID', 'Stripe Suscripcion ID', 'Fin Periodo', 'Pausada', 'Fecha Alta', ]; const rows = adminData.records.map(r => [ r.customer_id, r.customer_name, r.customer_email, r.plan, r.status, r.tokens_allowed, r.tokens_used_this_month, r.tokens_usage_pct + '%', r.stripe_customer_id, r.stripe_subscription_id, r.current_period_end ? new Date(r.current_period_end).toLocaleDateString('es-ES') : '', r.is_paused ? 'Si' : 'No', r.created_at ? new Date(r.created_at).toLocaleDateString('es-ES') : '', ]); const csv = BOM + [headers, ...rows] .map(r => r.map(v => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`).join(',')) .join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `facturacion_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); window.afNotify && window.afNotify('CSV exportado'); }; const getLastActionLabel = (record) => { if (!record) return '—'; const last = String(record.last_payment_status || '').toLowerCase(); if (last === 'gift') return 'Obsequio'; if (last === 'paid') return 'Cobro correcto'; if (last === 'failed') return 'Cobro fallido'; if (record.is_paused) return 'Pausada'; if (record.status === 'active') return 'Activada'; if (record.status === 'past_due') return 'Vencida'; return '—'; }; // ─── Derived values ─────────────────────────────────────────────────────────── const tokenAllowed = Number(subData?.tokens_allowed || 0); const tokenUsed = Number(subData?.tokens_used_this_month || 0); const tokenPct = tokenAllowed > 0 ? Math.min(100, Math.round((tokenUsed / tokenAllowed) * 100)) : 0; const currentPlan = String(subData?.plan || '').toLowerCase(); const PLANS = [ { id: 'starter', label: 'Starter', price: '€25/mes', tokens: '500k tokens/mes', features: [ '1 agente', 'Soporte estandar', 'Ejecuciones basicas', 'Sin acceso API', 'Analytics basico', ], }, { id: 'professional', label: 'Professional', price: '€69/mes', tokens: '1.5M tokens/mes', popular: true, features: [ 'Hasta 5 agentes', 'Soporte prioritario', 'Ejecuciones avanzadas', 'Acceso API', 'Analytics completo', ], }, { id: 'enterprise', label: 'Enterprise', price: '€179/mes', tokens: '5M tokens/mes', features: [ 'Agentes ilimitados', 'Soporte dedicado', 'Sin limite de ejecuciones', 'Acceso API', 'Analytics empresarial', ], }, ]; const PLAN_ORDER = { starter: 0, professional: 1, enterprise: 2 }; const getPlanCTA = (planId) => { if (planId === currentPlan) return { text: 'Plan actual', variant: 'disabled' }; const cur = PLAN_ORDER[currentPlan] ?? -1; const target = PLAN_ORDER[planId] ?? 0; if (target > cur) return { text: 'Mejorar a ' + PLANS.find(p => p.id === planId).label, variant: 'primary' }; return { text: 'Cambiar a ' + PLANS.find(p => p.id === planId).label, variant: 'secondary' }; }; // ─── Sub-components ─────────────────────────────────────────────────────────── const StatusBadge = ({ status, small }) => { const meta = getStatusMeta(status); return ( {meta.label} ); }; const PlanBadge = ({ plan }) => { const styles = { starter: { bg: 'var(--bg-3)', color: 'var(--ink-2)' }, professional: { bg: 'rgba(59,130,246,0.15)', color: '#3b82f6' }, enterprise: { bg: 'rgba(139,92,246,0.15)', color: '#8b5cf6' }, }; const s = styles[String(plan).toLowerCase()] || styles.starter; const labels = { starter: 'Starter', professional: 'Professional', enterprise: 'Enterprise' }; return ( {labels[String(plan).toLowerCase()] || plan || '—'} ); }; const MiniBar = ({ pct }) => (
{pct}%
); const SkeletonRow = () => ( {[...Array(8)].map((_, i) => (
))} ); // ─── Tab: Planes ────────────────────────────────────────────────────────────── const renderPlanes = () => { if (subData === undefined) { return (
Cargando suscripcion...
); } if (subData === null) { return (
No se pudo cargar la suscripcion. Recarga la pagina.
); } return (
{/* Subscription summary card */}
{subData.is_paused && (
Cuenta pausada por impago. Los agentes IA no pueden ejecutarse hasta que regularices el pago.
)}
{/* Plan actual */}
Plan actual
{subData.has_subscription ? (subData.plan || '—') : 'Sin plan'}
{/* Estado */}
Estado
{/* Renovacion */}
Renovacion
{formatDate(subData.current_period_end)}
{/* Tokens */}
Tokens este mes
{tokenUsed.toLocaleString('es-ES')} / {tokenAllowed.toLocaleString('es-ES')}
{tokenUsed.toLocaleString('es-ES')} de {tokenAllowed.toLocaleString('es-ES')} ({tokenPct}%)
{/* Plan cards */} {planError && (
{planError}
)} {planSuccess && (
{planSuccess}
)}
{PLANS.map(plan => { const isCurrent = plan.id === currentPlan; const cta = getPlanCTA(plan.id); const isUpdating = planUpdating === plan.id; return (
{/* Badges */}
{isCurrent && ( Plan actual )} {plan.popular && ( Mas popular )}
{/* Name + price */}
{plan.label}
{plan.price}
{plan.tokens}
{/* Features */}
    {plan.features.map((f, i) => (
  • {f}
  • ))}
{/* CTA */} {cta.variant === 'disabled' ? ( ) : cta.variant === 'primary' ? ( ) : ( )}
); })}
{/* Billing portal */}
Portal de facturacion
Gestiona metodos de pago, descarga facturas y actualiza datos de facturacion en el portal de Stripe.
); }; // ─── Tab: Facturacion (admin) ───────────────────────────────────────────────── const renderFacturacion = () => { const stats = adminData?.stats; const records = adminData?.records || []; return (
{/* KPI cards */}
MRR
€{stats ? Number(stats.mrr_eur || 0).toFixed(0) : '—'}
Ingresos Recurrentes Mensuales
Activas
{stats ? stats.total_active : '—'}
Suscripciones activas
En riesgo
{stats ? (Number(stats.total_past_due || 0) + Number(stats.total_paused || 0)) : '—'}
En riesgo o pausadas
Total
{stats ? stats.total_records : '—'}
Total registros
{/* Filters + export */}
setSearchTerm(e.target.value)} style={{ minWidth: 220, flex: 1 }} /> {adminData && ( {records.length} registros )}
{/* Table */}
{['Cliente', 'Email', 'Plan', 'Estado', 'Tokens', 'Renovacion', 'Pausada', 'Ultima accion', 'Acciones'].map(h => ( ))} {adminLoading && !adminData && ( [1, 2, 3, 4].map(i => ) )} {adminLoading && adminData && ( )} {!adminLoading && adminData && records.length === 0 && ( )} {records.map(r => { const usagePct = Number(r.tokens_usage_pct || 0); const isMenuOpen = openMenu && openMenu.customerId === r.customer_id; return ( e.currentTarget.style.background = 'var(--bg-3)'} onMouseLeave={e => e.currentTarget.style.background = ''} > {/* Cliente */} {/* Email */} {/* Plan */} {/* Estado */} {/* Tokens */} {/* Renovacion */} {/* Pausada */} {/* Ultima accion */} {/* Acciones */} ); })}
{h}
Actualizando...
No hay registros con los filtros aplicados.
{r.customer_name || '—'} {r.customer_email || '—'} {formatDateShort(r.current_period_end)} {r.is_paused ? ✓ Pausada : } {getLastActionLabel(r)} {isMenuOpen && (
e.stopPropagation()} > {/* Cambiar plan */}
Estado
)}
); }; // ─── Render ─────────────────────────────────────────────────────────────────── return (

{activeTab === 'facturacion' ? 'Facturación' : 'Planes'}

administracion {activeTab === 'facturacion' ? 'facturacion' : 'planes'}
{/* Tab bar */} {!routeLockedTab &&
{[ { id: 'planes', label: 'Planes' }, ...(isAdmin ? [{ id: 'facturacion', label: 'Facturacion' }] : []), ].map(tab => { const active = activeTab === tab.id; return ( ); })}
} {/* Tab content */} {activeTab === 'planes' && renderPlanes()} {activeTab === 'facturacion' && isAdmin && renderFacturacion()}
); } window.ScreenBilling = ScreenBilling;