📐 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
Índice
00Mapa do funil — fluxo visual e routing completo
01Schema de dados — migration SQL completo
02Motor de routing — funnelEngine.js
03API endpoints — routes/funnel.js
04Frontend — componentes React de cada step
05Pagamentos M-Pesa/e-Mola no contexto de upsell
06Segurança — idempotência, lock Redis, validações
07Analytics — eventos por step, pixels de conversão
08Migrações e seed do template
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)
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)
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
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