⚡ 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
Índice do documento
00Visão geral — comparação dos dois métodos e fluxo unificadoFundação
01One-Click Cartão — SetupIntent, off_session, webhookStripe
02One-Confirm M-Pesa/e-Mola — normalizar, guardar, reusarUSSD
03Gestão de sessão — schema, TTL, fallback de métodoDB
04Segurança — idempotência, lock Redis, protecção de dadosCrítico
05Checklist de implementação — 21 itens com prioridadeChecklist
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