Opsellio — Template Funil Cascata Máxima (3U + 3D)
📐 Template completo de código

Funil Cascata Máxima
3 Upsells + 3 Downsells

Código completo de implementação do template mais avançado da Opsellio — schema de dados, motor de routing, API endpoints, frontend de cada step, e lógica de pagamento M-Pesa/e-Mola.

3 Upsells 3 Downsells Node.js React PostgreSQL Redis M-Pesa e-Mola
Mapa visual do funil cascata máxima
ℹ️
Legenda: Verde = upsell, Âmbar = downsell. Cada recusa de upsell activa o downsell correspondente. Cada aceitação sobe para o próximo upsell. O funil só termina na página de obrigado — independentemente do caminho.
Tabela de routing completa — todos os caminhos possíveis
Step Tipo Se aceita → Se recusa →
checkout Checkout upsell_1 thank_you (saiu sem comprar)
upsell_1 Upsell upsell_2 downsell_1
downsell_1 Downsell upsell_2 downsell_2
upsell_2 Upsell upsell_3 downsell_2
downsell_2 Downsell upsell_3 downsell_3
upsell_3 Upsell thank_you downsell_3
downsell_3 Downsell thank_you thank_you
thank_you Fim — fim do funil —
Steps no funil
8
1 checkout + 3U + 3D + thank you
Caminhos possíveis
16
Do checkout ao thank you
Receita máxima por cliente
4 produtos
Principal + 3 upsells aceites
01Schema de dados

Migração SQL — PostgreSQL

Schema completo para suportar funis com steps, sessões, eventos e configuração de routing. Incluí constraints para garantir os limites (máx 3 upsells, 3 downsells).

migration_001_funnels.sql
-- Tabela principal de funis CREATE TABLE funnels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_by UUID NOT NULL REFERENCES users(id), name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'archived')), slug VARCHAR(100) UNIQUE NOT NULL, -- URL do funil settings JSONB NOT NULL DEFAULT '{}', -- pixel_meta, pixel_google, domínio version INTEGER NOT NULL DEFAULT 1, -- para snapshot em sessões deleted_at TIMESTAMPTZ, -- soft delete created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Steps do funil CREATE TABLE funnel_steps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), funnel_id UUID NOT NULL REFERENCES funnels(id) ON DELETE CASCADE, position INTEGER NOT NULL, step_key VARCHAR(50) NOT NULL, -- 'checkout','upsell_1','downsell_1', etc. type VARCHAR(20) NOT NULL CHECK (type IN ('checkout','upsell','downsell','thank_you')), product_id UUID REFERENCES products(id), page_config JSONB NOT NULL DEFAULT '{}', -- headline, cta, timer, vídeo, etc. price_config JSONB NOT NULL DEFAULT '{}', -- amount, original, currency, methods routing JSONB NOT NULL DEFAULT '{}', -- { on_accept, on_decline, on_timeout } created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(funnel_id, step_key), UNIQUE(funnel_id, position) ); -- Constraint: máx 3 upsells e 3 downsells por funil CREATE OR REPLACE FUNCTION check_step_limits() RETURNS TRIGGER AS $$ DECLARE upsell_count INTEGER; downsell_count INTEGER; BEGIN SELECT COUNT(*) INTO upsell_count FROM funnel_steps WHERE funnel_id = NEW.funnel_id AND type = 'upsell'; SELECT COUNT(*) INTO downsell_count FROM funnel_steps WHERE funnel_id = NEW.funnel_id AND type = 'downsell'; IF NEW.type = 'upsell' AND upsell_count >= 3 THEN RAISE EXCEPTION 'Máximo de 3 upsells por funil atingido'; END IF; IF NEW.type = 'downsell' AND downsell_count >= 3 THEN RAISE EXCEPTION 'Máximo de 3 downsells por funil atingido'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER enforce_step_limits BEFORE INSERT ON funnel_steps FOR EACH ROW EXECUTE FUNCTION check_step_limits(); -- Sessões de comprador (uma por visitante por funil) CREATE TABLE funnel_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), funnel_id UUID NOT NULL REFERENCES funnels(id), funnel_version INTEGER NOT NULL, -- snapshot da versão no início visitor_id VARCHAR(100) NOT NULL, -- fingerprint anónimo email VARCHAR(320), -- capturado antes do pagamento phone VARCHAR(20), -- normalizado (+258...) payment_method VARCHAR(20), -- método usado no checkout current_step VARCHAR(50) NOT NULL DEFAULT 'checkout', status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','completed','abandoned')), steps_accepted TEXT[] NOT NULL DEFAULT '{}', steps_declined TEXT[] NOT NULL DEFAULT '{}', total_paid INTEGER NOT NULL DEFAULT 0, -- em centavos MZN utm_data JSONB NOT NULL DEFAULT '{}', routing_snapshot JSONB NOT NULL DEFAULT '{}', -- cópia do routing no início started_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); -- Eventos por step (audit trail completo) CREATE TABLE step_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES funnel_sessions(id), step_key VARCHAR(50) NOT NULL, event_type VARCHAR(20) NOT NULL CHECK (event_type IN ('view','accept','decline','timeout','payment_initiated','payment_confirmed')), payment_id UUID, -- se event_type inclui pagamento amount INTEGER, -- em centavos MZN metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Índices de performance CREATE INDEX idx_sessions_funnel ON funnel_sessions(funnel_id); CREATE INDEX idx_sessions_current ON funnel_sessions(current_step); CREATE INDEX idx_events_session ON step_events(session_id); CREATE INDEX idx_steps_funnel_pos ON funnel_steps(funnel_id, position);
02Motor de Routing

funnelEngine.js — o coração do funil

Toda a lógica de decisão vive aqui. O frontend nunca decide o próximo step — apenas envia a acção. O engine valida, regista, e retorna o próximo step.

// services/funnelEngine.js const { db } = require('../db'); const { redis } = require('../redis'); const analytics = require('./analytics'); /** * Configuração do template de funil cascata máxima. * Este objecto define o routing para todos os 8 steps. * Seed este objecto na base de dados ao criar o template. */ const CASCADE_MAX_TEMPLATE = { name: 'Cascata Máxima — 3 Upsells + 3 Downsells', steps: [ { step_key: 'checkout', type: 'checkout', position: 1, routing: { on_accept: 'upsell_1', on_decline: 'thank_you', on_timeout: 'thank_you' }, page_config: { headline: '[Substitui pelo título do teu produto]', subheadline: '[Proposta de valor em 1 linha]', cta_text: 'Quero acesso agora', guarantee_text: '7 dias de garantia ou devolução total', timer_mins: 0, }, }, { step_key: 'upsell_1', type: 'upsell', position: 2, routing: { on_accept: 'upsell_2', on_decline: 'downsell_1', on_timeout: 'downsell_1' }, page_config: { headline: 'Espera! Adiciona este bónus exclusivo ao teu pedido', subheadline: '[Descreve o produto de upsell em 1 linha de valor]', cta_accept: 'Sim! Adiciona ao meu pedido', cta_decline: 'Não, obrigado — continuar sem o bónus', timer_mins: 10, media_type: 'video', // 'image' | 'video' | 'none' }, }, { step_key: 'downsell_1', type: 'downsell', position: 3, routing: { on_accept: 'upsell_2', on_decline: 'downsell_2', on_timeout: 'downsell_2' }, page_config: { headline: 'Última oportunidade — versão essencial com 60% de desconto', subheadline: '[Versão simplificada do upsell_1 — menor escopo, menor preço]', cta_accept: 'Quero a versão essencial', cta_decline: 'Não, dispenso completamente', timer_mins: 5, }, }, { step_key: 'upsell_2', type: 'upsell', position: 4, routing: { on_accept: 'upsell_3', on_decline: 'downsell_2', on_timeout: 'downsell_2' }, page_config: { headline: 'Aproveita — tens mais um bónus disponível', cta_accept: 'Sim, quero este também!', cta_decline: 'Não, obrigado', timer_mins: 8, }, }, { step_key: 'downsell_2', type: 'downsell', position: 5, routing: { on_accept: 'upsell_3', on_decline: 'downsell_3', on_timeout: 'downsell_3' }, page_config: { headline: 'Espera — tens 50% de desconto neste recurso', cta_accept: 'Quero com o desconto', cta_decline: 'Não, obrigado', timer_mins: 5, }, }, { step_key: 'upsell_3', type: 'upsell', position: 6, routing: { on_accept: 'thank_you', on_decline: 'downsell_3', on_timeout: 'downsell_3' }, page_config: { headline: 'Último bónus — acesso vitalício ao grupo VIP', cta_accept: 'Quero acesso VIP', cta_decline: 'Não, obrigado — continuar para a minha área', timer_mins: 7, }, }, { step_key: 'downsell_3', type: 'downsell', position: 7, routing: { on_accept: 'thank_you', on_decline: 'thank_you', on_timeout: 'thank_you' }, page_config: { headline: 'Última oportunidade — acesso mensal por apenas [preço]', cta_accept: 'Quero o acesso mensal', cta_decline: 'Não, prefiro avançar sem o bónus', timer_mins: 5, }, }, { step_key: 'thank_you', type: 'thank_you', position: 8, routing: {}, page_config: { headline: 'Bem-vindo/a! O teu acesso está pronto', subheadline: 'Verifica o teu email para aceder ao curso', show_purchases: true, // lista o que comprou nesta sessão whatsapp_group: 'https://wa.me/...', }, }, ], }; module.exports.CASCADE_MAX_TEMPLATE = CASCADE_MAX_TEMPLATE; /** * Inicializa uma nova sessão de funil para um visitante. * Guarda snapshot do routing para proteger sessões em curso * se o criador editar o funil. */ async function createSession(funnelId, visitorData) { const funnel = await db.funnels.findByIdWithSteps(funnelId); if (!funnel || funnel.status !== 'active') { throw new Error('Funil não encontrado ou inactivo'); } // Construir snapshot do routing para esta sessão const routingSnapshot = {}; funnel.steps.forEach(s => { routingSnapshot[s.step_key] = s.routing; }); return db.funnel_sessions.create({ funnel_id: funnelId, funnel_version: funnel.version, visitor_id: visitorData.visitor_id, utm_data: visitorData.utm || {}, routing_snapshot: routingSnapshot, current_step: 'checkout', }); } /** * Motor principal de routing. * Recebe: session, action ('accept'|'decline'|'timeout') * Retorna: { next_step, session } actualizado */ async function processAction(sessionId, action, paymentId = null) { // 1. Carregar sessão e validar const session = await db.funnel_sessions.findById(sessionId); if (!session || session.status !== 'active') { throw new Error('Sessão inválida ou já terminada'); } const currentStepKey = session.current_step; const routing = session.routing_snapshot[currentStepKey]; if (!routing) throw new Error(`Routing não encontrado para step: ${currentStepKey}`); // 2. Registar evento await db.step_events.create({ session_id: sessionId, step_key: currentStepKey, event_type: action, payment_id: paymentId, amount: paymentId ? await getPaymentAmount(paymentId) : null, }); // 3. Actualizar listas accepted/declined const updates = {}; if (action === 'accept') { updates.steps_accepted = [...session.steps_accepted, currentStepKey]; if (paymentId) { const amount = await getPaymentAmount(paymentId); updates.total_paid = session.total_paid + amount; } } else { updates.steps_declined = [...session.steps_declined, currentStepKey]; } // 4. Calcular próximo step via routing snapshot let nextStepKey; if (action === 'accept') nextStepKey = routing.on_accept; else if (action === 'decline') nextStepKey = routing.on_decline; else nextStepKey = routing.on_timeout; // 5. Verificar se chegou ao fim if (!nextStepKey || nextStepKey === 'thank_you') { updates.current_step = 'thank_you'; updates.status = 'completed'; updates.completed_at = new Date(); await db.funnel_sessions.update(sessionId, updates); await analytics.trackFunnelComplete(session); return { next_step: 'thank_you', session: { ...session, ...updates } }; } // 6. Avançar para o próximo step updates.current_step = nextStepKey; const updatedSession = await db.funnel_sessions.update(sessionId, updates); // 7. Carregar config do próximo step const nextStep = await db.funnel_steps.findByKey(session.funnel_id, nextStepKey); return { next_step: nextStepKey, step_config: nextStep, session: updatedSession }; } module.exports = { createSession, processAction, CASCADE_MAX_TEMPLATE };
03API Endpoints

routes/funnel.js — Express router completo

// routes/funnel.js const express = require('express'); const router = express.Router(); const engine = require('../services/funnelEngine'); const payments = require('../services/payments'); const { redis } = require('../redis'); const rateLimit = require('express-rate-limit'); // Rate limit: máx 20 actions por minuto por IP const actionLimiter = rateLimit({ windowMs: 60_000, max: 20, message: { error: 'Demasiados pedidos. Aguarda um momento.' }, }); /** * POST /api/funnel/:slug/session * Inicializa sessão — chamado quando o visitante chega ao checkout */ router.post('/:slug/session', async (req, res) => { try { const funnel = await db.funnels.findBySlug(req.params.slug); const session = await engine.createSession(funnel.id, { visitor_id: req.body.visitor_id || generateVisitorId(), utm: req.body.utm || extractUTM(req.query), }); res.json({ session_id: session.id, current_step: 'checkout' }); } catch (err) { res.status(400).json({ error: err.message }); } }); /** * POST /api/funnel/session/:sessionId/email * Captura email ANTES do pagamento — essencial para recuperação */ router.post('/session/:sessionId/email', async (req, res) => { const { email, phone } = req.body; await db.funnel_sessions.update(req.params.sessionId, { email: email?.toLowerCase().trim(), phone: phone ? normalizePhone(phone) : null, }); res.json({ ok: true }); }); /** * POST /api/funnel/session/:sessionId/pay * Inicia pagamento para o step actual (checkout ou upsell/downsell) * Retorna: { transaction_id, status: 'awaiting_ussd' | 'processing' } */ router.post('/session/:sessionId/pay', actionLimiter, async (req, res) => { const { sessionId } = req.params; const { payment_method } = req.body; // Lock Redis — prevenir double-pay const lockKey = `pay_lock:${sessionId}`; const locked = await redis.set(lockKey, '1', 'NX', 'EX', 30); if (!locked) { return res.status(409).json({ error: 'Pagamento já em processamento' }); } try { const session = await db.funnel_sessions.findById(sessionId); const stepCfg = await db.funnel_steps.findByKey(session.funnel_id, session.current_step); const amount = stepCfg.price_config.amount; // Para upsells/downsells de M-Pesa: usar número guardado na sessão const phone = session.payment_method === payment_method ? session.phone // reusar número da compra principal : req.body.phone; // ou pedir número novo (se trocou de método) const tx = await payments.initiatePayment({ method: payment_method, phone: normalizePhone(phone), amount, reference: `${sessionId}:${session.current_step}`, idempotency: `${sessionId}:${session.current_step}:v1`, }); // Guardar método de pagamento na sessão (1ª vez) if (!session.payment_method) { await db.funnel_sessions.update(sessionId, { payment_method }); } res.json({ transaction_id: tx.id, status: tx.status }); } finally { await redis.del(lockKey); } }); /** * GET /api/funnel/session/:sessionId/status/:txId * Polling de status do pagamento (M-Pesa/e-Mola são assíncronos) */ router.get('/session/:sessionId/status/:txId', async (req, res) => { const tx = await db.payments.findById(req.params.txId); if (tx.status === 'paid') { // Pagamento confirmado — processar accept automaticamente const result = await engine.processAction(req.params.sessionId, 'accept', tx.id); return res.json({ payment_status: 'paid', ...result }); } res.json({ payment_status: tx.status }); // 'pending' | 'failed' | 'expired' }); /** * POST /api/funnel/session/:sessionId/decline * Recusa de upsell/downsell (sem pagamento envolvido) */ router.post('/session/:sessionId/decline', actionLimiter, async (req, res) => { try { const result = await engine.processAction(req.params.sessionId, 'decline'); res.json(result); } catch (err) { res.status(400).json({ error: err.message }); } }); /** * POST /api/funnel/session/:sessionId/timeout * Chamado pelo frontend quando o timer de urgência expira */ router.post('/session/:sessionId/timeout', async (req, res) => { const result = await engine.processAction(req.params.sessionId, 'timeout'); res.json(result); }); /** * POST /api/funnel/webhook/mpesa * Webhook de confirmação do gateway M-Pesa * O gateway chama isto quando o utilizador confirma o USSD */ router.post('/webhook/mpesa', express.raw({type: '*/*'}), async (req, res) => { // 1. Verificar assinatura HMAC if (!verifyMpesaSignature(req.body, req.headers)) { return res.status(401).send('Assinatura inválida'); } // 2. Responder 200 imediatamente — processar em background res.status(200).send('OK'); // 3. Processar assincronamente const event = JSON.parse(req.body); const payment = await db.payments.findByReference(event.reference); if (!payment) return; // Idempotência — não processar o mesmo evento duas vezes const processed = await redis.set( `webhook:${event.id}`, '1', 'NX', 'EX', 86400 * 7 ); if (!processed) return; // Actualizar pagamento e avançar sessão await db.payments.update(payment.id, { status: event.result_code === 'SUCCESS' ? 'paid' : 'failed' }); if (event.result_code === 'SUCCESS') { await engine.processAction(payment.session_id, 'accept', payment.id); } }); module.exports = router;
04Frontend React

Componentes de step — React

Cada tipo de step tem o seu próprio componente. O componente pai FunnelPlayer gere a sessão e renderiza o componente correcto consoante o step actual.

FunnelPlayer.jsx — componente pai
// components/FunnelPlayer.jsx import { useState, useEffect, useCallback } from 'react'; import CheckoutStep from './steps/CheckoutStep'; import UpsellStep from './steps/UpsellStep'; import DownsellStep from './steps/DownsellStep'; import ThankYouStep from './steps/ThankYouStep'; import { useFunnelSession } from '../hooks/useFunnelSession'; const STEP_COMPONENTS = { checkout: CheckoutStep, upsell: UpsellStep, downsell: DownsellStep, thank_you: ThankYouStep, }; export default function FunnelPlayer({ slug }) { const { session, stepConfig, loading, advance } = useFunnelSession(slug); if (loading) return <FunnelSkeleton />; if (!stepConfig) return <ErrorState />; const StepComponent = STEP_COMPONENTS[stepConfig.type]; return ( <div className="funnel-player"> <StepComponent config={stepConfig} session={session} onAccept={() => advance('accept')} onDecline={() => advance('decline')} onTimeout={() => advance('timeout')} /> </div> ); }
useFunnelSession.js — hook de sessão
// hooks/useFunnelSession.js import { useState, useEffect, useRef } from 'react'; import api from '../api'; export function useFunnelSession(slug) { const [session, setSession] = useState(null); const [stepConfig, setStepConfig] = useState(null); const [loading, setLoading] = useState(true); const advancingRef = useRef(false); // prevenir double-advance useEffect(() => { const visitorId = localStorage.getItem('opsellio_visitor') || generateAndSaveVisitorId(); api.post(`/funnel/${slug}/session`, { visitor_id: visitorId, utm: Object.fromEntries(new URLSearchParams(location.search)), }).then(({ data }) => { setSession(data); return api.get(`/funnel/session/${data.session_id}/step`); }).then(({ data }) => { setStepConfig(data); setLoading(false); }); }, [slug]); const advance = useCallback(async (action) => { // Prevenir chamadas duplas (ex: clique duplo no botão de recusa) if (advancingRef.current) return; advancingRef.current = true; try { const endpoint = action === 'accept' ? `/funnel/session/${session.session_id}/pay` // accept = pagar : `/funnel/session/${session.session_id}/${action}`; // decline | timeout const { data } = await api.post(endpoint); setSession(prev => ({ ...prev, ...data.session })); setStepConfig(data.step_config || { type: 'thank_you' }); } finally { advancingRef.current = false; } }, [session]); return { session, stepConfig, loading, advance }; }
UpsellStep.jsx — componente de upsell com timer e confirmação
// components/steps/UpsellStep.jsx import { useState, useEffect, useCallback } from 'react'; import UrgencyTimer from '../UrgencyTimer'; import DeclineModal from '../DeclineModal'; import PaymentPoller from '../PaymentPoller'; import { formatPrice } from '../../utils/price'; export default function UpsellStep({ config, session, onAccept, onDecline, onTimeout }) { const [showDeclineModal, setShowDeclineModal] = useState(false); const [payState, setPayState] = useState('idle'); // idle|initiating|awaiting_ussd|confirmed|failed const [txId, setTxId] = useState(null); const handleAccept = useCallback(async () => { if (payState !== 'idle') return; setPayState('initiating'); try { const res = await api.post(`/funnel/session/${session.session_id}/pay`, { payment_method: session.payment_method, // reusar método da compra principal }); setTxId(res.data.transaction_id); setPayState(res.data.status === 'awaiting_ussd' ? 'awaiting_ussd' : 'processing'); } catch { setPayState('failed'); } }, [payState, session]); // Polling de status quando aguarda USSD if (payState === 'awaiting_ussd' || payState === 'processing') { return ( <PaymentPoller sessionId={session.session_id} txId={txId} onConfirmed={onAccept} onFailed={() => setPayState('failed')} onExpired={() => setPayState('idle')} paymentMethod={session.payment_method} /> ); } return ( <div className="step-page upsell-page"> {config.page_config.timer_mins > 0 && ( <UrgencyTimer minutes={config.page_config.timer_mins} onExpire={onTimeout} sessionId={session.session_id} {/* timer sincronizado com servidor */} /> )} {config.page_config.media_type === 'video' && ( <div className="video-wrapper"> <iframe src={config.page_config.video_url} allowFullScreen /> </div> )} <h1>{config.page_config.headline}</h1> <p>{config.page_config.subheadline}</p> {config.price_config.original && ( <div className="price-block"> <span className="original-price">{formatPrice(config.price_config.original)}</span> <span className="sale-price">{formatPrice(config.price_config.amount)}</span> </div> )} {/* Mostrar número mascarado para M-Pesa/e-Mola */} {['mpesa','emola'].includes(session.payment_method) && ( <div className="payment-method-info"> Será cobrado em <strong>{maskPhone(session.phone)}</strong> via {session.payment_method.toUpperCase()} </div> )} <button className={`btn-accept ${payState === 'initiating' ? 'loading' : ''}`} onClick={handleAccept} disabled={payState !== 'idle'} > {payState === 'initiating' ? 'A processar...' : config.page_config.cta_accept} </button> {payState === 'failed' && ( <div className="error-msg"> Algo correu mal. Tenta novamente ou recusa para continuar. <button onClick={() => setPayState('idle')}>Tentar novamente</button> </div> )} <button className="btn-decline" onClick={() => setShowDeclineModal(true)} > {config.page_config.cta_decline} </button> {showDeclineModal && ( <DeclineModal onConfirm={onDecline} onCancel={() => setShowDeclineModal(false)} message="Tens a certeza? Perdes este preço especial para sempre." /> )} </div> ); } // Mascarar número — mostrar só os últimos 4 dígitos function maskPhone(phone) { if (!phone) return ''; return '●●●● ' + phone.slice(-4); }
PaymentPoller.jsx — polling assíncrono para M-Pesa/e-Mola
// components/PaymentPoller.jsx import { useEffect, useRef } from 'react'; export default function PaymentPoller({ sessionId, txId, onConfirmed, onFailed, onExpired, paymentMethod }) { const attemptsRef = useRef(0); const MAX_ATTEMPTS = 24; // 24 × 5s = 120s máximo useEffect(() => { const poll = setInterval(async () => { attemptsRef.current++; if (attemptsRef.current > MAX_ATTEMPTS) { clearInterval(poll); return onExpired(); } const { data } = await api.get(`/funnel/session/${sessionId}/status/${txId}`); if (data.payment_status === 'paid') { clearInterval(poll); onConfirmed(data); } else if (data.payment_status === 'failed') { clearInterval(poll); onFailed(); } // se 'pending' — continua o polling }, 5000); // a cada 5 segundos return () => clearInterval(poll); }, [txId]); const isLocal = ['mpesa', 'emola'].includes(paymentMethod); return ( <div className="awaiting-payment"> <div className="spinner" /> <h2>{isLocal ? 'Confirma no teu telemóvel' : 'A processar pagamento...'}</h2> {isLocal && ( <p>Vai receber uma mensagem {paymentMethod.toUpperCase()} agora. Confirma com o teu PIN para activar o bónus.</p> )} <small>Não feches esta página</small> </div> ); }
05Pagamentos

services/payments.js — M-Pesa, e-Mola e Cartão

// services/payments.js const mpesaSDK = require('./gateways/mpesa'); const emolaSDK = require('./gateways/emola'); const stripeSDK = require('stripe')(process.env.STRIPE_SECRET); /** * Normalizar número moçambicano para qualquer formato de entrada. * Retorna sempre: '258XXXXXXXXX' */ function normalizePhone(raw) { let digits = raw.replace(/\D/g, ''); if (digits.startsWith('258258')) digits = digits.slice(3); if (digits.startsWith('258')) digits = digits.slice(3); if (digits.startsWith('00258')) digits = digits.slice(5); if (digits.length !== 9) throw new Error('Número inválido'); return '258' + digits; } /** * Detetar operador pelo prefixo do número */ function detectOperator(normalizedPhone) { const prefix = normalizedPhone.slice(3, 5); // dígitos 4 e 5 if (['84','85'].includes(prefix)) return 'mpesa'; if (['86','87'].includes(prefix)) return 'emola'; return null; } /** * Mapa de erros do gateway para mensagens em português */ const ERROR_MAP = { 'INVALID_MSISDN': 'Número de telefone inválido', 'SUBSCRIBER_NOT_FOUND': 'Número não encontrado — é número M-Pesa ou e-Mola?', 'INSUFFICIENT_FUNDS': 'Saldo insuficiente', 'SYSTEM_ERROR': 'Problema técnico — tenta novamente em 2 min', 'SERVICE_UNAVAILABLE': 'Serviço em manutenção — tenta outro método', 'TRANSACTION_LIMIT': 'Limite de transação atingido para hoje', 'TIMEOUT': 'Sem resposta — não foste cobrado', }; /** * Initiates a payment for a funnel step. * Para upsells de M-Pesa/e-Mola, o número já vem da sessão. */ async function initiatePayment({ method, phone, amount, reference, idempotency }) { const normalized = normalizePhone(phone); // Auto-detetar operador se método for 'auto' const resolvedMethod = method === 'auto' ? detectOperator(normalized) : method; switch (resolvedMethod) { case 'mpesa': { const tx = await mpesaSDK.c2b.initiate({ msisdn: normalized, amount: Math.round(amount / 100), // centavos → MZN inteiro reference, description: 'Opsellio — acesso ao produto', }); return { id: tx.conversation_id, status: 'awaiting_ussd' }; } case 'emola': { const tx = await emolaSDK.paymentRequest({ phone: normalized, amount: amount / 100, externalId: idempotency, }); return { id: tx.transactionId, status: 'awaiting_ussd' }; } case 'card': { // Para cartão em upsell: usar token guardado da compra principal const session = await db.funnel_sessions.findByReference(reference); const pi = await stripeSDK.paymentIntents.create({ amount, currency: 'mzn', customer: session.stripe_customer_id, payment_method: session.stripe_payment_method_id, confirm: true, // one-click real para cartão! idempotency_key: idempotency, }); return { id: pi.id, status: pi.status === 'succeeded' ? 'paid' : 'processing' }; } default: throw new Error(`Método de pagamento desconhecido: ${resolvedMethod}`); } } module.exports = { initiatePayment, normalizePhone, detectOperator, ERROR_MAP };
06Segurança

Idempotência, lock Redis e validações críticas

// middleware/funnelSecurity.js /** * Middleware de validação de sessão. * Garante que o step actual é válido e a sessão está activa. */ async function validateSession(req, res, next) { const session = await db.funnel_sessions.findById(req.params.sessionId); if (!session) { return res.status(404).json({ error: 'Sessão não encontrada' }); } if (session.status !== 'active') { return res.status(409).json({ error: 'Sessão já terminada', redirect: '/thank-you' }); } // Verificar que não está a tentar re-aceitar um step já recusado if (req.body.action === 'accept' && session.steps_declined?.includes(session.current_step)) { return res.status(400).json({ error: 'Step já recusado nesta sessão' }); } req.session = session; next(); } /** * Lock de pagamento por step — prevenir double-pay. * Usa Redis SETNX com TTL de 30s. */ async function acquirePayLock(sessionId, stepKey) { const key = `pay_lock:${sessionId}:${stepKey}`; const locked = await redis.set(key, '1', 'NX', 'EX', 30); return { locked: !!locked, release: () => redis.del(key) }; } /** * Idempotência de webhook — garantir processamento único por evento. * Guarda o event ID por 7 dias com Redis. */ async function isEventAlreadyProcessed(eventId) { const key = `webhook:processed:${eventId}`; const set = await redis.set(key, '1', 'NX', 'EX', 86400 * 7); return !set; // se set é null, já existia → já processado } /** * Verificar assinatura HMAC do webhook M-Pesa. * Usar o raw body — nunca o body já parseado. */ function verifyMpesaSignature(rawBody, headers) { const signature = headers['x-mpesa-signature']; const expected = crypto .createHmac('sha256', process.env.MPESA_WEBHOOK_SECRET) .update(rawBody) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } module.exports = { validateSession, acquirePayLock, isEventAlreadyProcessed, verifyMpesaSignature };
07Analytics

services/analytics.js — eventos e pixels

// services/analytics.js /** * Disparar evento de conversão Meta Pixel por step. * Cada aceitação de step é um evento Purchase separado. */ async function trackStepAccept(session, step, amount) { const funnel = await db.funnels.findById(session.funnel_id); if (!funnel.settings.pixel_meta) return; // Meta Conversions API (server-side — mais fiável que pixel no browser) await fetch('https://graph.facebook.com/v19.0/{pixel_id}/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: [{ event_name: 'Purchase', event_time: Math.floor(Date.now() / 1000), action_source: 'website', user_data: { em: hashSHA256(session.email), // email em hash SHA256 ph: hashSHA256(session.phone), // telefone em hash SHA256 }, custom_data: { value: amount / 100, // em MZN currency: 'MZN', content_ids: [step.product_id], content_name: `Funil: ${step.step_key}`, }, }], access_token: process.env.META_ACCESS_TOKEN, }), }); } /** * Calcular estatísticas de conversão por step. * Usado no dashboard do criador. */ async function getFunnelStats(funnelId, dateRange) { const steps = ['checkout','upsell_1','downsell_1','upsell_2','downsell_2','upsell_3','downsell_3']; const stats = await Promise.all(steps.map(async stepKey => { const [views, accepts, declines] = await Promise.all([ db.step_events.count({ funnel_id: funnelId, step_key: stepKey, event_type: 'view', ...dateRange }), db.step_events.count({ funnel_id: funnelId, step_key: stepKey, event_type: 'accept', ...dateRange }), db.step_events.count({ funnel_id: funnelId, step_key: stepKey, event_type: 'decline', ...dateRange }), ]); const revenue = await db.step_events.sumAmount({ funnel_id: funnelId, step_key: stepKey, event_type: 'accept', ...dateRange }); return { step_key: stepKey, views, accepts, declines, conversion_rate: views ? Math.round(accepts / views * 10000) / 100 : 0, revenue_mzn: revenue / 100, }; })); return stats; } module.exports = { trackStepAccept, getFunnelStats };
08Seed do template

seeds/cascadeMaxTemplate.js — criar o funil na base de dados

Como usar: Corre este seed para criar o template na base de dados. O criador do produto depois preenche os produtos, preços e textos no dashboard — a estrutura de routing está pré-definida.
// seeds/cascadeMaxTemplate.js const { CASCADE_MAX_TEMPLATE } = require('../services/funnelEngine'); const { db } = require('../db'); async function seedCascadeMaxTemplate(creatorUserId) { // 1. Criar o funil const funnel = await db.funnels.create({ created_by: creatorUserId, name: CASCADE_MAX_TEMPLATE.name, slug: `funil-${Date.now()}`, // o criador pode mudar depois status: 'draft', // começa em draft — publicar depois settings: {}, }); // 2. Criar todos os steps com o routing pré-definido for (const step of CASCADE_MAX_TEMPLATE.steps) { await db.funnel_steps.create({ funnel_id: funnel.id, step_key: step.step_key, type: step.type, position: step.position, routing: step.routing, page_config: step.page_config, price_config: { amount: 0, // o criador preenche no dashboard currency: 'MZN', payment_methods: ['mpesa', 'emola', 'card'], }, }); } console.log(`Template criado: funnel_id=${funnel.id}`); console.log('Steps criados:', CASCADE_MAX_TEMPLATE.steps.map(s => s.step_key)); return funnel; } // Correr directamente: node seeds/cascadeMaxTemplate.js USER_ID if (require.main === module) { const userId = process.argv[2]; seedCascadeMaxTemplate(userId).then(() => process.exit(0)); } module.exports = { seedCascadeMaxTemplate };
Estrutura de ficheiros do projecto
opsellio/ ├── services/ │ ├── funnelEngine.js ← Motor de routing (Secção 02) │ ├── payments.js ← M-Pesa, e-Mola, Cartão (Secção 05) │ └── analytics.js ← Eventos e pixels (Secção 07) ├── routes/ │ └── funnel.js ← API endpoints (Secção 03) ├── middleware/ │ └── funnelSecurity.js ← Segurança e idempotência (Secção 06) ├── migrations/ │ └── 001_funnels.sql ← Schema PostgreSQL (Secção 01) ├── seeds/ │ └── cascadeMaxTemplate.js ← Seed do template (Secção 08) └── components/ ← React frontend (Secção 04) ├── FunnelPlayer.jsx ├── hooks/ │ └── useFunnelSession.js ├── steps/ │ ├── CheckoutStep.jsx │ ├── UpsellStep.jsx │ ├── DownsellStep.jsx │ └── ThankYouStep.jsx └── PaymentPoller.jsx
Dependências Node.js
express
pg (PostgreSQL)
ioredis
stripe
express-rate-limit
crypto (nativo)
Dependências React
react
axios (api calls)
react-router-dom
zustand (estado global)
date-fns (timer)
Infra necessária
PostgreSQL 14+
Redis 7+
Node.js 18+
HTTPS obrigatório
Webhook endpoint público
Opsellio
Template Funil Cascata Máxima · 3 Upsells + 3 Downsells · v2025-05
8 steps · 16 caminhos · Node.js + React + PostgreSQL + Redis