Opsellio — Framework One-Click & One-Confirm Upsell
⚡ Framework técnico de implementação

One-Click & One-Confirm
Upsell

Implementação completa do débito automático por token de cartão e do upsell com número M-Pesa/e-Mola pré-guardado — o mínimo de fricção possível em cada método.

One-Click Cartão One-Confirm M-Pesa One-Confirm e-Mola Stripe SetupIntent off_session USSD Redis Lock Idempotência
00Visão Geral

Dois métodos, um princípio

O comprador não preenche nada nos upsells. No cartão é 100% automático. No M-Pesa/e-Mola confirma apenas um USSD — o número já está guardado da compra principal.

One-Click Cartão
0 inputs
Débito automático via token
One-Confirm USSD
1 USSD
Mínimo possível no protocolo
Dados reinseridos
Nenhum
Número/token da sessão
TTL do contexto
30 min
Após checkout principal
💡
Porquê o M-Pesa/e-Mola não pode ser one-click como o cartão: o protocolo USSD exige confirmação humana com PIN em cada transação — é a proteção anti-fraude do operador moçambicano. Não é uma limitação técnica da Opsellio, é o protocolo. O que podemos fazer é eliminar tudo o resto: número pré-guardado, zero formulários, apenas o USSD inevitável.
💳 Cartão — One-Click real
Zero interação após o checkout
Checkout
SetupIntent criado → token guardado
Upsell
PaymentIntent off_session automático
Cliente faz
Nada — débito em < 3 segundos
Falha possível
3DS (raro) ou cartão recusado
Fallback
Sugerir M-Pesa/e-Mola
Tecnologia
Stripe SetupIntent + PaymentIntent
📱 M-Pesa/e-Mola — One-Confirm
Uma confirmação USSD obrigatória
Checkout
Número normalizado guardado na sessão
Upsell
Gateway inicia USSD com número guardado
Cliente faz
Confirmar USSD no telemóvel (1 passo)
Tempo espera
30–120 segundos (webhook confirma)
Fallback
WhatsApp recovery se USSD não confirmado
Tecnologia
normalizePhone + gateway SDK + polling
Fluxo unificado — a Opsellio decide qual usar
Início
Checkout
Compra principal paga
guardar contexto
Sessão
Contexto guardado
Token ou número (30 min TTL)
comprador clica
Upsell
Botão "Aceitar"
Token enviado ao servidor
servidor decide
Cartão
off_session auto
< 3 segundos
M-Pesa/e-Mola
USSD enviado
30–120s polling
confirmado
Sucesso
Próximo step
Redirect automático
01One-Click Cartão

Stripe SetupIntent + off_session

O token do cartão é guardado durante o checkout. Nos upsells, o servidor debita directamente — o comprador não vê nada.

⚠️
O SetupIntent tem de ser criado no checkout, não no upsell. Este é o erro mais comum. No momento do upsell o cartão já tem de estar guardado. Se tentares guardar e cobrar ao mesmo tempo, o UX fica complexo e a conversão cai.
Fase 1 — Criar SetupIntent durante o checkout
Backend — initCheckout() com SetupIntent Crítico
Ao iniciar a sessão de checkout, criar um SetupIntent com usage:"off_session" para guardar o cartão para cobranças futuras (upsells). O client_secret vai para o frontend.
services/checkout.js
// Ao iniciar sessão de checkout — ANTES do comprador pagar async function initCheckout(customerData, sessionId) { // 1. Criar ou recuperar cliente Stripe let customer = await stripe.customers.list({ email: customerData.email, limit: 1 }); if (!customer.data.length) { customer = await stripe.customers.create({ email: customerData.email, name: customerData.name, metadata: { funnel_session_id: sessionId }, }); } else { customer = customer.data[0]; } // 2. Criar SetupIntent para guardar cartão para upsells futuros const setupIntent = await stripe.setupIntents.create({ customer: customer.id, usage: 'off_session', // permite cobrar sem o cliente presente payment_method_types: ['card'], metadata: { funnel_session_id: sessionId }, }); // 3. Criar PaymentIntent para o produto principal const paymentIntent = await stripe.paymentIntents.create({ amount: product.price_cents, currency: 'mzn', customer: customer.id, setup_future_usage: 'off_session', // guarda o cartão automaticamente ao pagar metadata: { funnel_session_id: sessionId }, }); // 4. Guardar na sessão de funil await db.funnel_sessions.update(sessionId, { stripe_customer_id: customer.id, stripe_setup_intent_id: setupIntent.id, payment_context_expires: new Date(Date.now() + 30 * 60 * 1000), }); return { payment_client_secret: paymentIntent.client_secret, stripe_customer_id: customer.id, }; }
Fase 2 — Webhook Stripe: extrair e guardar token
Backend — webhook payment_intent.succeeded Crítico
Quando o checkout é pago, o Stripe envia o webhook. Extrair o payment_method (token do cartão) e guardar na sessão para uso nos upsells.
routes/webhooks/stripe.js
// Webhook Stripe — verificar assinatura HMAC antes de processar router.post('/webhook/stripe', express.raw({type:'*/*'}), async (req, res) => { const sig = req.headers['stripe-signature']; const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET); res.status(200).send('OK'); // responder imediatamente — processar assíncrono if (event.type === 'payment_intent.succeeded') { const pi = event.data.object; const sessionId = pi.metadata.funnel_session_id; if (!sessionId) return; // Idempotência — não processar o mesmo evento duas vezes const already = await redis.set(`stripe:evt:${event.id}`,'1','NX','EX',604800); if (!already) return; // Guardar token do cartão na sessão await db.funnel_sessions.update(sessionId, { payment_method_type: 'card', stripe_payment_method_id: pi.payment_method, // pm_abc123 — o token }); // Avançar sessão para o primeiro upsell await funnelEngine.advance(sessionId, 'accept', pi.id); } });
Fase 3 — Débito automático nos upsells
Backend — chargeCardOneClick() Crítico
Quando o comprador aceita o upsell, criar PaymentIntent com confirm:true e off_session:true. Zero interação do comprador — resultado em menos de 3 segundos.
services/upsellPayments.js
async function chargeCardOneClick(session, step) { // Verificar que temos o token e que o contexto não expirou if (!session.stripe_payment_method_id) throw new Error('Token de cartão não encontrado'); if (new Date() > new Date(session.payment_context_expires)) { return { status: 'context_expired' }; // pedir dados de cartão novamente } try { const pi = await stripe.paymentIntents.create({ amount: step.price_cents, currency: 'mzn', customer: session.stripe_customer_id, payment_method: session.stripe_payment_method_id, confirm: true, // confirmar imediatamente off_session: true, // sem presença do cliente idempotency_key: `upsell_${session.id}_${step.id}`, }); if (pi.status === 'succeeded') { return { status: 'paid', payment_id: pi.id }; } // 3DS necessário — raro em off_session mas possível if (pi.status === 'requires_action') { return { status: 'requires_3ds', client_secret: pi.client_secret }; } } catch (err) { // Cartão recusado — sugerir M-Pesa/e-Mola como alternativa if (err.code === 'card_declined' || err.code === 'insufficient_funds') { return { status: 'declined', suggest_alternative: true }; } throw err; } }
02One-Confirm M-Pesa/e-Mola

Número guardado da sessão + 1 USSD

Após o checkout M-Pesa/e-Mola, o número normalizado fica na sessão. Nos upsells, o gateway inicia a transação directamente — o comprador só confirma o USSD.

Fase 1 — Normalizar e guardar número após checkout
Funções de normalização e detecção de operador Crítico
Aceitar todos os formatos possíveis de número moçambicano e normalizar para o formato padrão do gateway (258XXXXXXXXX) antes de guardar.
utils/phone.js
/** * Aceita qualquer formato moçambicano e retorna 258XXXXXXXXX * Testa: +258841234567 | 258841234567 | 841234567 | 84 123-4567 */ function normalizePhone(raw) { let d = String(raw).replace(/\D/g, ''); // só dígitos if (d.startsWith('258258')) d = d.slice(3); // duplicado if (d.startsWith('258')) d = d.slice(3); // prefixo if (d.startsWith('00258')) d = d.slice(5); // alternativo if (d.length !== 9) throw new Error('Número inválido'); return '258' + d; } /** Detectar 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; } /** Mascarar número para mostrar na UI — nunca expor completo */ function maskPhone(normalizedPhone) { return '●●●● ' + normalizedPhone.slice(-4); } module.exports = { normalizePhone, detectOperator, maskPhone };
Webhook M-Pesa/e-Mola — guardar número após checkout confirmado Crítico
Só guardar o número APÓS o webhook de confirmação do gateway — nunca quando o comprador inicia o pagamento. O número é extraído do MSISDN que o gateway inclui na confirmação.
routes/webhooks/mpesa.js
router.post('/webhook/mpesa', express.raw({type:'*/*'}), async (req, res) => { // Verificar assinatura HMAC — obrigatório if (!verifyMpesaSignature(req.body, req.headers)) { return res.status(401).send('Assinatura inválida'); } res.status(200).send('OK'); // responder imediatamente const payload = JSON.parse(req.body); const sessionId = payload.reference.split(':')[0]; // Idempotência const lock = await redis.set(`mpesa:evt:${payload.transaction_id}`,'1','NX','EX',604800); if (!lock) return; if (payload.result_code === 'SUCCESS') { const phone = normalizePhone(payload.msisdn); const operator = detectOperator(phone); // Guardar número normalizado + operador + máscara na sessão await db.funnel_sessions.update(sessionId, { payment_method_type: operator, // 'mpesa' | 'emola' payment_phone: phone, // '258841234567' payment_phone_masked: maskPhone(phone), // '●●●● 4567' payment_context_expires: new Date(Date.now() + 30 * 60 * 1000), }); await funnelEngine.advance(sessionId, 'accept', payload.transaction_id); } });
Fase 2 — Iniciar transação com número guardado
Backend — chargeOneConfirm() Crítico
Usar o número normalizado da sessão directamente no SDK do gateway. O comprador não digita nada — apenas recebe o USSD e confirma com o PIN.
services/upsellPayments.js
async function chargeOneConfirm(session, step) { // Verificar contexto válido if (!session.payment_phone) throw new Error('Número de telefone não encontrado'); if (new Date() > new Date(session.payment_context_expires)) { return { status: 'context_expired' }; } const reference = `${session.id}:${step.id}`; const idempotency = `upsell_${session.id}_${step.id}_v1`; if (session.payment_method_type === 'mpesa') { // M-Pesa C2B — iniciar com número da sessão const tx = await mpesaSDK.c2b.initiate({ msisdn: session.payment_phone, // ← número da sessão, não do frontend amount: Math.round(step.price_cents / 100), reference, description: 'Opsellio Upsell', }); return { transaction_id: tx.conversation_id, status: 'awaiting_ussd' }; } if (session.payment_method_type === 'emola') { // e-Mola — payment request com número da sessão const tx = await emolaSDK.paymentRequest({ phone: session.payment_phone, // ← número da sessão amount: step.price_cents / 100, externalId: idempotency, }); return { transaction_id: tx.transactionId, status: 'awaiting_ussd' }; } } // Endpoint central — decide automaticamente qual método usar router.get('/funnel/accept', async (req, res) => { const { session, step } = await validateToken(req.query.t); // Lock Redis — prevenir double-pay const lockKey = `pay_lock:${session.id}:${step.id}`; const lock = await redis.set(lockKey, '1', 'NX', 'EX', 30); if (!lock) return res.redirect('/waiting?already=true'); let tx; if (session.payment_method_type === 'card') { tx = await chargeCardOneClick(session, step); if (tx.status === 'paid') return res.redirect(await getNextStepUrl(session)); if (tx.status === 'declined') return res.redirect(`/upsell-fail?suggest=mpesa&s=${session.id}`); } else { tx = await chargeOneConfirm(session, step); return res.redirect(`/waiting?tx=${tx.transaction_id}&s=${session.id}`); } });
Fase 3 — Página de espera hosted na Opsellio
Frontend — polling + instrução USSD específica por operador Alto
Durante os 30–120s do USSD, mostrar página hosted na Opsellio com countdown, número mascarado e instrução clara. Polling cada 5s por máximo 2 minutos.
pages/waiting.js
async function pollUpsellPayment(txId, sessionId, maskedPhone, operator) { const MAX_ATTEMPTS = 24; // 24 × 5s = 120s let attempts = 0; // Mostrar instrução específica por operador const instruction = { mpesa: `Vai receber uma mensagem M-Pesa em ${maskedPhone}. Confirma com o teu PIN M-Pesa.`, emola: `Vai receber uma mensagem e-Mola em ${maskedPhone}. Confirma com o teu PIN e-Mola.`, }[operator]; document.getElementById('instruction').textContent = instruction; document.getElementById('phone-display').textContent = maskedPhone; const poll = setInterval(async () => { attempts++; if (attempts >= MAX_ATTEMPTS) { clearInterval(poll); // Timeout — estado ambíguo, não erro definitivo showAmbiguous('Ainda a verificar. Se confirmaste, aguarda mais 5 minutos.'); return; } const { data } = await api.get(`/funnel/tx-status/${txId}?s=${sessionId}`); if (data.status === 'paid') { clearInterval(poll); window.location.href = data.next_step_url; // avançar para próximo upsell } if (data.status === 'failed') { clearInterval(poll); showError(data.error_message, data.suggest_alternative); } // 'pending' — continuar polling updateCountdown(MAX_ATTEMPTS - attempts); }, 5000); } // Fallback por WhatsApp se USSD não confirmado em 5 min function sendWhatsAppRecovery(sessionId, stepId, phone) { // Disparar após timeout — mensagem automática com token válido api.post('/funnel/recovery/whatsapp', { session_id: sessionId, step_id: stepId }); // A API envia: "O teu bónus expira em 30 min — clica aqui para activar" // com link que contém um token de accept válido }
03Gestão de Sessão

Schema de sessão e lógica de fallback

Schema — campos de pagamento na funnel_sessions
Migração SQL — campos a adicionar Crítico
migrations/add_payment_context.sql
-- Adicionar à tabela funnel_sessions existente -- Tipo de método de pagamento ALTER TABLE funnel_sessions ADD COLUMN payment_method_type VARCHAR(20), -- 'card' | 'mpesa' | 'emola' -- Cartão (Stripe) ADD COLUMN stripe_customer_id VARCHAR(100), -- cus_abc123 ADD COLUMN stripe_payment_method_id VARCHAR(100), -- pm_abc123 (token guardado) ADD COLUMN stripe_setup_intent_id VARCHAR(100), -- si_abc123 -- M-Pesa / e-Mola ADD COLUMN payment_phone VARCHAR(20), -- '258841234567' (normalizado) ADD COLUMN payment_phone_masked VARCHAR(20), -- '●●●● 4567' (só para UI) ADD COLUMN payment_operator VARCHAR(10), -- 'mpesa' | 'emola' -- Segurança e limites ADD COLUMN payment_context_expires TIMESTAMPTZ, -- TTL: 30 min após checkout ADD COLUMN upsell_attempts INTEGER DEFAULT 0; -- max 10 por sessão -- Índice para queries de validação de contexto CREATE INDEX idx_sessions_payment ON funnel_sessions(payment_method_type, payment_context_expires);
Fallback — método diferente no upsell
Backend — handleUpsellAccept() com lógica de fallback Alto
Se o comprador quer pagar um upsell com método diferente do checkout, ou se o contexto expirou, mostrar formulário completo para o novo método.
services/upsellPayments.js
async function handleUpsellAccept(session, step) { const method = session.payment_method_type; // Verificar que contexto ainda é válido const contextValid = method && new Date() < new Date(session.payment_context_expires); if (!contextValid) { // Contexto expirado ou não encontrado — pedir dados novamente return { status: 'needs_input', redirect: `/upsell-pay?step=${step.id}&s=${session.id}&reason=expired`, }; } // Incrementar contador de tentativas await db.funnel_sessions.update(session.id, { upsell_attempts: (session.upsell_attempts || 0) + 1, }); if (session.upsell_attempts >= 10) { // Muitas tentativas — bloquear por segurança return { status: 'blocked', reason: 'too_many_attempts' }; } // One-click (cartão) ou one-confirm (USSD) if (method === 'card') return chargeCardOneClick(session, step); if (method === 'mpesa' || method === 'emola') return chargeOneConfirm(session, step); throw new Error(`Método desconhecido: ${method}`); }
04Segurança & Idempotência

Lock Redis, idempotência e protecção de dados

🚨
Os três pilares de segurança sem os quais o one-click quebra: (1) lock Redis antes de cada cobrança — previne double-pay por clique duplo; (2) idempotency key em cada PaymentIntent/gateway — previne double-pay em retries; (3) payment_method nunca exposto ao frontend — o token Stripe e o número de telefone são segredos do servidor.
Lock Redis por step de upsell Crítico
SETNX com TTL de 30s antes de qualquer cobrança. Dois cliques simultâneos → só um processa.
async function acquirePayLock(sessionId, stepId) { const key = `pay_lock:${sessionId}:${stepId}`; const locked = await redis.set(key, '1', 'NX', 'EX', 30); return { acquired: !!locked, release: () => redis.del(key), }; } // Uso no endpoint: const { acquired, release } = await acquirePayLock(sessionId, stepId); if (!acquired) return res.redirect('/waiting?already=true'); try { await processPayment(); } finally { await release(); }
Idempotência de webhooks (Redis, 7 dias) Crítico
Stripe e os gateways M-Pesa/e-Mola podem enviar o mesmo webhook múltiplas vezes. Guardar IDs processados e ignorar duplicados.
async function isWebhookProcessed(eventId) { const key = `webhook:${eventId}`; const set = await redis.set(key, '1', 'NX', 'EX', 604800); return !set; // se set é null → já existia → já processado } // Uso em qualquer webhook: if (await isWebhookProcessed(event.id)) { return; // já processado — ignorar silenciosamente } await processEvent(event); // processar apenas uma vez
Proteger dados sensíveis — nunca expor ao frontend Crítico
O token Stripe (pm_abc123) e o número de telefone completo (258841234567) nunca chegam ao browser. O frontend só recebe o número mascarado para contexto visual.
O que o frontend pode ver vs o que fica no servidor
// ✅ O frontend pode ver: { payment_method_type: "mpesa", // tipo (não o número) payment_phone_masked: "●●●● 4567", // mascarado context_expires_in: 1740, // segundos restantes (para countdown) } // ❌ O frontend NUNCA vê: // stripe_payment_method_id: "pm_1abc..." ← token Stripe // stripe_customer_id: "cus_abc..." ← ID cliente Stripe // payment_phone: "258841234567" ← número completo // payment_context_expires: "2025-05-09..." ← TTL exacto // Endpoint seguro — filtrar antes de enviar ao frontend: app.get('/funnel/session/context', async (req, res) => { const session = await db.funnel_sessions.findById(req.session_id); const expiresIn = Math.max(0, (new Date(session.payment_context_expires) - new Date()) / 1000); res.json({ payment_method_type: session.payment_method_type, payment_phone_masked: session.payment_phone_masked, context_expires_in: Math.round(expiresIn), // NUNCA incluir: stripe_payment_method_id, payment_phone, etc. }); });
05Checklist

21 itens de implementação

Marcados com prioridade. Os Críticos são pré-requisitos — sem eles o sistema não é seguro nem funcional.

Opsellio
One-Click & One-Confirm Upsell — Framework técnico · v2025-05
Cartão · M-Pesa · e-Mola · Stripe · Redis · Node.js