diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 022941c6..6d44a199 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -45,10 +45,83 @@ model Installation { reps Rep[] whatsappSessions WhatsAppSession[] mpesaPayments MpesaPayment[] + whatsappConfig WhatsAppConfig? + messageTemplates MessageTemplate[] + cartRecoveries CartRecovery[] @@map("installations") } +model WhatsAppConfig { + id String @id @default(cuid()) + installationId String @unique + phoneNumberId String @unique // Cloud API phone number ID β€” the inbound routing key + accessToken String // WABA system-user token for this tenant + catalogId String? // Meta Commerce Manager catalog ID + wabaId String? // WhatsApp Business Account ID β€” required to manage templates + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + installation Installation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@map("whatsapp_configs") +} + +/// Message templates required to message a customer outside the 24-hour +/// customer-service window. Mirrors Meta's template registry so clients never +/// touch the Meta console (see docs/whatsapp-commerce-competitive-playbook.md). +model MessageTemplate { + id String @id @default(cuid()) + installationId String + purpose String // Stable key we send by: cart_recovery_1, order_receipt, ... + name String // Meta template name (lowercase, snake_case) + language String @default("en") + category String // UTILITY | MARKETING | AUTHENTICATION + bodyText String // Body with {{1}}-style placeholders + headerText String? + footerText String? + buttons Json? // Meta BUTTONS component payload + placeholderCount Int @default(0) + metaTemplateId String? // Returned by Meta on submission + status String @default("LOCAL") // LOCAL, PENDING, APPROVED, REJECTED, PAUSED, DISABLED + rejectionReason String? + lastSyncedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + installation Installation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, name, language]) + @@index([installationId, purpose, status]) + @@map("message_templates") +} + +/// One row per abandoned cart (order that reached STK push but never settled). +/// Drives the 30-minute / 24-hour / 72-hour recovery arc. +model CartRecovery { + id String @id @default(cuid()) + installationId String + orderReference String + phone String + amount String + itemsSummary String? // Human-readable line for the message body + stage Int @default(0) // Recovery messages sent so far + status String @default("pending") // pending, recovered, exhausted, cancelled + abandonedAt DateTime @default(now()) + nextAttemptAt DateTime? // Null once terminal + lastAttemptAt DateTime? + recoveredAt DateTime? + lastError String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + installation Installation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, orderReference]) + @@index([status, nextAttemptAt]) + @@map("cart_recoveries") +} + model Product { id String @id @default(cuid()) installationId String @@ -138,7 +211,8 @@ model WhatsAppSession { model MpesaPayment { id String @id @default(cuid()) installationId String - orderReference String // Local/Fluid order reference this payment settles + orderReference String // Local order reference (WA-xxxx) this payment settles + fluidOrderId String? // Fluid platform order ID, when creation succeeded checkoutRequestId String @unique // Daraja STK push CheckoutRequestID merchantRequestId String? phone String diff --git a/backend/src/config/fastify.ts b/backend/src/config/fastify.ts index 1684f6cd..61151692 100644 --- a/backend/src/config/fastify.ts +++ b/backend/src/config/fastify.ts @@ -7,6 +7,17 @@ export function createFastifyInstance() { logger: { level: 'info' } }); + // Keep the raw JSON body alongside the parsed one β€” Meta's + // X-Hub-Signature-256 must be verified over the exact bytes received. + fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => { + ;(request as any).rawBody = body + try { + done(null, body.length ? JSON.parse(body.toString('utf8')) : {}) + } catch (err) { + done(err as Error, undefined) + } + }); + // Register plugins fastify.register(helmet); diff --git a/backend/src/index.ts b/backend/src/index.ts index 83e73fe3..6cd058af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,8 @@ import { repRoutes } from './routes/reps'; import { testWebhookRoutes } from './routes/testWebhook'; import { whatsappRoutes } from './routes/whatsapp'; import { mpesaRoutes } from './routes/mpesa'; +import { templateRoutes } from './routes/templates'; +import { jobRoutes } from './routes/jobs'; // Create Fastify instance with all configuration const fastify = createFastifyInstance(); @@ -22,6 +24,8 @@ fastify.register(repRoutes); fastify.register(testWebhookRoutes); fastify.register(whatsappRoutes); fastify.register(mpesaRoutes); +fastify.register(templateRoutes); +fastify.register(jobRoutes); // Start the server const start = async () => { diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts new file mode 100644 index 00000000..ab793524 --- /dev/null +++ b/backend/src/routes/jobs.ts @@ -0,0 +1,57 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { CartRecoveryService } from '../services/cartRecoveryService' +import { resolveTenant } from './templates' + +/** + * Background job endpoints. + * + * The cart-recovery runner is exposed as an HTTP endpoint so it can be driven + * by an external scheduler (Render Cron, GitHub Actions, cron-job.org) rather + * than depending on a single long-lived process. It is also run on an interval + * in-process when CART_RECOVERY_INTERVAL_MINUTES is set β€” handy for a demo box + * but not the right choice for multi-instance deployments. + * + * Protect with JOBS_SECRET (query param or x-jobs-secret header). + */ +export async function jobRoutes(fastify: FastifyInstance) { + fastify.post('/api/jobs/cart-recovery', async (request: FastifyRequest, reply: FastifyReply) => { + const secret = process.env.JOBS_SECRET + if (secret) { + const provided = + (request.query as Record)?.secret || + (request.headers['x-jobs-secret'] as string | undefined) + if (provided !== secret) { + return reply.status(401).send({ error: 'Unauthorized' }) + } + } + + try { + const result = await CartRecoveryService.runDue(resolveTenant, fastify.log) + fastify.log.info( + `πŸ›’ Cart recovery run: considered=${result.considered} sent=${result.sent} skipped=${result.skipped} failed=${result.failed} exhausted=${result.exhausted}` + ) + return { status: 'ok', ...result } + } catch (error) { + fastify.log.error(error) + return reply.status(500).send({ error: 'Cart recovery run failed' }) + } + }) + + // Optional in-process scheduler for demos / single-instance deployments + const intervalMinutes = Number(process.env.CART_RECOVERY_INTERVAL_MINUTES || 0) + if (intervalMinutes > 0) { + const timer = setInterval(() => { + CartRecoveryService.runDue(resolveTenant, fastify.log) + .then(result => { + if (result.considered > 0) { + fastify.log.info(`πŸ›’ Cart recovery tick: sent=${result.sent} skipped=${result.skipped}`) + } + }) + .catch(err => fastify.log.error(`❌ Cart recovery tick failed: ${err}`)) + }, intervalMinutes * 60_000) + + timer.unref?.() + fastify.addHook('onClose', async () => clearInterval(timer)) + fastify.log.info(`⏰ In-process cart recovery scheduled every ${intervalMinutes} minute(s)`) + } +} diff --git a/backend/src/routes/mpesa.ts b/backend/src/routes/mpesa.ts index 535af594..0300d378 100644 --- a/backend/src/routes/mpesa.ts +++ b/backend/src/routes/mpesa.ts @@ -1,17 +1,28 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { prisma } from '../db' import { MpesaService } from '../services/mpesaService' -import { WhatsAppService } from '../services/whatsappService' +import { WhatsAppService, WhatsAppTenant } from '../services/whatsappService' +import { FluidService } from '../services/fluidService' +import { CartRecoveryService } from '../services/cartRecoveryService' /** * M-Pesa Daraja callback endpoint. Safaricom POSTs the STK push result here; - * we settle the payment record, flip the order, and close the loop in WhatsApp. + * we settle the payment record, flip the order (locally and in Fluid), and + * close the loop in WhatsApp. */ export async function mpesaRoutes(fastify: FastifyInstance) { fastify.post('/api/webhook/mpesa/callback', async (request: FastifyRequest, reply: FastifyReply) => { try { - // TODO(security): restrict by Safaricom IP allowlist or a secret path segment; - // Daraja callbacks carry no signature. + // Daraja callbacks carry no signature. A shared-secret path segment plus + // (in production) Safaricom's published IP ranges is the practical guard. + if (process.env.MPESA_CALLBACK_SECRET) { + const provided = (request.query as Record)?.secret + if (provided !== process.env.MPESA_CALLBACK_SECRET) { + fastify.log.warn('🚫 M-Pesa callback rejected: bad callback secret') + return reply.status(401).send({ error: 'Unauthorized' }) + } + } + const result = MpesaService.parseCallback(request.body) if (!result) { fastify.log.warn('⚠️ Unrecognized M-Pesa callback payload') @@ -20,6 +31,7 @@ export async function mpesaRoutes(fastify: FastifyInstance) { fastify.log.info(`πŸ’° M-Pesa callback ${result.checkoutRequestId}: ${result.success ? 'PAID' : 'FAILED'} (${result.resultDescription})`) + // Idempotency: only transition rows still pending, so Daraja retries no-op const payments = await prisma.$queryRaw` UPDATE mpesa_payments SET status = ${result.success ? 'success' : 'failed'}, @@ -27,16 +39,17 @@ export async function mpesaRoutes(fastify: FastifyInstance) { "resultDescription" = ${result.resultDescription}, "rawCallback" = ${JSON.stringify(request.body)}::jsonb, "updatedAt" = NOW() - WHERE "checkoutRequestId" = ${result.checkoutRequestId} - RETURNING "installationId", "orderReference", phone, amount + WHERE "checkoutRequestId" = ${result.checkoutRequestId} AND status = 'pending' + RETURNING "installationId", "orderReference", "fluidOrderId", phone, amount ` as any[] if (!payments.length) { - fastify.log.warn(`⚠️ No mpesa_payments row for checkoutRequestId ${result.checkoutRequestId}`) + fastify.log.warn(`⚠️ No pending mpesa_payments row for checkoutRequestId ${result.checkoutRequestId} (missing or already settled)`) return reply.send({ ResultCode: 0, ResultDesc: 'Accepted' }) } const payment = payments[0] + const tenant = await resolveTenantForInstallation(payment.installationId) if (result.success) { await prisma.$executeRaw` @@ -45,19 +58,40 @@ export async function mpesaRoutes(fastify: FastifyInstance) { WHERE "installationId" = ${payment.installationId} AND "fluidOrderId" = ${payment.orderReference} ` - // TODO(fluid-order): mark the order paid in Fluid via the platform API - // so fulfillment and rep commission events fire upstream. + // Stop the recovery arc β€” this cart converted + await CartRecoveryService.markRecovered(payment.installationId, payment.orderReference) - await WhatsAppService.sendOrderConfirmation( - payment.phone, - payment.orderReference, - Number(payment.amount), - `Payment received βœ… (M-Pesa ref ${result.receiptNumber}). We are preparing your order.` - ).catch(err => fastify.log.error(`❌ Failed to send WhatsApp receipt: ${err}`)) - } else { + // Mark the order paid in Fluid so fulfillment and rep commissions fire upstream + if (payment.fluidOrderId) { + const ctx = await FluidService.getInstallationContext(payment.installationId) + if (ctx) { + const marked = await FluidService.markOrderPaid( + ctx, + payment.fluidOrderId, + { amountKes: Number(payment.amount), receiptNumber: result.receiptNumber }, + fastify.log + ) + if (!marked) fastify.log.warn(`⚠️ Could not mark Fluid order ${payment.fluidOrderId} paid; local state is settled`) + } + } + + if (tenant) { + await WhatsAppService.sendOrderConfirmation( + tenant, + payment.phone, + payment.orderReference, + Number(payment.amount), + `Payment received βœ… (M-Pesa ref ${result.receiptNumber}). We are preparing your order.` + ).catch(err => fastify.log.error(`❌ Failed to send WhatsApp receipt: ${err}`)) + } + } else if (tenant) { + // The customer just interacted, so we are inside the 24h window and + // free-form text is fine. The recovery arc (30min/24h/72h) takes over + // from here using approved templates once the window closes. await WhatsAppService.sendText( + tenant, payment.phone, - `Payment for order ${payment.orderReference} was not completed (${result.resultDescription}). Reply "pay" to try again.` + `Payment for order ${payment.orderReference} was not completed (${result.resultDescription}). Reply *pay* to try again.` ).catch(err => fastify.log.error(`❌ Failed to send WhatsApp payment-failure notice: ${err}`)) } @@ -69,3 +103,25 @@ export async function mpesaRoutes(fastify: FastifyInstance) { } }) } + +/** Tenant lookup for outbound messages: per-installation config, env fallback. */ +async function resolveTenantForInstallation(installationId: string): Promise { + const configs = await prisma.$queryRaw` + SELECT "phoneNumberId", "accessToken", "catalogId", "wabaId" + FROM whatsapp_configs + WHERE "installationId" = ${installationId} + LIMIT 1 + ` as any[] + + if (configs.length) { + return { + phoneNumberId: configs[0].phoneNumberId, + accessToken: configs[0].accessToken, + catalogId: configs[0].catalogId, + wabaId: configs[0].wabaId + } + } + + const envTenant = WhatsAppService.envTenant() + return envTenant.accessToken && envTenant.phoneNumberId ? envTenant : null +} diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts new file mode 100644 index 00000000..395498e2 --- /dev/null +++ b/backend/src/routes/templates.ts @@ -0,0 +1,105 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { prisma } from '../db' +import { TemplateService, TEMPLATE_LIBRARY } from '../services/templateService' +import { WhatsAppService, WhatsAppTenant } from '../services/whatsappService' + +/** + * Template management API. + * + * The point of these endpoints is that a client never opens the Meta console: + * seed the pre-built library, submit it, poll status. The dashboard can drive + * all of it. See docs/whatsapp-commerce-competitive-playbook.md Β§4. + */ +export async function templateRoutes(fastify: FastifyInstance) { + // The library itself (static β€” useful for the dashboard preview) + fastify.get('/api/templates/library', async () => ({ + templates: TEMPLATE_LIBRARY.map(t => ({ + purpose: t.purpose, + name: t.name, + language: t.language, + category: t.category, + bodyText: t.bodyText, + placeholderCount: t.placeholderCount + })) + })) + + // Templates stored for an installation, with live status + fastify.get('/api/templates/:installationId', async (request: FastifyRequest, reply: FastifyReply) => { + const { installationId } = request.params as { installationId: string } + try { + const templates = await TemplateService.list(installationId) + return { templates, count: templates.length } + } catch (error) { + fastify.log.error(error) + return reply.status(500).send({ error: 'Failed to list templates' }) + } + }) + + // Seed the default library (idempotent) + fastify.post('/api/templates/:installationId/seed', async (request: FastifyRequest, reply: FastifyReply) => { + const { installationId } = request.params as { installationId: string } + try { + const seeded = await TemplateService.seedLibrary(installationId) + const templates = await TemplateService.list(installationId) + return { seeded, total: templates.length, templates } + } catch (error) { + fastify.log.error(error) + return reply.status(500).send({ error: 'Failed to seed template library' }) + } + }) + + // Submit all LOCAL/REJECTED templates to Meta for review + fastify.post('/api/templates/:installationId/submit', async (request: FastifyRequest, reply: FastifyReply) => { + const { installationId } = request.params as { installationId: string } + try { + const tenant = await resolveTenant(installationId) + if (!tenant) return reply.status(400).send({ error: 'No WhatsApp config for this installation' }) + if (!tenant.wabaId) return reply.status(400).send({ error: 'wabaId is required to manage templates' }) + + const result = await TemplateService.submitAllPending(installationId, tenant, fastify.log) + const templates = await TemplateService.list(installationId) + return { ...result, templates } + } catch (error) { + fastify.log.error(error) + return reply.status(500).send({ error: 'Failed to submit templates' }) + } + }) + + // Pull current approval statuses from Meta + fastify.post('/api/templates/:installationId/sync', async (request: FastifyRequest, reply: FastifyReply) => { + const { installationId } = request.params as { installationId: string } + try { + const tenant = await resolveTenant(installationId) + if (!tenant) return reply.status(400).send({ error: 'No WhatsApp config for this installation' }) + + const result = await TemplateService.syncStatuses(installationId, tenant, fastify.log) + const templates = await TemplateService.list(installationId) + return { ...result, templates } + } catch (error) { + fastify.log.error(error) + return reply.status(500).send({ error: 'Failed to sync template statuses' }) + } + }) +} + +/** Per-installation WhatsApp config, falling back to env vars (single tenant). */ +export async function resolveTenant(installationId: string): Promise { + const configs = await prisma.$queryRaw` + SELECT "phoneNumberId", "accessToken", "catalogId", "wabaId" + FROM whatsapp_configs + WHERE "installationId" = ${installationId} + LIMIT 1 + ` as any[] + + if (configs.length) { + return { + phoneNumberId: configs[0].phoneNumberId, + accessToken: configs[0].accessToken, + catalogId: configs[0].catalogId, + wabaId: configs[0].wabaId + } + } + + const envTenant = WhatsAppService.envTenant() + return envTenant.accessToken && envTenant.phoneNumberId ? envTenant : null +} diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 25eb2806..29073a97 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { prisma } from '../db' import { randomUUID } from 'crypto' import { WebhookRegistrationService } from '../services/webhookRegistration' +import { WhatsAppService } from '../services/whatsappService' export async function webhookRoutes(fastify: FastifyInstance) { // Webhook endpoint for Fluid platform events @@ -87,6 +88,38 @@ export async function webhookRoutes(fastify: FastifyInstance) { `; fastify.log.info(`βœ… Product ${body.product.id} automatically saved to database for ${installation.companyName}`); + + // Mirror the product into the Meta Commerce Manager catalog so the + // WhatsApp storefront stays in sync. Fire-and-forget: catalog + // failures never block Fluid webhook processing. + setImmediate(async () => { + try { + const configs = await prisma.$queryRaw` + SELECT "phoneNumberId", "accessToken", "catalogId" + FROM whatsapp_configs + WHERE "installationId" = ${installation.installationId} + LIMIT 1 + ` as any[]; + + const tenant = configs.length + ? { phoneNumberId: configs[0].phoneNumberId, accessToken: configs[0].accessToken, catalogId: configs[0].catalogId } + : WhatsAppService.envTenant(); + + if (!tenant.catalogId || !tenant.accessToken) return; // WhatsApp commerce not configured for this tenant + + await WhatsAppService.syncProductToCatalog(tenant, { + retailerId: body.product.sku || body.product.id.toString(), + title: body.product.title, + description: cleanDescription, + priceKes: body.product.price || null, + imageUrl: body.product.image_url || body.product.imageUrl || null, + inStock: body.product.in_stock ?? true + }); + fastify.log.info(`πŸ›οΈ Product ${body.product.id} synced to WhatsApp catalog ${tenant.catalogId}`); + } catch (catalogError) { + fastify.log.warn(`⚠️ WhatsApp catalog sync failed for product ${body.product.id}: ${catalogError}`); + } + }); } else { fastify.log.warn(`⚠️ No installation found for fluid shop: ${fluidShop}`); } diff --git a/backend/src/routes/whatsapp.ts b/backend/src/routes/whatsapp.ts index 812bcf04..bc78b2b4 100644 --- a/backend/src/routes/whatsapp.ts +++ b/backend/src/routes/whatsapp.ts @@ -1,7 +1,9 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { prisma } from '../db' -import { WhatsAppService, InboundWhatsAppMessage } from '../services/whatsappService' +import { WhatsAppService, WhatsAppTenant, InboundWhatsAppMessage } from '../services/whatsappService' import { MpesaService } from '../services/mpesaService' +import { FluidService } from '../services/fluidService' +import { CartRecoveryService } from '../services/cartRecoveryService' /** * WhatsApp Cloud API webhook endpoints. @@ -9,10 +11,15 @@ import { MpesaService } from '../services/mpesaService' * Flow (see docs/whatsapp-commerce-spec.md): * 1. Customer opens chat from a rep's wa.me deep link (referral carries rep shareGuid) * 2. Bot sends the native catalog message; customer browses + builds a cart in WhatsApp - * 3. Cart arrives here as an `order` message -> we create a pending order + * 3. Cart arrives here as an `order` message -> order created locally AND in Fluid * 4. We fire an M-Pesa STK push; the PIN prompt pops on the customer's phone * 5. Daraja callback (routes/mpesa.ts) confirms payment -> order paid -> receipt sent */ +interface ResolvedTenant { + installationId: string + tenant: WhatsAppTenant +} + export async function whatsappRoutes(fastify: FastifyInstance) { // Meta webhook verification handshake (performed once when configuring the app) fastify.get('/api/webhook/whatsapp', async (request: FastifyRequest, reply: FastifyReply) => { @@ -30,9 +37,19 @@ export async function whatsappRoutes(fastify: FastifyInstance) { // Inbound messages + statuses fastify.post('/api/webhook/whatsapp', async (request: FastifyRequest, reply: FastifyReply) => { try { - // TODO(security): verify X-Hub-Signature-256 over the RAW body before parsing. - // Requires registering a rawBody content-type parser in config/fastify.ts: - // WhatsAppService.verifySignature(rawBody, request.headers['x-hub-signature-256']) + // Verify X-Hub-Signature-256 over the raw body (preserved in config/fastify.ts). + // Enforced whenever an app secret is configured; without one we log and continue + // so local development against ngrok/sandbox keeps working. + const rawBody = (request as any).rawBody as Buffer | undefined + const signature = request.headers['x-hub-signature-256'] as string | undefined + if (process.env.WHATSAPP_APP_SECRET) { + if (!rawBody || !WhatsAppService.verifySignature(rawBody, signature)) { + fastify.log.warn('🚫 WhatsApp webhook rejected: bad or missing X-Hub-Signature-256') + return reply.status(401).send({ error: 'Invalid signature' }) + } + } else { + fastify.log.warn('⚠️ WHATSAPP_APP_SECRET not set β€” webhook signature NOT verified') + } const body = request.body as any const messages = WhatsAppService.parseInbound(body) @@ -53,59 +70,180 @@ export async function whatsappRoutes(fastify: FastifyInstance) { }) } -async function handleInbound(fastify: FastifyInstance, message: InboundWhatsAppMessage) { - // TODO(multi-tenant): resolve the installation from the receiving phone number ID - // (webhook value.metadata.phone_number_id -> per-installation WhatsApp config). - // Scaffold uses the first active installation. +/** + * Multi-tenant routing: the receiving number's phone_number_id maps to an + * installation via whatsapp_configs. Env-var config is the single-tenant + * fallback (first active installation) so the sandbox works with zero rows. + */ +async function resolveTenant(fastify: FastifyInstance, phoneNumberId?: string): Promise { + if (phoneNumberId) { + const configs = await prisma.$queryRaw` + SELECT wc."installationId", wc."phoneNumberId", wc."accessToken", wc."catalogId", wc."wabaId" + FROM whatsapp_configs wc + JOIN installations i ON wc."installationId" = i.id + WHERE wc."phoneNumberId" = ${phoneNumberId} AND i."isActive" = true + LIMIT 1 + ` as any[] + + if (configs.length) { + const config = configs[0] + return { + installationId: config.installationId, + tenant: { + phoneNumberId: config.phoneNumberId, + accessToken: config.accessToken, + catalogId: config.catalogId, + wabaId: config.wabaId + } + } + } + } + + const envTenant = WhatsAppService.envTenant() + if (!envTenant.accessToken) { + fastify.log.warn(`⚠️ No whatsapp_config for phone_number_id ${phoneNumberId} and no env fallback`) + return null + } + const installations = await prisma.$queryRaw` - SELECT i.id as "installationId", c.name as "companyName" + SELECT i.id as "installationId" FROM installations i - JOIN companies c ON i."companyId" = c.id WHERE i."isActive" = true + ORDER BY i."createdAt" ASC LIMIT 1 ` as any[] if (!installations.length) { fastify.log.warn('⚠️ No active installation for inbound WhatsApp message') - return + return null } - const installationId = installations[0].installationId + + return { + installationId: installations[0].installationId, + tenant: { ...envTenant, phoneNumberId: phoneNumberId || envTenant.phoneNumberId } + } +} + +async function handleInbound(fastify: FastifyInstance, message: InboundWhatsAppMessage) { + const resolved = await resolveTenant(fastify, message.phoneNumberId) + if (!resolved) return + const { installationId, tenant } = resolved // Rep attribution: wa.me deep links carry ?text=ref:; referral.body preserves it const refMatch = (message.referral?.body || message.text || '').match(/ref:([\w-]+)/) let repId: string | null = null + let repShareGuid: string | null = null if (refMatch) { const reps = await prisma.$queryRaw` - SELECT id FROM reps WHERE "installationId" = ${installationId} AND "shareGuid" = ${refMatch[1]} LIMIT 1 + SELECT id, "shareGuid" FROM reps + WHERE "installationId" = ${installationId} AND "shareGuid" = ${refMatch[1]} + LIMIT 1 ` as any[] repId = reps[0]?.id || null + repShareGuid = reps[0]?.shareGuid || null } - // Upsert the chat session (state machine + rep binding) - await prisma.$executeRaw` + // Upsert the chat session (state machine + rep binding, first-touch wins) + const sessions = await prisma.$queryRaw` INSERT INTO whatsapp_sessions (id, "installationId", phone, "repId", state, "lastMessageAt", "createdAt", "updatedAt") VALUES (gen_random_uuid(), ${installationId}, ${message.from}, ${repId}, 'active', NOW(), NOW(), NOW()) ON CONFLICT ("installationId", phone) DO UPDATE SET - "repId" = COALESCE(EXCLUDED."repId", whatsapp_sessions."repId"), + "repId" = COALESCE(whatsapp_sessions."repId", EXCLUDED."repId"), "lastMessageAt" = NOW(), "updatedAt" = NOW() - ` + RETURNING "repId" + ` as any[] + + // Use the session's bound rep (covers carts sent after the initial referral message) + const sessionRepId = sessions[0]?.repId || repId + if (sessionRepId && !repShareGuid) { + const reps = await prisma.$queryRaw` + SELECT "shareGuid" FROM reps WHERE id = ${sessionRepId} LIMIT 1 + ` as any[] + repShareGuid = reps[0]?.shareGuid || null + } if (message.type === 'order' && message.orderItems?.length) { - await handleCartSubmission(fastify, installationId, message) - } else if (message.type === 'text') { - // Any text starts (or restarts) the shopping flow with the native catalog + await handleCartSubmission(fastify, installationId, tenant, message, repShareGuid) + return + } + + const keyword = (message.text || '').trim().toLowerCase() + + // Keyword handling must come before the catalog fallback, otherwise a + // customer replying "pay" to a recovery message just gets the catalog again. + if (message.type === 'text' || message.type === 'interactive') { + if (keyword === 'pay' || keyword === 'lipa') { + await handlePayRetry(fastify, installationId, tenant, message.from) + return + } + + if (keyword === 'cancel' || keyword === 'stop') { + await CartRecoveryService.cancelAllForPhone(installationId, message.from) + await WhatsAppService.sendText( + tenant, + message.from, + keyword === 'stop' + ? 'You will not receive further order reminders. Message us any time to shop again.' + : 'Your open order has been closed. Message us any time to shop again.' + ) + return + } + } + + if (message.type === 'text') { + // Any other text starts (or restarts) the shopping flow with the native catalog await WhatsAppService.sendCatalogMessage( + tenant, message.from, 'Karibu! Browse our catalog below and add items to your cart. When you send the cart we will send an M-Pesa prompt to this number.' ) } } +/** "reply pay" β€” re-fire the M-Pesa prompt for the customer's open cart. */ +async function handlePayRetry( + fastify: FastifyInstance, + installationId: string, + tenant: WhatsAppTenant, + phone: string +) { + try { + const retry = await CartRecoveryService.retryPayment(installationId, phone, fastify.log) + + if (!retry.retried) { + await WhatsAppService.sendText( + tenant, + phone, + 'You do not have an order waiting for payment. Browse the catalog to start a new order.' + ) + await WhatsAppService.sendCatalogMessage(tenant, phone, 'Karibu! Here is our catalog.') + return + } + + await WhatsAppService.sendOrderConfirmation( + tenant, + phone, + retry.orderReference!, + retry.amount!, + 'We have sent the M-Pesa prompt again β€” check your phone and enter your PIN. πŸ™' + ) + } catch (err) { + fastify.log.error(`❌ Pay retry failed for ${phone}: ${err}`) + await WhatsAppService.sendText( + tenant, + phone, + 'We could not send the M-Pesa prompt just now. Please try again in a few minutes.' + ).catch(() => undefined) + } +} + async function handleCartSubmission( fastify: FastifyInstance, installationId: string, - message: InboundWhatsAppMessage + tenant: WhatsAppTenant, + message: InboundWhatsAppMessage, + repShareGuid: string | null ) { const items = message.orderItems! const totalKes = Math.round(items.reduce((sum, item) => sum + item.item_price * item.quantity, 0)) @@ -113,9 +251,7 @@ async function handleCartSubmission( fastify.log.info(`πŸ›’ WhatsApp cart from ${message.from}: ${items.length} item(s), KSh ${totalKes}`) - // TODO(fluid-order): create the order in Fluid via the platform API using the - // installation's DIT token (OrderService), so it flows into normal fulfillment. - // Scaffold records it locally in the orders table. + // Local order record first β€” source of truth even if the Fluid API call fails await prisma.$executeRaw` INSERT INTO orders ( id, "installationId", "fluidOrderId", "orderNumber", amount, status, @@ -123,12 +259,27 @@ async function handleCartSubmission( ) VALUES ( gen_random_uuid(), ${installationId}, ${orderReference}, ${orderReference}, ${String(totalKes)}, 'pending_payment', null, ${message.from}, - ${items.length}::integer, ${JSON.stringify({ source: 'whatsapp', items, from: message.from })}::jsonb, + ${items.length}::integer, + ${JSON.stringify({ source: 'whatsapp', items, from: message.from, repShareGuid })}::jsonb, NOW(), NOW() ) ON CONFLICT ("installationId", "fluidOrderId") DO NOTHING ` + // Create the order in Fluid so fulfillment + rep commissions fire upstream + let fluidOrderId: string | null = null + const ctx = await FluidService.getInstallationContext(installationId) + if (ctx) { + fluidOrderId = await FluidService.createOrder( + ctx, + { reference: orderReference, customerPhone: message.from, items, totalKes, repShareGuid }, + fastify.log + ) + if (!fluidOrderId) { + fastify.log.warn(`⚠️ Fluid order creation failed for ${orderReference}; local order retained`) + } + } + const stk = await MpesaService.stkPush({ phone: message.from, amount: totalKes, @@ -138,15 +289,27 @@ async function handleCartSubmission( await prisma.$executeRaw` INSERT INTO mpesa_payments ( - id, "installationId", "orderReference", "checkoutRequestId", "merchantRequestId", - phone, amount, status, "createdAt", "updatedAt" + id, "installationId", "orderReference", "fluidOrderId", "checkoutRequestId", + "merchantRequestId", phone, amount, status, "createdAt", "updatedAt" ) VALUES ( - gen_random_uuid(), ${installationId}, ${orderReference}, ${stk.checkoutRequestId}, - ${stk.merchantRequestId}, ${message.from}, ${String(totalKes)}, 'pending', NOW(), NOW() + gen_random_uuid(), ${installationId}, ${orderReference}, ${fluidOrderId}, + ${stk.checkoutRequestId}, ${stk.merchantRequestId}, ${message.from}, + ${String(totalKes)}, 'pending', NOW(), NOW() ) ` + // Make this cart recoverable: if the PIN is never entered, the recovery + // arc (30 min / 24h / 72h) will chase it. + await CartRecoveryService.schedule({ + installationId, + orderReference, + phone: message.from, + amount: totalKes, + itemsSummary: `${items.length} item${items.length === 1 ? '' : 's'}` + }) + await WhatsAppService.sendOrderConfirmation( + tenant, message.from, orderReference, totalKes, diff --git a/backend/src/services/cartRecoveryService.ts b/backend/src/services/cartRecoveryService.ts new file mode 100644 index 00000000..618f1886 --- /dev/null +++ b/backend/src/services/cartRecoveryService.ts @@ -0,0 +1,317 @@ +import { prisma } from '../db' +import { WhatsAppService, WhatsAppTenant } from './whatsappService' +import { TemplateService, TemplatePurpose } from './templateService' +import { MpesaService } from './mpesaService' + +/** + * Abandoned-cart recovery. + * + * Industry data puts WhatsApp cart recovery at 18–23% on optimized flows, and + * automated flows at 60–70% of all WhatsApp revenue β€” so this is the highest + * ROI feature after checkout itself + * (docs/whatsapp-commerce-competitive-playbook.md Β§3). + * + * What "abandoned" means here: the cart became a real order and an M-Pesa STK + * push was fired, but the customer never entered their PIN (or it failed), so + * the order is still `pending_payment`. That is the abandonment we can observe + * β€” catalog browsing without a submitted cart is invisible to the Cloud API. + * + * Cadence β€” first touch fast, then decreasing pressure: + * Stage 1 +30 minutes reminder (usually inside the 24h window) + * Stage 2 +24 hours reassurance (outside window -> template required) + * Stage 3 +72 hours final call (outside window -> template required) + * + * Window rule: free-form text is only legal within 24 hours of the customer's + * last inbound message. Outside that, an APPROVED template is mandatory; if + * none exists we skip rather than attempt an illegal send. + */ +const STAGE_DELAYS_MINUTES = [ + Number(process.env.CART_RECOVERY_STAGE_1_MINUTES || 30), + Number(process.env.CART_RECOVERY_STAGE_2_MINUTES || 24 * 60), + Number(process.env.CART_RECOVERY_STAGE_3_MINUTES || 72 * 60) +] + +const STAGE_PURPOSES: TemplatePurpose[] = [ + 'cart_recovery_1', + 'cart_recovery_2', + 'cart_recovery_3' +] + +const MAX_STAGES = STAGE_DELAYS_MINUTES.length + +export interface RecoveryRunResult { + considered: number + sent: number + skipped: number + failed: number + exhausted: number +} + +export class CartRecoveryService { + /** Called when an STK push is fired, so an unpaid order becomes recoverable. */ + static async schedule(params: { + installationId: string + orderReference: string + phone: string + amount: number + itemsSummary?: string + }): Promise { + const nextAttemptAt = new Date(Date.now() + STAGE_DELAYS_MINUTES[0] * 60_000) + + await prisma.$executeRaw` + INSERT INTO cart_recoveries ( + id, "installationId", "orderReference", phone, amount, "itemsSummary", + stage, status, "abandonedAt", "nextAttemptAt", "createdAt", "updatedAt" + ) VALUES ( + gen_random_uuid(), ${params.installationId}, ${params.orderReference}, + ${params.phone}, ${String(params.amount)}, ${params.itemsSummary || null}, + 0, 'pending', NOW(), ${nextAttemptAt}, NOW(), NOW() + ) + ON CONFLICT ("installationId", "orderReference") DO NOTHING + ` + } + + /** Payment settled β€” stop chasing. */ + static async markRecovered(installationId: string, orderReference: string): Promise { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET status = 'recovered', "recoveredAt" = NOW(), "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE "installationId" = ${installationId} + AND "orderReference" = ${orderReference} + AND status = 'pending' + ` + } + + /** Customer asked to stop, or cancelled the order. */ + static async cancel(installationId: string, orderReference: string): Promise { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET status = 'cancelled', "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE "installationId" = ${installationId} + AND "orderReference" = ${orderReference} + AND status = 'pending' + ` + } + + /** Stop chasing every open cart for a phone number (used by STOP / cancel). */ + static async cancelAllForPhone(installationId: string, phone: string): Promise { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET status = 'cancelled', "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE "installationId" = ${installationId} AND phone = ${phone} AND status = 'pending' + ` + } + + /** Most recent unpaid order for a phone β€” powers the "reply pay" retry. */ + static async findOpenCart(installationId: string, phone: string): Promise<{ + orderReference: string + amount: string + } | null> { + const rows = await prisma.$queryRaw` + SELECT "orderReference", amount + FROM cart_recoveries + WHERE "installationId" = ${installationId} AND phone = ${phone} AND status = 'pending' + ORDER BY "abandonedAt" DESC + LIMIT 1 + ` as any[] + return rows[0] || null + } + + /** + * Re-fire an STK push for an open cart ("reply pay"). Records a new payment + * attempt row so the Daraja callback can settle it. + */ + static async retryPayment( + installationId: string, + phone: string, + log?: { info: (m: string) => void; warn: (m: string) => void } + ): Promise<{ retried: boolean; orderReference?: string; amount?: number }> { + const open = await this.findOpenCart(installationId, phone) + if (!open) return { retried: false } + + const amount = Math.round(Number(open.amount)) + const stk = await MpesaService.stkPush({ + phone, + amount, + accountReference: open.orderReference, + description: 'WhatsApp order retry' + }) + + // Reuse the fluidOrderId already linked to this order reference, if any + const existing = await prisma.$queryRaw` + SELECT "fluidOrderId" FROM mpesa_payments + WHERE "installationId" = ${installationId} AND "orderReference" = ${open.orderReference} + ORDER BY "createdAt" DESC LIMIT 1 + ` as any[] + + await prisma.$executeRaw` + INSERT INTO mpesa_payments ( + id, "installationId", "orderReference", "fluidOrderId", "checkoutRequestId", + "merchantRequestId", phone, amount, status, "createdAt", "updatedAt" + ) VALUES ( + gen_random_uuid(), ${installationId}, ${open.orderReference}, + ${existing[0]?.fluidOrderId || null}, ${stk.checkoutRequestId}, + ${stk.merchantRequestId}, ${phone}, ${String(amount)}, 'pending', NOW(), NOW() + ) + ` + + log?.info(`πŸ” Retried STK push for ${open.orderReference} (${phone})`) + return { retried: true, orderReference: open.orderReference, amount } + } + + /** + * Process every recovery whose next attempt is due. Safe to call repeatedly + * (cron or interval) β€” each row advances at most one stage per run. + */ + static async runDue( + resolveTenant: (installationId: string) => Promise, + log?: { info: (m: string) => void; warn: (m: string) => void }, + limit = 100 + ): Promise { + const result: RecoveryRunResult = { considered: 0, sent: 0, skipped: 0, failed: 0, exhausted: 0 } + + // Claim due rows. Reconcile first: anything already paid should not be chased. + await prisma.$executeRaw` + UPDATE cart_recoveries cr + SET status = 'recovered', "recoveredAt" = NOW(), "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE cr.status = 'pending' + AND EXISTS ( + SELECT 1 FROM orders o + WHERE o."installationId" = cr."installationId" + AND o."fluidOrderId" = cr."orderReference" + AND o.status = 'paid' + ) + ` + + const due = await prisma.$queryRaw` + SELECT cr.id, cr."installationId", cr."orderReference", cr.phone, cr.amount, + cr.stage, cr."itemsSummary", + ws."lastMessageAt" as "lastInboundAt" + FROM cart_recoveries cr + LEFT JOIN whatsapp_sessions ws + ON ws."installationId" = cr."installationId" AND ws.phone = cr.phone + WHERE cr.status = 'pending' + AND cr."nextAttemptAt" IS NOT NULL + AND cr."nextAttemptAt" <= NOW() + ORDER BY cr."nextAttemptAt" ASC + LIMIT ${limit} + ` as any[] + + result.considered = due.length + + for (const row of due) { + const stage = row.stage as number + + if (stage >= MAX_STAGES) { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET status = 'exhausted', "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE id = ${row.id} + ` + result.exhausted++ + continue + } + + const tenant = await resolveTenant(row.installationId) + if (!tenant) { + log?.warn(`⚠️ Cart recovery skipped ${row.orderReference}: no WhatsApp config`) + await this.deferOrExhaust(row.id, stage) + result.skipped++ + continue + } + + const amountLabel = Number(row.amount).toLocaleString('en-KE') + const firstName = 'there' // Cloud API does not expose a verified name; keep it neutral + + // Free-form text is only legal within 24h of the customer's last message + const lastInbound = row.lastInboundAt ? new Date(row.lastInboundAt).getTime() : 0 + const withinWindow = lastInbound > 0 && Date.now() - lastInbound < 24 * 60 * 60 * 1000 + + try { + if (withinWindow) { + await WhatsAppService.sendText( + tenant, + row.phone, + stage === 0 + ? `Your order ${row.orderReference} for KSh ${amountLabel} is still waiting for payment. Reply *pay* and we will send the M-Pesa prompt again.` + : `Order ${row.orderReference} (KSh ${amountLabel}) is still open. Reply *pay* to complete it, or *cancel* to close it.` + ) + } else { + const purpose = STAGE_PURPOSES[stage] + const template = await TemplateService.getApproved(row.installationId, purpose, 'en') + + if (!template) { + // No approved template: sending free-form here would violate policy + log?.warn(`⚠️ Cart recovery skipped ${row.orderReference}: no approved template for ${purpose}`) + await this.deferOrExhaust(row.id, stage) + result.skipped++ + continue + } + + const params = + template.placeholderCount === 3 + ? [firstName, row.orderReference, amountLabel] + : [firstName, row.orderReference] + + await WhatsAppService.sendTemplate( + tenant, + row.phone, + template.name, + template.language, + params.slice(0, template.placeholderCount) + ) + } + + const nextStage = stage + 1 + const nextAttemptAt = + nextStage < MAX_STAGES + ? new Date(Date.now() + (STAGE_DELAYS_MINUTES[nextStage] - STAGE_DELAYS_MINUTES[stage]) * 60_000) + : null + + await prisma.$executeRaw` + UPDATE cart_recoveries + SET stage = ${nextStage}::integer, + "lastAttemptAt" = NOW(), + "nextAttemptAt" = ${nextAttemptAt}, + status = ${nextAttemptAt ? 'pending' : 'exhausted'}, + "lastError" = null, + "updatedAt" = NOW() + WHERE id = ${row.id} + ` + + result.sent++ + if (!nextAttemptAt) result.exhausted++ + log?.info(`πŸ“¨ Cart recovery stage ${nextStage} sent for ${row.orderReference}`) + } catch (err) { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET "lastError" = ${String(err).slice(0, 500)}, + "nextAttemptAt" = NOW() + INTERVAL '1 hour', + "updatedAt" = NOW() + WHERE id = ${row.id} + ` + log?.warn(`⚠️ Cart recovery failed for ${row.orderReference}: ${err}`) + result.failed++ + } + } + + return result + } + + /** Push a skipped attempt out an hour, or give up if it was the last stage. */ + private static async deferOrExhaust(id: string, stage: number): Promise { + if (stage + 1 >= MAX_STAGES) { + await prisma.$executeRaw` + UPDATE cart_recoveries + SET status = 'exhausted', "nextAttemptAt" = null, "updatedAt" = NOW() + WHERE id = ${id} + ` + return + } + await prisma.$executeRaw` + UPDATE cart_recoveries + SET "nextAttemptAt" = NOW() + INTERVAL '1 hour', "updatedAt" = NOW() + WHERE id = ${id} + ` + } +} diff --git a/backend/src/services/fluidService.ts b/backend/src/services/fluidService.ts new file mode 100644 index 00000000..d2694e7d --- /dev/null +++ b/backend/src/services/fluidService.ts @@ -0,0 +1,142 @@ +import { prisma } from '../db' +import { WhatsAppOrderItem } from './whatsappService' + +/** + * Fluid platform order integration. Creates WhatsApp-originated orders in + * Fluid via the installation's DIT token so fulfillment, reporting, and rep + * commissions fire upstream, and marks them paid on M-Pesa settlement. + * + * Endpoint strategy mirrors OrderService.fetchOrdersFromFluid: try the v1 + * subdomain API first, then fall back. A Fluid failure never blocks the + * WhatsApp flow β€” the local orders row is the source of truth until sync. + */ +export interface FluidInstallationContext { + installationId: string + fluidShop: string | null // e.g. "myco.fluid.app" + authToken: string | null +} + +export class FluidService { + /** Load what we need to call the Fluid API on behalf of an installation. */ + static async getInstallationContext(installationId: string): Promise { + const rows = await prisma.$queryRaw` + SELECT i.id as "installationId", i."authenticationToken", i."webhookVerificationToken", c."fluidShop" + FROM installations i + JOIN companies c ON i."companyId" = c.id + WHERE i.id = ${installationId} AND i."isActive" = true + LIMIT 1 + ` as any[] + if (!rows.length) return null + + return { + installationId: rows[0].installationId, + fluidShop: rows[0].fluidShop, + authToken: rows[0].authenticationToken || rows[0].webhookVerificationToken || null + } + } + + /** + * Create an order in Fluid from a WhatsApp cart. Returns the Fluid order ID, + * or null when the API rejects it (caller keeps the local order regardless). + */ + static async createOrder( + ctx: FluidInstallationContext, + order: { + reference: string + customerPhone: string + items: WhatsAppOrderItem[] + totalKes: number + repShareGuid?: string | null + }, + log?: { info: (msg: string) => void; warn: (msg: string) => void } + ): Promise { + const payload = { + order: { + external_id: order.reference, + source: 'whatsapp', + customer_phone: order.customerPhone, + rep_share_guid: order.repShareGuid || undefined, + currency: 'KES', + total: order.totalKes, + line_items: order.items.map(item => ({ + sku: item.product_retailer_id, + quantity: item.quantity, + price: item.item_price + })) + } + } + + const result = await this.request(ctx, 'POST', 'orders', payload, log) + const fluidOrderId = result?.order?.id ?? result?.id ?? null + return fluidOrderId != null ? String(fluidOrderId) : null + } + + /** Mark a Fluid order paid after the M-Pesa callback settles. */ + static async markOrderPaid( + ctx: FluidInstallationContext, + fluidOrderId: string, + payment: { amountKes: number; receiptNumber?: string }, + log?: { info: (msg: string) => void; warn: (msg: string) => void } + ): Promise { + const payload = { + order: { + status: 'paid', + payment: { + method: 'mpesa', + amount: payment.amountKes, + currency: 'KES', + reference: payment.receiptNumber || undefined + } + } + } + + const result = await this.request(ctx, 'PATCH', `orders/${fluidOrderId}`, payload, log) + return result !== null + } + + private static async request( + ctx: FluidInstallationContext, + method: 'POST' | 'PATCH', + resource: string, + payload: unknown, + log?: { info: (msg: string) => void; warn: (msg: string) => void } + ): Promise { + if (!ctx.fluidShop || !ctx.authToken) { + log?.warn(`⚠️ Fluid API skipped for ${resource}: missing fluidShop or auth token`) + return null + } + + const subdomain = ctx.fluidShop.replace('.fluid.app', '') + const endpoints = [ + `https://${subdomain}.fluid.app/api/v1/${resource}`, + `https://fluid.app/api/v1/${resource}?company=${subdomain}` + ] + + for (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, { + method, + headers: { + Authorization: `Bearer ${ctx.authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + if (response.ok) { + log?.info(`βœ… Fluid ${method} ${resource} succeeded via ${endpoint}`) + return response.json() + } + + // 404 likely means wrong endpoint shape β€” try the next; other codes are real failures + const errorText = await response.text() + log?.warn(`⚠️ Fluid ${method} ${endpoint} -> ${response.status}: ${errorText.slice(0, 300)}`) + if (response.status !== 404) return null + } catch (err) { + log?.warn(`⚠️ Fluid ${method} ${endpoint} network error: ${err}`) + } + } + + return null + } +} diff --git a/backend/src/services/templateService.ts b/backend/src/services/templateService.ts new file mode 100644 index 00000000..2282475b --- /dev/null +++ b/backend/src/services/templateService.ts @@ -0,0 +1,310 @@ +import { prisma } from '../db' +import { WhatsAppTenant } from './whatsappService' + +const GRAPH_API_BASE = 'https://graph.facebook.com/v21.0' + +/** + * Message template manager. + * + * Messaging a customer outside WhatsApp's 24-hour customer-service window + * requires a Meta-approved template. Clients should never have to write or + * submit one by hand, so we ship a pre-built library, submit it to Meta on + * their behalf, and track approval status. + */ +export type TemplatePurpose = + | 'cart_recovery_1' + | 'cart_recovery_2' + | 'cart_recovery_3' + | 'order_receipt' + | 'order_status' + | 'payment_failed' + +export interface TemplateDefinition { + purpose: TemplatePurpose + name: string + language: string + category: 'UTILITY' | 'MARKETING' | 'AUTHENTICATION' + bodyText: string + headerText?: string + footerText?: string + buttons?: unknown + placeholderCount: number + /** Sample values Meta requires to review placeholders. */ + example: string[] +} + +/** + * Default library. Deliberately compliant for direct selling: no health + * claims, no income claims, no unverifiable superlatives β€” see + * docs/whatsapp-commerce-competitive-playbook.md Β§5. + * + * UTILITY = tied to a transaction the customer initiated (cheaper, higher + * deliverability). MARKETING = promotional, needs marketing opt-in. + */ +export const TEMPLATE_LIBRARY: TemplateDefinition[] = [ + { + purpose: 'cart_recovery_1', + name: 'cart_reminder_pending_payment', + language: 'en', + category: 'UTILITY', + bodyText: + 'Hi {{1}}, your order {{2}} for KSh {{3}} is still waiting for payment. Reply *pay* and we will send the M-Pesa prompt again.', + footerText: 'Reply STOP to opt out', + placeholderCount: 3, + example: ['Amina', 'WA-K3LM9Q', '3,291'] + }, + { + purpose: 'cart_recovery_2', + name: 'cart_reminder_assurance', + language: 'en', + category: 'UTILITY', + bodyText: + 'Hi {{1}}, order {{2}} is still open. You pay by M-Pesa only after you confirm, and you can collect at our Nairobi office or choose delivery. Reply *pay* to continue or *help* to talk to us.', + footerText: 'Reply STOP to opt out', + placeholderCount: 2, + example: ['Amina', 'WA-K3LM9Q'] + }, + { + purpose: 'cart_recovery_3', + name: 'cart_reminder_final', + language: 'en', + category: 'UTILITY', + bodyText: + 'Hi {{1}}, this is the last reminder for order {{2}} (KSh {{3}}). Reply *pay* to complete it, or *cancel* and we will close it.', + footerText: 'Reply STOP to opt out', + placeholderCount: 3, + example: ['Amina', 'WA-K3LM9Q', '3,291'] + }, + { + purpose: 'order_receipt', + name: 'order_payment_received', + language: 'en', + category: 'UTILITY', + bodyText: + 'Payment received for order {{1}}. Amount: KSh {{2}}. M-Pesa reference: {{3}}. We are preparing your order.', + placeholderCount: 3, + example: ['WA-K3LM9Q', '3,291', 'TEST123XYZ'] + }, + { + purpose: 'order_status', + name: 'order_status_update', + language: 'en', + category: 'UTILITY', + bodyText: 'Update on order {{1}}: {{2}}.', + placeholderCount: 2, + example: ['WA-K3LM9Q', 'ready for collection at our Nairobi office'] + }, + { + purpose: 'payment_failed', + name: 'payment_not_completed', + language: 'en', + category: 'UTILITY', + bodyText: + 'Payment for order {{1}} was not completed ({{2}}). Reply *pay* to try again.', + placeholderCount: 2, + example: ['WA-K3LM9Q', 'request cancelled by user'] + } +] + +export interface StoredTemplate { + id: string + purpose: string + name: string + language: string + status: string + placeholderCount: number + metaTemplateId: string | null +} + +export class TemplateService { + /** Insert the default library for an installation (idempotent). */ + static async seedLibrary(installationId: string): Promise { + let seeded = 0 + + for (const def of TEMPLATE_LIBRARY) { + const result = await prisma.$executeRaw` + INSERT INTO message_templates ( + id, "installationId", purpose, name, language, category, + "bodyText", "headerText", "footerText", buttons, "placeholderCount", + status, "createdAt", "updatedAt" + ) VALUES ( + gen_random_uuid(), ${installationId}, ${def.purpose}, ${def.name}, + ${def.language}, ${def.category}, ${def.bodyText}, + ${def.headerText || null}, ${def.footerText || null}, + ${def.buttons ? JSON.stringify(def.buttons) : null}::jsonb, + ${def.placeholderCount}::integer, 'LOCAL', NOW(), NOW() + ) + ON CONFLICT ("installationId", name, language) DO NOTHING + ` + seeded += result + } + + return seeded + } + + static async list(installationId: string): Promise { + return await prisma.$queryRaw` + SELECT id, purpose, name, language, status, "placeholderCount", "metaTemplateId" + FROM message_templates + WHERE "installationId" = ${installationId} + ORDER BY purpose ASC + ` as StoredTemplate[] + } + + /** + * Resolve an APPROVED template for a purpose. Returns null when nothing is + * approved yet β€” callers must fall back to a free-form message (only legal + * inside the 24-hour window) or skip the send. + */ + static async getApproved( + installationId: string, + purpose: TemplatePurpose, + language = 'en' + ): Promise { + const rows = await prisma.$queryRaw` + SELECT id, purpose, name, language, status, "placeholderCount", "metaTemplateId" + FROM message_templates + WHERE "installationId" = ${installationId} + AND purpose = ${purpose} + AND language = ${language} + AND status = 'APPROVED' + LIMIT 1 + ` as StoredTemplate[] + return rows[0] || null + } + + /** Submit one local template to Meta for review. */ + static async submitToMeta( + tenant: WhatsAppTenant, + templateId: string, + log?: { info: (m: string) => void; warn: (m: string) => void } + ): Promise<{ submitted: boolean; error?: string }> { + if (!tenant.wabaId) return { submitted: false, error: 'No wabaId configured for this tenant' } + + const rows = await prisma.$queryRaw` + SELECT id, purpose, name, language, category, "bodyText", "headerText", + "footerText", buttons, "placeholderCount" + FROM message_templates WHERE id = ${templateId} LIMIT 1 + ` as any[] + if (!rows.length) return { submitted: false, error: 'Template not found' } + const tpl = rows[0] + + const definition = TEMPLATE_LIBRARY.find(d => d.name === tpl.name) + const example = definition?.example || Array.from({ length: tpl.placeholderCount }, (_, i) => `sample${i + 1}`) + + const components: any[] = [] + if (tpl.headerText) { + components.push({ type: 'HEADER', format: 'TEXT', text: tpl.headerText }) + } + const body: any = { type: 'BODY', text: tpl.bodyText } + if (tpl.placeholderCount > 0) { + body.example = { body_text: [example.slice(0, tpl.placeholderCount)] } + } + components.push(body) + if (tpl.footerText) components.push({ type: 'FOOTER', text: tpl.footerText }) + if (tpl.buttons) components.push({ type: 'BUTTONS', buttons: tpl.buttons }) + + try { + const response = await fetch(`${GRAPH_API_BASE}/${tenant.wabaId}/message_templates`, { + method: 'POST', + headers: { + Authorization: `Bearer ${tenant.accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: tpl.name, + language: tpl.language, + category: tpl.category, + components + }) + }) + + const payload: any = await response.json().catch(() => ({})) + + if (!response.ok) { + const message = payload?.error?.error_user_msg || payload?.error?.message || `HTTP ${response.status}` + await prisma.$executeRaw` + UPDATE message_templates + SET status = 'REJECTED', "rejectionReason" = ${String(message).slice(0, 500)}, + "lastSyncedAt" = NOW(), "updatedAt" = NOW() + WHERE id = ${templateId} + ` + log?.warn(`⚠️ Template ${tpl.name} submission failed: ${message}`) + return { submitted: false, error: String(message) } + } + + await prisma.$executeRaw` + UPDATE message_templates + SET status = ${payload.status || 'PENDING'}, "metaTemplateId" = ${payload.id || null}, + "rejectionReason" = null, "lastSyncedAt" = NOW(), "updatedAt" = NOW() + WHERE id = ${templateId} + ` + log?.info(`πŸ“€ Template ${tpl.name} submitted to Meta (${payload.id})`) + return { submitted: true } + } catch (err) { + log?.warn(`⚠️ Template ${tpl.name} submission error: ${err}`) + return { submitted: false, error: String(err) } + } + } + + /** Submit every LOCAL or REJECTED template for an installation. */ + static async submitAllPending( + installationId: string, + tenant: WhatsAppTenant, + log?: { info: (m: string) => void; warn: (m: string) => void } + ): Promise<{ submitted: number; failed: number }> { + const pending = await prisma.$queryRaw` + SELECT id FROM message_templates + WHERE "installationId" = ${installationId} AND status IN ('LOCAL', 'REJECTED') + ` as any[] + + let submitted = 0 + let failed = 0 + for (const row of pending) { + const result = await this.submitToMeta(tenant, row.id, log) + result.submitted ? submitted++ : failed++ + } + return { submitted, failed } + } + + /** + * Pull current statuses from Meta. Approval is asynchronous, so this must be + * polled (or driven by the message_template_status_update webhook field). + */ + static async syncStatuses( + installationId: string, + tenant: WhatsAppTenant, + log?: { info: (m: string) => void; warn: (m: string) => void } + ): Promise<{ updated: number }> { + if (!tenant.wabaId) return { updated: 0 } + + const response = await fetch( + `${GRAPH_API_BASE}/${tenant.wabaId}/message_templates?fields=id,name,language,status,category&limit=200`, + { headers: { Authorization: `Bearer ${tenant.accessToken}` } } + ) + + if (!response.ok) { + log?.warn(`⚠️ Template status sync failed: HTTP ${response.status}`) + return { updated: 0 } + } + + const payload: any = await response.json() + let updated = 0 + + for (const remote of payload.data || []) { + const result = await prisma.$executeRaw` + UPDATE message_templates + SET status = ${remote.status}, "metaTemplateId" = ${remote.id}, + "lastSyncedAt" = NOW(), "updatedAt" = NOW() + WHERE "installationId" = ${installationId} + AND name = ${remote.name} + AND language = ${remote.language} + AND status <> ${remote.status} + ` + updated += result + } + + log?.info(`πŸ”„ Template status sync: ${updated} updated`) + return { updated } + } +} diff --git a/backend/src/services/whatsappService.ts b/backend/src/services/whatsappService.ts index 0cb247e9..1a28b7e6 100644 --- a/backend/src/services/whatsappService.ts +++ b/backend/src/services/whatsappService.ts @@ -3,15 +3,26 @@ import { createHmac, timingSafeEqual } from 'crypto' /** * WhatsApp Cloud API integration. * - * Env vars required (see docs/whatsapp-commerce-spec.md): + * Global env vars (single-tenant fallback; see docs/whatsapp-commerce-spec.md): * WHATSAPP_ACCESS_TOKEN - System-user token for the WABA * WHATSAPP_PHONE_NUMBER_ID - Sender phone number ID * WHATSAPP_VERIFY_TOKEN - Arbitrary string echoed on webhook verification * WHATSAPP_APP_SECRET - Meta app secret, for X-Hub-Signature-256 checks * WHATSAPP_CATALOG_ID - Meta Commerce Manager catalog ID + * + * Multi-tenant: a whatsapp_configs row per installation overrides these + * (routes/whatsapp.ts resolves the tenant from the webhook's phone_number_id). */ const GRAPH_API_BASE = 'https://graph.facebook.com/v21.0' +export interface WhatsAppTenant { + phoneNumberId: string + accessToken: string + catalogId?: string | null + /** WhatsApp Business Account ID β€” required for template management. */ + wabaId?: string | null +} + export interface WhatsAppOrderItem { product_retailer_id: string // maps to Fluid product SKU / fluidProductId quantity: number @@ -23,6 +34,8 @@ export interface InboundWhatsAppMessage { from: string // customer phone in E.164 without "+", e.g. "254712345678" messageId: string timestamp: string + /** Receiving business number's phone_number_id β€” the multi-tenant routing key */ + phoneNumberId?: string type: 'text' | 'order' | 'interactive' | 'other' text?: string orderItems?: WhatsAppOrderItem[] @@ -32,8 +45,13 @@ export interface InboundWhatsAppMessage { } export class WhatsAppService { - static get phoneNumberId(): string { - return process.env.WHATSAPP_PHONE_NUMBER_ID || '' + static envTenant(): WhatsAppTenant { + return { + phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID || '', + accessToken: process.env.WHATSAPP_ACCESS_TOKEN || '', + catalogId: process.env.WHATSAPP_CATALOG_ID || null, + wabaId: process.env.WHATSAPP_WABA_ID || null + } } static get verifyToken(): string { @@ -42,7 +60,7 @@ export class WhatsAppService { /** * Verify the X-Hub-Signature-256 header Meta sends with every webhook. - * Must be computed over the RAW request body, not the parsed JSON. + * Computed over the RAW request body (config/fastify.ts preserves it). */ static verifySignature(rawBody: string | Buffer, signatureHeader?: string): boolean { const appSecret = process.env.WHATSAPP_APP_SECRET @@ -64,11 +82,13 @@ export class WhatsAppService { for (const entry of body?.entry || []) { for (const change of entry.changes || []) { const value = change.value + const phoneNumberId = value?.metadata?.phone_number_id for (const msg of value?.messages || []) { const base = { from: msg.from, messageId: msg.id, timestamp: msg.timestamp, + phoneNumberId, referral: msg.referral } @@ -95,8 +115,8 @@ export class WhatsAppService { } /** Send a plain text message inside an open 24h customer-service window. */ - static async sendText(to: string, body: string): Promise { - await this.graphRequest(`/${this.phoneNumberId}/messages`, { + static async sendText(tenant: WhatsAppTenant, to: string, body: string): Promise { + await this.graphRequest(tenant.accessToken, `/${tenant.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'text', @@ -105,8 +125,8 @@ export class WhatsAppService { } /** Send the storefront entry point: a catalog message the customer can browse and cart from. */ - static async sendCatalogMessage(to: string, bodyText: string): Promise { - await this.graphRequest(`/${this.phoneNumberId}/messages`, { + static async sendCatalogMessage(tenant: WhatsAppTenant, to: string, bodyText: string): Promise { + await this.graphRequest(tenant.accessToken, `/${tenant.phoneNumberId}/messages`, { messaging_product: 'whatsapp', to, type: 'interactive', @@ -118,43 +138,98 @@ export class WhatsAppService { }) } + /** + * Send an approved template message. This is the ONLY way to reach a + * customer outside the 24-hour customer-service window. + */ + static async sendTemplate( + tenant: WhatsAppTenant, + to: string, + templateName: string, + language: string, + bodyParams: string[] = [] + ): Promise { + const components = bodyParams.length + ? [{ type: 'body', parameters: bodyParams.map(text => ({ type: 'text', text })) }] + : undefined + + await this.graphRequest(tenant.accessToken, `/${tenant.phoneNumberId}/messages`, { + messaging_product: 'whatsapp', + to, + type: 'template', + template: { + name: templateName, + language: { code: language }, + ...(components ? { components } : {}) + } + }) + } + /** Confirmation with order summary after the cart lands and before/after payment. */ static async sendOrderConfirmation( + tenant: WhatsAppTenant, to: string, orderNumber: string, totalKes: number, statusLine: string ): Promise { await this.sendText( + tenant, to, `Order ${orderNumber} β€” KSh ${totalKes.toLocaleString('en-KE')}\n${statusLine}` ) } /** - * TODO(catalog-sync): push Fluid products into the Meta Commerce Manager - * catalog (batch API: /{catalog_id}/items_batch) keyed by retailer_id = SKU. - * Trigger from the existing product webhook in routes/webhook.ts. + * Upsert one Fluid product into the tenant's Meta Commerce Manager catalog. + * Uses the items_batch API keyed by retailer_id (= Fluid SKU / product ID), + * so repeated calls update in place. Prices are minor units + currency. */ - static async syncProductToCatalog(_product: { - sku: string - title: string - description?: string | null - priceKes?: string | null - imageUrl?: string | null - inStock: boolean - }): Promise { - throw new Error('Not implemented: Meta catalog sync (see whatsapp-commerce-spec.md Β§5.1)') + static async syncProductToCatalog( + tenant: WhatsAppTenant, + product: { + retailerId: string + title: string + description?: string | null + priceKes?: string | null + imageUrl?: string | null + inStock: boolean + productUrl?: string | null + } + ): Promise { + if (!tenant.catalogId) throw new Error('No catalog ID configured for this tenant') + + const priceMinorUnits = product.priceKes + ? Math.round(parseFloat(product.priceKes) * 100) + : 0 + + await this.graphRequest(tenant.accessToken, `/${tenant.catalogId}/items_batch`, { + item_type: 'PRODUCT_ITEM', + requests: [ + { + method: 'UPDATE', // UPDATE upserts: creates when the retailer_id is new + data: { + id: product.retailerId, + title: product.title.slice(0, 200), + description: (product.description || product.title).slice(0, 9999), + availability: product.inStock ? 'in stock' : 'out of stock', + condition: 'new', + price: `${priceMinorUnits} KES`, + link: product.productUrl || 'https://fluid.app', + image_link: product.imageUrl || 'https://fluid.app/favicon.png' + } + } + ] + }) } - private static async graphRequest(path: string, payload: unknown): Promise { - const token = process.env.WHATSAPP_ACCESS_TOKEN - if (!token) throw new Error('WHATSAPP_ACCESS_TOKEN is not configured') + private static async graphRequest(accessToken: string, path: string, payload: unknown): Promise { + if (!accessToken) throw new Error('WhatsApp access token is not configured') const response = await fetch(`${GRAPH_API_BASE}${path}`, { method: 'POST', headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) diff --git a/docs/fl-localization-audit-brief.md b/docs/fl-localization-audit-brief.md new file mode 100644 index 00000000..768f0f81 --- /dev/null +++ b/docs/fl-localization-audit-brief.md @@ -0,0 +1,183 @@ +# Handoff Brief β€” Forever Living Website Localization Audit + +> Self-contained brief for an agent with live web-browsing/screenshot access. +> Written because `foreverliving.com` is egress-blocked in the environment +> where the hypotheses below were formed β€” they are **unverified** and must be +> confirmed or corrected against the live pages. + +--- + +## 1. Role and objective + +Act as an **ecommerce conversion (CRO) expert and CMO**. + +Audit how Forever Living Products (FLP) localizes its country websites, then +deliver prioritized, implementable recommendations to make the **Kenya** +homepage more locally relevant and higher-converting. + +Answer three questions with evidence: + +1. **Does FLP localize country sites at all**, or ship one global template? +2. **Which elements get localized**, in which markets, and to what depth? +3. **How localized is Kenya** compared with a peer market that does it well? + +## 2. Business context you need + +- FLP is a direct-selling/MLM company (aloe vera drinks, supplements, bee + products, personal care) operating in 160+ countries. +- **Kenya**: established 2005; Nairobi office (Reinsurance Plaza, 4th Floor, + Taifa Road, CBD) also serves as the East Africa hub for Ethiopia, Somalia, + South Sudan, Rwanda, Uganda. +- Kenya ordering today is **manual**: customers register as Preferred + Customers, then order by WhatsApp/phone message (name + national ID), + paying by M-Pesa transfer or on pickup. There is **no integrated + mobile-money checkout** on the official store. +- Reference price: Forever Aloe Vera Gel β‰ˆ **KSh 3,291** officially, while + third-party Jumia sellers list the same product at **KSh 2,280–3,999** β€” + active gray-market price erosion. +- **Benchmark market = Canada**, not the USA: FLP is transitioning away from + its sponsorship-based model in the U.S. (effective May 1, 2026), so Canada + is the closest market that still needs a homepage doing *both* jobs Kenya + needs β€” retail conversion **and** FBO (Forever Business Owner) recruiting. + Treat the **USA** site as the flagship/original template for comparison. +- TikTok Shop does not exist in Kenya or anywhere in Africa, so social + traffic must convert via web or WhatsApp. + +## 3. Pages to audit + +Primary comparison: + +- Kenya (English): https://foreverliving.com/ken/en-ke/home +- Canada (English): https://foreverliving.com/can/en-ca/home +- Canada (French): https://foreverliving.com/can/fr-ca/home +- USA (template baseline): https://foreverliving.com/usa/en-us/home + +Pattern evidence from other markets (sample at least four): + +- France: https://foreverliving.com/fra/fr-fr/home +- India: https://foreverliving.com/ind/en-us/welcome +- UK, South Africa, Nigeria, Japan, Mexico, Brazil β€” discover exact locale + paths from the site's country/region selector and record the pattern. + +Supporting surfaces: + +- Legacy shop per market: `https://shop.foreverliving.com/retail/entry/Shop.do?store=KEN` + (swap KEN / CAN / USA) β€” capture currency, categories, checkout options. +- Kenya contact page and the equivalent Canada page. +- Note whether any market offers a modern self-service checkout vs the legacy + storefront. + +## 4. Method + +For **each** page: capture a full-page screenshot and record the fields +below. Build a comparison matrix with markets as columns. + +Fields to record per page: + +- **URL locale pattern** (country code + language code; note anomalies such + as a non-native language code) +- **Language(s) available** and whether translation is full or partial +- **Hero**: headline, subhead, image subject (are the people/setting + regionally plausible?), primary CTA label and destination +- **Secondary CTAs**: shop, join/become an FBO, find a distributor, contact +- **Currency and prices**: shown on homepage? which currency? formatting? +- **Featured products**: which SKUs, and do they differ by market? +- **Payment methods surfaced** anywhere on the page or checkout (cards, + mobile money, cash on delivery, bank transfer) +- **Local trust signals**: office address, phone, WhatsApp number, local + certifications, delivery/pickup promises +- **Local social proof**: testimonials, faces, named FBOs, events, awards +- **Recruiting prominence**: how visible is the FBO/business opportunity, and + how is income framed (compliance language?) +- **Compliance/legal variations**: market-specific terms (e.g. purchase caps, + income-claim disclaimers, cooling-off periods) +- **Page section order** β€” is the skeleton identical across markets? +- **Technical**: page weight, largest assets, mobile rendering, autoplay + media, Core Web Vitals if measurable (PageSpeed Insights is fine) +- **Imagery provenance**: do the same image assets appear across multiple + country sites? (reverse-image or filename/CDN-path comparison) + +Also assess **SEO/discovery for Kenya**: search "Forever Living Kenya", +"forever aloe vera gel price Kenya", "how to join Forever Living Kenya" and +record whether the corporate site or third-party/distributor sites rank. +Note `hreflang` implementation across locales. + +## 5. Working hypotheses to verify or falsify + +These came from indexed metadata and secondary sources, **not** from viewing +the live pages. Confirm each with a screenshot or quote, and say plainly when +one is wrong. + +- **H1 β€” One global template, tiered localization.** All markets share the + same platform, structure, and "The Aloe Vera Company" identity at + `foreverliving.com/{country}/{locale}/home`. Evidence: Kenya's page title + renders as "The Aloe Vera Company (Kenya)", Canada's as "The Aloe Vera + Company (CA)". +- **H2 β€” Localization tiers exist.** Tier 1 (mature markets) get real + language localization β€” Canada has both `en-ca` and `fr-ca`, France has + `fr-fr`. Tier 2 (emerging markets) get the English template plus a + currency swap; India's locale path is literally `ind/en-us`, which would be + the strongest single piece of evidence for this tier if confirmed. +- **H3 β€” What is localized:** language (Tier 1 only), currency and product + catalog in the shop layer, local contact details, and legally required + business terms (e.g. the UK's Β£200 first-week FBO purchase cap). +- **H4 β€” What is NOT localized:** imagery (global campaign assets reused + everywhere), core messaging and taglines, page structure, payment UX, and + local social proof. +- **H5 β€” Kenya is Tier 2.** English only (no Swahili), KSh pricing confined + to the legacy shop, a local contact page β€” and no local imagery, no M-Pesa + presence on site, no Kenyan testimonials, no locally framed FBO pitch. +- **H6 β€” The conversion gap is payment and trust**, not traffic: the site + never surfaces the payment rail (M-Pesa) or the channel (WhatsApp) that + Kenyan buyers actually use, while marketplace resellers offer mobile money + and cash on delivery. + +If any hypothesis fails, report the correction prominently β€” a wrong premise +changes the recommendations. + +## 6. Deliverable + +Produce a single document with these sections: + +1. **Executive summary** (≀200 words): FLP's localization strategy in one + paragraph, Kenya's tier, and the top three fixes. +2. **Localization matrix**: the comparison table across markets and fields + from Β§4. +3. **Findings**: per-hypothesis verdict (confirmed / partly confirmed / + falsified) with screenshot or quote evidence. +4. **Kenya vs Canada gap analysis**: side-by-side, element by element, with + the conversion consequence of each gap named. +5. **Prioritized recommendations**: ranked by expected impact Γ· effort. For + each β€” what to change, why it converts in this market, effort (content + edit / design / platform), owner, and how to measure it. Separate + "content-only, ship this week" from "needs platform work". +6. **Suggested test plan**: 3–5 A/B or before/after tests with the primary + metric for each. +7. **Appendix**: screenshots, URLs, and dates captured. + +Format for a CMO audience: plain language, tables over prose, no jargon +without a definition. Every factual claim carries a source link or a +screenshot reference. + +## 7. Constraints + +- **Cite or screenshot everything.** Distinguish observed facts from + inference, and say so when something could not be verified. +- **Note the capture date** β€” these pages change. +- Check **mobile rendering explicitly** (emulate a mid-range Android on a + throttled connection). Kenyan traffic is overwhelmingly mobile on metered + data. +- Respect robots.txt and rate limits; normal browsing only, no bulk scraping. +- Do not evaluate the MLM business model itself β€” the scope is website + localization and conversion. +- Recommendations must be implementable by a marketing team on FLP's existing + platform. Flag anything requiring corporate/global approval, since country + sites appear to be centrally controlled. + +## 8. Definition of done + +- All four primary pages plus β‰₯4 pattern markets captured and matrixed. +- Every hypothesis in Β§5 explicitly ruled in or out with evidence. +- Recommendations ranked, each with a measurement plan. +- A reader who has never seen the sites can explain FLP's localization + strategy and what Kenya should change first. diff --git a/docs/forever-living-kenya-competitive-landscape.md b/docs/forever-living-kenya-competitive-landscape.md index 1194f61b..571e19d5 100644 --- a/docs/forever-living-kenya-competitive-landscape.md +++ b/docs/forever-living-kenya-competitive-landscape.md @@ -66,6 +66,35 @@ genuinely innovative, but the company ceased operations in 2024 when venture funding dried up. Agent-network economics must work on unit margins, not subsidy. +## 2b. The Tooling Layer Reps Already Use + +Not competitors to FLP, but competitors to *any official channel Fluid +builds* β€” because reps adopt them unprompted and they set the expectation of +"good enough". + +- **Kyte** (`kyte.site`) β€” Brazilian POS/inventory app built around WhatsApp + selling: free tier, self-signup, storefront at `your-business.kyte.site`, + 60,000+ merchants. An FLP FBO runs + `forever-living-products-7.kyte.site`; the `-7` slug suffix shows at least + six other accounts had already claimed the brand name. Not a corporate + deployment, and no evidence FLP uses Kyte in any market. +- **Comparable tools:** WhatsApp Business app catalogs (free, native), plus + Wati/Zoko/Interakt-class SaaS at the higher end. + +**What this tells us:** + +- **Demand is validated** β€” reps build WhatsApp storefronts without being + asked. +- **The bar is low but real** β€” these tools deliver a catalog link and a + manual chat handoff, quickly and free. Any official product must be at + least as easy to start. +- **The gap is the moat** β€” none of them can bind an order to the rep, + create it in the company's commerce platform so commissions fire, or + auto-reconcile mobile money. That is precisely what an official, + platform-integrated channel adds. +- **For FLP it is shadow commerce** β€” branded storefronts moving product + with no corporate visibility into pricing, claims, or attribution. + ## 3. Cross-Cutting Success Patterns 1. **M-Pesa-native payments** β€” winners meet buyers on mobile money; losers diff --git a/docs/forever-living-kenya-teardown.md b/docs/forever-living-kenya-teardown.md index 44287e32..0e0ef6b0 100644 --- a/docs/forever-living-kenya-teardown.md +++ b/docs/forever-living-kenya-teardown.md @@ -65,6 +65,33 @@ site but by **independent FBO sites**: **Europe-based sponsor** targeting Kenyan sign-ups. - Assorted Kyte/WordPress/Facebook shops. +### Distributors are already DIY-ing WhatsApp storefronts + +The clearest example: `forever-living-products-7.kyte.site` β€” an FBO-run +catalog on **Kyte**, a Brazilian POS/inventory app whose entire pitch is +selling over WhatsApp (free tier, self-signup, storefront published at +`your-business.kyte.site`, order pages and all). + +The `-7` suffix is the tell: Kyte slugs are unique, so at least six other +accounts had already claimed "forever-living-products". This is an +auto-deduplicated free signup by an individual, not a corporate deployment β€” +FLP corporate shows no sign of using Kyte in any market, and would not ship a +numbered slug when Kyte offers custom domains on paid plans. + +Why it matters: + +- **Demand is already proven.** Reps reach for WhatsApp-catalog tools + unprompted. An official channel formalizes existing behavior rather than + creating a new one. +- **It is ungoverned.** These storefronts carry FLP branding with no price + control, no review of health/income claims, and β€” critically β€” **no link to + FLP's compensation plan**, so orders placed through them are invisible to + the platform. +- **It sets the competitive floor.** Kyte-class tools deliver a catalog link + plus a manual WhatsApp handoff. What they structurally cannot do is bind an + order to the rep, create it in the company's commerce platform so + commissions fire, or auto-reconcile mobile money. + **Observations:** - **Brand fragmentation** β€” no consistent design, pricing, or claims across @@ -73,6 +100,8 @@ site but by **independent FBO sites**: corporate .com for Kenya-intent queries. - **Compliance surface** β€” health/income claims on independent sites are hard for corporate to police. +- **Shadow commerce** β€” rep-run WhatsApp storefronts move product with zero + corporate visibility into orders, pricing, or attribution. ## 5. Marketplace / Gray Market diff --git a/docs/mist-handoff-1-fidelity.md b/docs/mist-handoff-1-fidelity.md new file mode 100644 index 00000000..4414443a --- /dev/null +++ b/docs/mist-handoff-1-fidelity.md @@ -0,0 +1,186 @@ +# Mist Handoff 1 of 2 β€” Make the Fluid Clone Match the Live FL Kenya Site + +**Goal:** bring `https://forever-living-kenya.fluid.app/` (our scraped clone) +to visual and structural parity with the live +`https://foreverliving.com/ken/en-ke/home`, so that any later improvement is +measured against a faithful baseline. + +**Do not do improvements in this pass.** Improvements are Handoff 2. This +pass is fidelity only. + +**Critical rule β€” preserve intentional additions.** The clone already +contains deliberate additions that do **not** exist on the live site (for +example a social media section showing creator videos). **Do not delete +them.** Inventory them in an "Intentional Divergence" list and leave them in +place. Only unintentional drift gets corrected. + +--- + +## Phase 0 β€” Set up the comparison + +1. Open both URLs logged out, in a clean profile, cache disabled. +2. Capture full-page screenshots of each at three viewports: + - Mobile 390Γ—844 (primary β€” most Kenyan traffic) + - Tablet 768Γ—1024 + - Desktop 1440Γ—900 +3. Save the live site's rendered DOM (`document.documentElement.outerHTML`) + and the clone's, to a file each. These are your diff sources. +4. Record the **capture date and time** β€” the live site can change under you. +5. If the live site geo-redirects or shows a country/consent interstitial, + note it and capture the post-interstitial state. + +## Phase 1 β€” Build the fidelity inventory + +Produce one table. Rows = every discrete element, top to bottom. Columns = +`Live` / `Clone` / `Status` (Match / Drift / Missing / Extra-Intentional / +Extra-Unintentional) / `Fix action`. + +Walk these in order and log every row: + +**Global chrome** +- Announcement/promo bar (text, link, dismissible?) +- Logo (exact asset, dimensions, link target) +- Nav items in exact order and exact labels; dropdown/mega-menu contents +- Utility nav: search, account/login, cart, country/language selector +- Sticky behavior on scroll +- Footer: every column, every link label and href, social icons, legal + links, copyright line, payment/certification badges + +**Hero** +- Background image or video (exact asset, focal point, overlay opacity) +- Headline, subhead, eyebrow text β€” quote character-for-character +- Primary CTA label + href; secondary CTA label + href +- Height at each viewport; text alignment + +**Every body section, in order** +- Section type (product grid, banner, editorial, testimonial, video, etc.) +- Heading and body copy β€” exact text +- Images (asset, aspect ratio, alt text) +- Product cards: product name, image, price, currency formatting, badge, + CTA label, link target +- Any carousel: slide count, slide order, autoplay, timing, controls + +**Design tokens** +- Colors: sample the actual hex values for background, text, primary + button, link, borders. Do not eyeball β€” use devtools. +- Typography: font families (and whether webfonts actually load in the + clone), weights, sizes, line-heights, letter-spacing for h1/h2/h3/body/ + button/caption +- Spacing rhythm: section padding, grid gutters, container max-width +- Button and card styles: radius, border, shadow, hover state + +**Meta and technical** +- ``, meta description, canonical, OG/Twitter tags, favicon +- `hreflang` tags present on live? replicate the pattern +- Structured data (JSON-LD) blocks +- Analytics/tag scripts on live (note them; do NOT copy tracking IDs into + the clone β€” use our own or none) + +## Phase 2 β€” Fix in dependency order + +Work top-down in this order; each layer depends on the one before it. + +**Step 1 β€” Structure and section order.** Make the clone's section sequence +and section count identical to live (plus intentional additions, which stay +where they are). Fix missing or reordered sections first β€” everything else +is cosmetic until the skeleton is right. + +**Step 2 β€” Navigation and information architecture.** Restore exact nav +labels, order, and dropdown contents. Every nav item must point somewhere +sensible: match live's destination, or map to our clone's equivalent route. +Log any nav item we intentionally cannot support. + +**Step 3 β€” Copy.** Replace all placeholder, truncated, or paraphrased text +with the live site's exact wording, including microcopy (button labels, form +labels, disclaimers, "learn more" links). Preserve live's capitalization and +punctuation. Flag β€” do not silently keep β€” any lorem ipsum or template +filler. + +**Step 4 β€” Assets.** For each image: source the correct asset at the correct +resolution (prefer re-hosting the same visual on our own CDN over +hotlinking). Fix broken/placeholder images, wrong aspect ratios, and missing +alt text. Confirm the logo and favicon are correct, and that retina (2x) +variants exist where live has them. + +**Step 5 β€” Design tokens.** Apply the sampled colors, fonts, sizes, and +spacing. Common failure: the scrape lost the webfont, so the clone renders in +a fallback and everything looks subtly wrong β€” check computed font-family, not +the stylesheet. + +**Step 6 β€” Interactions.** Restore carousels, accordions, dropdowns, hover +states, scroll behaviors, and any modal. Match timing and direction, not just +presence. + +**Step 7 β€” Responsive parity.** Compare at all three viewports. Fix +breakpoint mismatches, overflowing text, images that don't reflow, tap +targets under 44px, and any horizontal scroll on mobile. + +**Step 8 β€” Links and routing.** Click or programmatically resolve **every** +link. Zero 404s, zero `href="#"`, zero links leaking to the live domain +unless intentional. Build a link table: label β†’ clone href β†’ status code. + +**Step 9 β€” Meta and SEO parity.** Set title, description, OG tags, favicon, +and any JSON-LD to mirror live's pattern. + +## Phase 3 β€” Known scrape-artifact checklist + +Scraped clones fail in predictable ways. Explicitly verify each: + +- [ ] Webfonts not loading β†’ fallback typeface +- [ ] Images with absolute paths to the origin domain (hotlinked or broken) +- [ ] CSS background-images lost entirely +- [ ] Icon fonts / inline SVG sprites missing β†’ invisible or box glyphs +- [ ] JavaScript-rendered sections absent (scraper captured pre-hydration) +- [ ] Carousels frozen on slide 1 +- [ ] Forms present but with no action / non-functional +- [ ] Currency or price formatting wrong or hardcoded to another market +- [ ] Product data stale, mispriced, or partially populated +- [ ] Cookie/consent banner and privacy/terms links missing +- [ ] Duplicate or orphaned sections from a partial re-scrape +- [ ] Mixed content (http assets on an https page) +- [ ] `noindex` missing β€” a public clone of a real brand's site should be + `noindex,nofollow` unless there is an explicit decision otherwise +- [ ] Live analytics/pixel IDs accidentally carried over + +## Phase 4 β€” Acceptance criteria + +The pass is done when all of the following are true: + +1. Side-by-side full-page screenshots at 390 / 768 / 1440 show no + *unintended* differences in section order, content, or styling. +2. Every text string in the clone matches live, or is on the Intentional + Divergence list. +3. Every image renders (no broken/placeholder assets) at the right aspect + ratio. +4. Computed font-family, primary color, and button style match live exactly. +5. Zero dead links; link table attached. +6. No horizontal scroll and no clipped content at 390px. +7. Intentional additions (e.g. the creator-video social section) are intact + and documented. +8. Clone is `noindex,nofollow` and carries no live tracking IDs. + +## Phase 5 β€” Deliverable + +Produce a **Fidelity Report** containing: + +1. **Summary**: how close the clone was, and the count of drift items fixed + by category. +2. **Inventory table** from Phase 1 with final statuses. +3. **Before/after screenshots** at all three viewports. +4. **Intentional Divergence list** β€” every deliberate difference, with a + one-line reason, so the next pass knows what is on purpose. +5. **Unresolvable gaps** β€” anything on live we cannot replicate (gated + content, third-party widgets, licensed assets) with the reason. +6. **Link table** and **capture date**. +7. **Changelog** of files/components edited. + +## Constraints + +- Replicating a competitor's/client's page layout for internal prototyping is + fine; do not present the clone publicly as Forever Living's own site. Keep + it `noindex`, and keep any "demo/prototype" labeling that already exists. +- Do not copy live analytics IDs, tracking pixels, or third-party keys. +- Prefer re-hosting assets over hotlinking the origin. +- Respect robots.txt and normal request rates; this is manual-scale browsing, + not bulk scraping. +- Note capture dates on every screenshot; the live site changes. diff --git a/docs/mist-handoff-2-improvements.md b/docs/mist-handoff-2-improvements.md new file mode 100644 index 00000000..505162d3 --- /dev/null +++ b/docs/mist-handoff-2-improvements.md @@ -0,0 +1,299 @@ +# Mist Handoff 2 of 2 β€” Localization Audit + Conversion Tweaks for FL Kenya + +**Prerequisite:** Handoff 1 (fidelity pass) is complete, so the clone at +`https://forever-living-kenya.fluid.app/` faithfully mirrors +`https://foreverliving.com/ken/en-ke/home`. + +**Goal:** audit how Forever Living localizes its country sites, then apply +**targeted, additive tweaks** to the Kenya page that raise conversion for the +Kenyan market. + +## The governing constraint: tweak, don't rebuild + +This is **not** a redesign. Keep Forever Living's brand, visual language, +page skeleton, and section order. Improvements must take one of two forms: + +- **Type A β€” In-place edit** of an existing element (add a badge, add price + to an existing card, change a CTA label, swap one image for a locally + relevant one). +- **Type B β€” Additive band**: a new self-contained horizontal section + inserted between existing sections, styled with the site's existing tokens. + +**The model to follow is the creator-video social section already added to +the clone.** That is exactly the right pattern: a self-contained band that +adds local energy and proof without disturbing anything around it. Every +recommendation should be expressible as "insert a band here" or "edit this +element," never "restructure the page." + +Anything that would require altering FL's global template, navigation, or +brand system goes in a separate **"Needs corporate approval"** list rather +than being built. + +--- + +## Part 1 β€” Localization audit + +### Objective + +Answer with evidence: + +1. Does FLP localize country sites at all, or ship one global template? +2. Which elements get localized, in which markets, and to what depth? +3. How localized is Kenya compared with a peer market that does it well? + +### Business context you need + +- FLP is a direct-selling/MLM company (aloe vera drinks, supplements, bee + products, personal care) operating in 160+ countries. +- **Kenya**: established 2005; Nairobi office (Reinsurance Plaza, 4th Floor, + Taifa Road, CBD) also serves as the East Africa hub for Ethiopia, Somalia, + South Sudan, Rwanda, and Uganda. +- Kenya ordering today is **manual**: register as a Preferred Customer, then + order by WhatsApp/phone message (name + national ID), paying by M-Pesa + transfer or on pickup. There is **no integrated mobile-money checkout** on + the official store. +- Reference price: Forever Aloe Vera Gel β‰ˆ **KSh 3,291** officially, while + third-party Jumia sellers list the same product at **KSh 2,280–3,999** β€” + active gray-market price erosion and an authenticity problem. +- **Benchmark market = Canada**, not the USA: FLP is transitioning away from + its sponsorship-based model in the U.S. (effective May 1, 2026), so Canada + is the closest market whose homepage still does *both* jobs Kenya needs β€” + retail conversion **and** FBO (Forever Business Owner) recruiting. Treat + the **USA** site as the flagship/original template. +- **TikTok Shop does not exist in Kenya or anywhere in Africa**, so social + traffic cannot convert in-app β€” it must land on web or WhatsApp. This is + why the creator-video band matters and why it needs a clear next step. + +### Pages to audit + +Primary comparison: + +- Kenya: https://foreverliving.com/ken/en-ke/home +- Canada (English): https://foreverliving.com/can/en-ca/home +- Canada (French): https://foreverliving.com/can/fr-ca/home +- USA (template baseline): https://foreverliving.com/usa/en-us/home + +Pattern evidence (sample at least four): + +- France: https://foreverliving.com/fra/fr-fr/home +- India: https://foreverliving.com/ind/en-us/welcome +- UK, South Africa, Nigeria, Japan, Mexico, Brazil β€” find exact locale paths + via the site's country/region selector and record the pattern. + +Supporting surfaces: + +- Legacy shop per market: `https://shop.foreverliving.com/retail/entry/Shop.do?store=KEN` + (swap KEN / CAN / USA) β€” capture currency, categories, checkout options. +- Kenya contact page vs the Canada equivalent. +- Whether any market has a modern self-service checkout vs the legacy store. + +### What to record per page + +URL locale pattern Β· languages offered and translation depth Β· hero +headline/subhead/image subject/CTA Β· secondary CTAs (shop, join, find a +distributor) Β· currency and whether prices appear on the homepage Β· featured +SKUs Β· payment methods surfaced anywhere Β· local trust signals (address, +phone, WhatsApp, delivery/pickup promises) Β· local social proof (faces, +testimonials, named FBOs, events) Β· recruiting prominence and income-claim +framing Β· market-specific legal/compliance terms Β· section order Β· page +weight and mobile rendering Β· whether the same image assets recur across +country sites (reverse-image or CDN-path comparison). + +Also check **Kenya SEO/discovery**: search "Forever Living Kenya", "forever +aloe vera gel price Kenya", "how to join Forever Living Kenya" β€” record +whether corporate or third-party/distributor sites rank. Note `hreflang` +implementation across locales. + +### Hypotheses to verify or falsify + +These come from indexed metadata and secondary sources, **not** from viewing +the live pages (the analyst's environment could not reach them). Confirm each +with a screenshot or quote, and state plainly when one is wrong β€” a wrong +premise changes the recommendations. + +- **H1 β€” One global template, tiered localization.** All markets share the + same platform, structure, and "The Aloe Vera Company" identity at + `foreverliving.com/{country}/{locale}/home`. Kenya's page title renders as + "The Aloe Vera Company (Kenya)", Canada's as "The Aloe Vera Company (CA)". +- **H2 β€” Localization tiers exist.** Tier 1 (mature markets) get real + language localization: Canada has both `en-ca` and `fr-ca`; France has + `fr-fr`. Tier 2 (emerging markets) get the English template plus a currency + swap β€” India's locale path is literally `ind/en-us`, the strongest single + piece of evidence if confirmed. +- **H3 β€” What IS localized:** language (Tier 1 only), currency and product + catalog in the shop layer, local contact details, and legally required + business terms (e.g. the UK's Β£200 first-week FBO purchase cap). +- **H4 β€” What is NOT localized:** imagery (global campaign assets reused + everywhere), core messaging and taglines, page structure, payment UX, and + local social proof. +- **H5 β€” Kenya is Tier 2.** English only (no Swahili), KSh pricing confined + to the legacy shop, a local contact page β€” and no local imagery, no M-Pesa + presence on site, no Kenyan testimonials, no locally framed FBO pitch. +- **H6 β€” The conversion gap is payment and trust, not traffic.** The site + never surfaces the payment rail (M-Pesa) or the channel (WhatsApp) Kenyan + buyers actually use, while marketplace resellers offer mobile money and + cash on delivery. + +--- + +## Part 2 β€” Tweaks to build on the clone + +Implement in this order. Each item states type, placement, and how to measure +it. Ship Type A edits and cheap bands first. + +### Priority 1 β€” Surface the payment rail and channel (Type A, hero) + +Add to the hero, without changing the hero image or headline: a **"Lipa na +M-Pesa"** badge and an **"Order on WhatsApp"** secondary button (a `wa.me` +deep link). Kenyans convert where their money already lives; today the site +hides its only local payment path behind a contact page. +*Measure:* clicks on the WhatsApp CTA; assisted orders attributed to it. + +### Priority 2 β€” Show KSh prices on the homepage (Type A, product cards) + +Put real prices on existing product cards ("Forever Aloe Vera Gel β€” +KSh 3,291"). This anchors legitimacy against the KSh 2,280–3,999 Jumia +spread and pre-qualifies clicks before the clunky legacy store. +*Measure:* product card CTR; bounce rate on the shop handoff. + +### Priority 3 β€” Replace the "Aloe as Nature Intended" graphic + +**Why it must change:** it is a decorative watercolor aloe with bold black +text set *on top of* the leaves β€” the type collides with the illustration and +hurts legibility. It carries no proof, no price, and no call to action, and it +consumes prime vertical space that pushes real content below the fold. It is +the single lowest-yield block on the page. + +Replace it with one of the following, keeping the same slot and roughly the +same height. **Recommended: Option A**, because it converts *and* answers the +question every first-time Kenyan buyer actually has. + +- **Option A (recommended) β€” "How to order in 3 steps" strip.** Three + numbered icon-and-label steps: *1. Message us on WhatsApp β†’ 2. Pay with + M-Pesa β†’ 3. Collect in Nairobi CBD or get countrywide delivery.* Turns dead + decoration into the conversion path. Ends with the WhatsApp CTA. +- **Option B β€” Authenticity / anti-counterfeit band.** "Buy genuine Forever." + Short proof points: 99.7% pure inner-leaf aloe, IASC-certified for purity + and potency, sold only through registered FBOs and the Nairobi office, with + a "verify your FBO" link. Directly attacks the gray-market problem. +- **Option C β€” Shoppable product spotlight.** The real Aloe Vera Gel bottle + photograph, one-line benefit, KSh 3,291, and an order button. Converts the + slot from illustration into a merchandising unit. +- **Option D β€” Kenyan proof band.** Two or three photo testimonials with + first names and counties. Highest trust lift; requires sourcing real + content and permission. + +Whichever is chosen: keep the aloe-provenance *story* if it is on-brand, but +express it as legible proof points beside an image, never as text overlapping +an illustration. Never place body copy on top of a busy graphic. + +### Priority 4 β€” Extend the creator-video social band (Type B, already added) + +The band exists; make it earn its place. Each video tile needs **a next +step** β€” because TikTok Shop is unavailable in the region, the video itself +cannot convert. Add per-tile "Shop this product" or "Order on WhatsApp" +links, show the creator's handle for credibility, prefer Kenyan creators, and +lazy-load posters (see Priority 8). Consider a "Featured FBO of the month" +tile to double as recruiting. +*Measure:* video engagement β†’ outbound click rate to shop/WhatsApp. + +### Priority 5 β€” Localize the imagery (Type A) + +Swap two images β€” ideally the hero and one section image β€” for photography of +Kenyan FBOs and customers. Global stock imagery reads as "not really here" and +undermines a trust purchase. If new photography is not available, source from +the local office's event and social archives with permission. +*Measure:* scroll depth past the hero; hero CTA CTR. + +### Priority 6 β€” Make the FBO opportunity locally concrete (Type A/B) + +Canada keeps a visible recruiting CTA; Kenya should lead with it. Frame it +locally: side-hustle language, earnings and thresholds expressed in KSh, +"free to register," and rep-attributed join links so the FBO who drove the +visit gets credit. +*Measure:* join-page starts and completions; share of signups carrying a rep +attribution. + +### Priority 7 β€” Trust anchors in header and footer (Type A) + +Add the Nairobi office address, the WhatsApp order number, and a +"collect in Nairobi CBD or countrywide delivery" line. Counters both +online-fraud wariness and counterfeit fear, and costs nothing but content. + +### Priority 8 β€” Mobile weight and speed (technical) + +Kenyan traffic is overwhelmingly mobile on metered data bundles. Compress +hero media, serve WebP/AVIF with correct `srcset`, lazy-load below-fold +imagery and video posters, and remove autoplay media. Target LCP under 2.5s +on a throttled 4G mid-range Android. +*Measure:* PageSpeed/Core Web Vitals before vs after; mobile bounce rate. + +### Priority 9 β€” A Swahili gesture now, a `sw-ke` locale later (Type A) + +A greeting such as "Karibu" and a few Swahili accents in headings cost +nothing and signal presence. A full `sw-ke` locale is a corporate ask β€” note +that Canada's dual `en-ca`/`fr-ca` setup proves the platform already supports +it. + +### Priority 10 β€” SEO defense (technical/content) + +Distributor micro-sites and review blogs currently outrank corporate for +"Forever Living Kenya" queries. Add localized homepage copy targeting +product-plus-KSh queries, plus Product and FAQ structured data, and correct +`hreflang`. +*Measure:* rankings for the three query sets above; organic entry sessions. + +--- + +## Part 3 β€” Deliverable + +One document containing: + +1. **Executive summary** (≀200 words): FLP's localization strategy, Kenya's + tier, and the top three fixes. +2. **Localization matrix**: markets Γ— recorded fields. +3. **Hypothesis findings**: H1–H6 each marked confirmed / partly confirmed / + falsified, with screenshot or quote evidence. +4. **Kenya vs Canada gap analysis**: element by element, each gap paired with + its conversion consequence. +5. **Implemented tweaks**: what was changed on the clone, before/after + screenshots at 390 / 768 / 1440, and which Type (A or B) each was. +6. **"Needs corporate approval" list**: anything requiring changes to FL's + global template, brand system, navigation, or a new locale. +7. **Test plan**: 3–5 A/B or before/after tests with the primary metric for + each, in priority order. +8. **Appendix**: URLs, capture dates, screenshots. + +Write for a CMO audience: plain language, tables over prose, no jargon +without a definition. Every factual claim carries a source link or screenshot +reference. + +## Constraints + +- Cite or screenshot everything; separate observed fact from inference, and + say when something could not be verified. +- Note capture dates β€” these pages change. +- **Preserve the brand.** Use FL's existing colors, type, and components. New + bands must look native to the page, not bolted on. +- **Additive only.** Do not remove or reorder FL's existing sections; the one + intended removal is the "Aloe as Nature Intended" graphic, which is + *replaced* in its own slot. +- Test mobile explicitly (mid-range Android, throttled connection). +- No health claims and no income claims. Wellness copy must stay compliant, + and FBO earnings framing must carry appropriate disclaimers. +- Use only imagery we have rights to; get permission for creator videos and + customer testimonials before publishing. +- Keep the clone `noindex,nofollow` and do not present it as Forever Living's + official site. +- Recommendations must be implementable by a marketing team on the existing + platform; flag anything else rather than building it. + +## Definition of done + +- H1–H6 each explicitly ruled in or out with evidence. +- Priorities 1–3 implemented on the clone (payment/channel surfacing, KSh + prices, aloe graphic replaced) with before/after screenshots. +- Every implemented tweak is Type A or Type B β€” no structural redesign. +- Each recommendation carries a measurement plan. +- A reader who has never seen the sites can explain FLP's localization + strategy and what Kenya should change first. diff --git a/docs/whatsapp-commerce-competitive-playbook.md b/docs/whatsapp-commerce-competitive-playbook.md new file mode 100644 index 00000000..f4a27eb3 --- /dev/null +++ b/docs/whatsapp-commerce-competitive-playbook.md @@ -0,0 +1,203 @@ +# WhatsApp Commerce β€” Who's Best in the World, and What to Copy + +Research brief for building Fluid's WhatsApp connection to be **high quality, +easiest to use, easiest to deploy, and highest converting**. Reviewed August +2026. + +Companions: [spec](./whatsapp-commerce-spec.md) Β· +[Tech Provider strategy](./whatsapp-tech-provider-strategy.md) Β· +[FL Kenya teardown](./forever-living-kenya-teardown.md) + +--- + +## 1. The market has four distinct layers + +Knowing which layer a company competes in prevents bad comparisons. + +**Layer 1 β€” Infrastructure / BSPs.** Sell API access, numbers, and +deliverability. **Gupshup** (50,000+ customers in 130+ countries, +120B+ messages/year), **Infobip**, **Twilio**, **Sinch**, **Clickatell**, +**360dialog** (cleanest markup), **Take Blip** (LatAm's largest, backed by +SoftBank and Microsoft). Fluid is a *consumer* of this layer in Stage 2, then +bypasses it as a Tech Provider. + +**Layer 2 β€” Enterprise conversational commerce.** Sell outcomes to big +brands. **Yalo**, **Haptik**, **charles**, Gupshup's commerce arm. *This is +Fluid's layer.* + +**Layer 3 β€” D2C / Shopify SaaS.** Self-serve tools for online brands: +**Zoko** (3,000+ DTC brands, 70+ countries), **Wati**, **Interakt**, +**AiSensy**, **BIK**, **DelightChat**, **Chatarmin**, **Spur**. Feature-rich, +strong benchmarks, no attribution model for salesforces. + +**Layer 4 β€” SMB self-serve catalogs.** **Kyte**, and WhatsApp Business app's +native catalog. Free, instant, everywhere β€” this is what individual reps use +today and it sets the "time to first value" bar. + +## 2. The three to study hardest + +### Yalo β€” the closest structural analog +AI-driven conversational commerce for **B2B CPG in Latin America**: NestlΓ©, +Unilever, Coca-Cola FEMSA selling to millions of corner-store owners over +WhatsApp. Why it maps to Fluid almost one-to-one: a brand serving a large +network of small, non-technical buyers in emerging markets, replacing a +manual sales-rep-driven order process. Their thesis β€” *in emerging markets +consumers spend 84% of screen time in messaging apps* β€” is the same bet. + +Lessons to steal: +- They started as a conversation/workflow builder and **added native commerce + later**, after repeatedly integrating commerce platforms by hand for + clients. We are skipping straight to native commerce β€” the right call. +- **Personalized recommendations by micro-segment** (Grupo Mariposa case: + store owners get order suggestions learned from their own neighbourhood + rather than depending on a rep's memory). The direct-selling analogue is + replenishment suggestions per customer and per rep's downline. +- Sell to the *brand*, deliver value to the *network*. Fluid's buyer is the + company; the daily user is the rep. + +### Haptik β€” the end-to-end consumer benchmark +Built **JioMart on WhatsApp** (Reliance-owned; the first true end-to-end +WhatsApp shopping experience β€” browse full catalog, cart, pay, all in chat). +Clients include KFC, Whirlpool, HP, Disney Hotstar. Study it for what +"complete" looks like: catalog search, pincode/serviceability checks, order +tracking in-chat, personalized recommendations. **Caveat we learned the hard +way:** JioMart gates on an Indian delivery pincode, so a non-Indian tester +hits a dead end β€” a reminder to design graceful failure for out-of-market +users. + +### charles β€” the ease-of-deployment benchmark +EU conversational-commerce/CRM platform for D2C brands. Notable for +**onboarding customers within one day**, with CSM-driven setup of popups, +welcome flows, and integrations from out-of-the-box components. If Fluid's +target is "connect WhatsApp in under 15 minutes," charles is the service +model to beat. + +## 3. Conversion mechanics that actually move numbers + +Benchmarks to design against and to use in the pitch: + +| Mechanic | Reported impact | +|---|---| +| **In-chat checkout** (no redirect) | ~35% higher conversion, ~23% fewer abandoned carts | +| **Abandoned-cart recovery via WhatsApp** | 18–23% recovery on optimized flows (good range 10–30%, best cases ~40%); 4x ROI vs email | +| **Automated flows** (cart, post-purchase, back-in-stock) | 60–70% of all WhatsApp revenue | +| **Fast recovery timing** | 18–25% of abandoned sessions convert within 30 minutes | +| **Click-to-WhatsApp ads** | CTR 15–25%; cost per conversation ~€1.50–8.00 | +| **Conversation hooks** ("Chat with us") vs site hooks ("Shop now") | 15–30% lift in conversion-to-conversation | +| **Channel baseline** | Open rates ~4x email; conversion ~2x SMS | +| **Response speed** | Sub-60-second replies compress decisions from hours to minutes | + +Design rules that follow directly: + +1. **Never redirect to pay.** The no-redirect checkout is the single largest + documented lift. For Kenya this means M-Pesa STK push in-chat (built), not + a link to a web checkout. +2. **Automations are the product, not a feature.** If automated flows drive + 60–70% of revenue, then cart recovery, order status, and replenishment + nudges are roadmap-critical, not phase 3 nice-to-haves. +3. **Recover within 30 minutes**, then follow the proven three-message / + 72-hour arc: reminder β†’ trust β†’ incentive. +4. **Answer in under a minute**, always β€” automated first response, human + handoff after. +5. **Opt-in has a floor:** below roughly 200 monthly opt-ins the automation + effort doesn't pay back. Rep-shared links are Fluid's opt-in engine, which + is a structural advantage over brands buying ads. + +## 4. Ease of deployment β€” the real battleground + +Observed onboarding times: **Wati ~30 minutes** guided, genuinely no-code; +**charles ~1 day** with a CSM; **Zoko** repeatedly criticized for +**onboarding friction, pricing escalation, and leaking WhatsApp Business API +complexity to the user**; **Kyte** is effectively instant and free. + +The lesson is unambiguous: **the winner in this category is whoever hides +Meta's complexity best.** Every platform that makes the customer understand +WABAs, phone number registration, template approval, or the 24-hour window +gets punished in reviews. + +Fluid's targets: +- **Company onboarding < 15 minutes**, zero Meta console visits (embedded + signup does number + WABA + catalog). +- **Rep onboarding < 60 seconds** β€” they copy their link. That is the whole + step. +- **Templates pre-built and pre-submitted** per vertical, so no client ever + writes one from scratch. +- **No jargon in the UI.** Never surface "phone_number_id" or "template + category" to a client. + +## 5. What nobody in this market does β€” Fluid's moat + +Every platform above optimizes brandβ†’consumer. **None of them model a +salesforce.** Specifically missing across Layers 2–4: + +1. **Rep attribution** β€” binding a conversation and its orders to the rep who + sourced it (our `shareGuid` deep link + session binding). +2. **Compensation-plan integration** β€” orders landing in the company's + commerce platform so commissions, ranks, and volume actually fire + (`FluidService`). +3. **Automatic mobile-money reconciliation** β€” STK push tied to an order + record, settled by callback, no human matching payments. +4. **Multi-tenant per-company provisioning** from one platform account + (`whatsapp_configs` + Tech Provider embedded signup). +5. **Downline-aware governance** β€” approved templates and compliant copy + pushed to thousands of reps, replacing the ungoverned Kyte/WordPress + shadow storefronts documented in the teardown. +6. **Compliance guardrails for direct selling** β€” blocking health and income + claims at the template level. This is an MLM-specific requirement no + generic tool addresses, and a genuine risk-reduction sale to corporate. + +Positioning: *Kyte sells a rep a storefront. Zoko sells a brand a chat +channel. Fluid sells a direct-selling company an attributed commerce channel +its whole field force can run.* + +## 6. Capability checklist β€” where our build stands + +| Capability | Best-in-class | Fluid today | Priority | +|---|---|---|---| +| Native catalog + cart in chat | Table stakes | βœ… built | β€” | +| No-redirect payment | JioMart, Flows+PSP | βœ… M-Pesa STK | β€” | +| Order β†’ commerce platform | Yalo | βœ… FluidService | Confirm API shapes | +| Rep attribution | **nobody** | βœ… deep link + session | Extend to dashboards | +| Multi-tenant provisioning | BSP-grade | βœ… schema, manual | Embedded signup | +| Abandoned-cart recovery | 18–23% recovery | ❌ | **P1 β€” highest ROI gap** | +| Order-status / delivery updates | Table stakes | ❌ | P1 | +| Replenishment nudges | Yalo micro-segments | ❌ | P2 (28-day gel cycle) | +| Shared inbox / agent handoff | Wati, charles | ❌ | P2 | +| WhatsApp Flows (multi-screen) | charles, Haptik | ❌ | P2 (address capture) | +| Template manager | All Layer 3 | ❌ | P2 (needed for P1 flows) | +| Click-to-WhatsApp ads support | Layer 3 | Partial (referral parsing) | P3 | +| Analytics / attribution dashboard | All | ❌ | P2 (rep-facing) | +| Recommendations engine | Yalo | ❌ | P3 | + +**Biggest single gap:** automated flows. Industry data says they generate +60–70% of WhatsApp revenue, and we have none. Cart recovery plus order-status +updates should jump ahead of most of the phase-2 list β€” and both require the +template manager, which makes it the real critical path. + +## 7. Ten things to copy, in order + +1. In-chat checkout with no redirect (done β€” protect it). +2. Abandoned-cart recovery, first message inside 30 minutes. +3. Order-status and delivery updates as approved templates. +4. Onboarding that never shows a client the Meta console. +5. Pre-built, pre-approved template library per vertical. +6. Sub-60-second automated first response, then human handoff. +7. Replenishment cadence per product (aloe gel β‰ˆ 28 days). +8. Conversation-hook CTAs everywhere ("Chat to order"), not "Shop now". +9. Graceful out-of-market handling (the JioMart pincode trap). +10. Rep-facing analytics β€” because the rep is the daily user, and Yalo's + lesson is that you win by making the network more effective, not just the + brand. + +## 8. Sources + +- Yalo: [case study](https://medium.com/@chrishedge/conversational-workflow-builder-yalo-expands-into-native-commerce-to-help-cpgs-better-engage-49ecca1bee14) Β· [B Capital](https://b.capital/why-we-invested/why-we-invested-yalochat/) Β· [McKinsey β€” Grupo Mariposa](https://www.mckinsey.com/capabilities/tech-and-ai/how-we-help-clients/rewired-in-action/grupo-mariposa-harnessing-connected-technology-in-the-latam-food-and-beverage-market) +- Haptik: [JioMart case study](https://www.haptik.ai/resources/case-study/jio-mart) Β· [Meta announcement](https://about.fb.com/news/2022/08/shop-on-whatsapp-with-jiomart-in-india/) +- charles: [site](https://www.hello-charles.com/) Β· [G2 reviews](https://www.g2.com/products/charles/reviews) +- Gupshup: [conversational commerce](https://www.gupshup.io/en/customer-engagement/conversational-commerce) +- Take Blip: [SoftBank/Microsoft backing](https://techcrunch.com/2024/11/18/text-marketing-firm-blip-secures-backing-from-softbank-and-microsoft/) +- Zoko: [platform](https://www.zoko.io/post/conversational-commerce-platforms-benefits-leading-companies) Β· [review incl. criticism](https://respond.io/blog/zoko-review) +- Kyte: [digital catalog](https://www.kyteapp.com/engaging/digital-catalog) Β· [WhatsApp sales](https://www.kyteapp.com/selling/whatsapp-sales) +- Benchmarks: [Chatarmin cart recovery 18–23%](https://chatarmin.com/en/blog/how-to-recover-abandoned-carts-via-whatsapp) Β· [Flowcart checkout flows](https://www.flowcart.ai/blog/whatsapp-checkout-cart-flows) Β· [Kanal CTWA benchmarks](https://getkanal.com/blog/click-to-whatsapp-ads-benchmarks-2026) Β· [Kanal KPIs](https://getkanal.com/blog/whatsapp-marketing-roi-kpis-benchmarks) Β· [Kanal WhatsApp vs email](https://getkanal.com/blog/whatsapp-vs-email-abandoned-cart-recovery) +- Opt-in rules: [Meta β€” getting opt-in](https://developers.facebook.com/documentation/business-messaging/whatsapp/getting-opt-in) Β· [Blueticks opt-in practices](https://blueticks.co/blog/whatsapp-opt-in-best-practices) +- Flows governance: [8x8 best practices](https://developer.8x8.com/connect/docs/whatsapp/whatsapp-flows-best-practices/) diff --git a/docs/whatsapp-commerce-spec.md b/docs/whatsapp-commerce-spec.md index 92e111c3..44ac8f39 100644 --- a/docs/whatsapp-commerce-spec.md +++ b/docs/whatsapp-commerce-spec.md @@ -74,9 +74,32 @@ All of this compiles today (TypeScript clean, Prisma schema valid): - **`backend/src/routes/mpesa.ts`** β€” the payment callback. Settles the payment record, flips the order to paid, and sends the in-chat receipt (or a "reply *pay* to retry" notice on failure). -- **Two new Prisma models** β€” `WhatsAppSession` (conversation state + rep - binding per customer phone) and `MpesaPayment` (one row per STK push, - keyed by Daraja's CheckoutRequestID, raw callback kept for audit). +- **Prisma models** β€” `WhatsAppSession` (conversation state + rep binding per + customer phone), `MpesaPayment` (one row per STK push, keyed by Daraja's + CheckoutRequestID, raw callback kept for audit), `WhatsAppConfig` + (per-tenant credentials), `MessageTemplate`, and `CartRecovery`. +- **`backend/src/services/templateService.ts`** β€” the template manager. Ships + a pre-built, compliance-reviewed library (cart recovery Γ—3, receipt, order + status, payment failed), submits it to Meta on the client's behalf, and + polls approval status. Clients never open the Meta console. +- **`backend/src/services/cartRecoveryService.ts`** β€” abandoned-cart recovery + on a 30-minute / 24-hour / 72-hour arc, plus `reply pay` retry that re-fires + the STK push. Industry benchmark for this flow is 18–23% recovery, and + automated flows drive 60–70% of WhatsApp revenue. +- **`backend/src/routes/templates.ts`** and **`routes/jobs.ts`** β€” template + seed/submit/sync endpoints and the recovery runner + (`POST /api/jobs/cart-recovery`, secret-protected, cron-friendly, with an + optional in-process interval for demos). + +### The 24-hour window, handled correctly + +Free-form messages are only legal within 24 hours of the customer's last +inbound message. The recovery service checks `whatsapp_sessions.lastMessageAt` +and picks its channel accordingly: **inside** the window it sends plain text; +**outside** it requires an APPROVED template and **skips the send rather than +attempting a non-compliant one**. That dependency is why the template manager +had to land before recovery could work β€” it is the critical path, not the +conversation state machine. ### Rep attribution, the key MLM detail diff --git a/docs/whatsapp-cx-strategy-handoff.md b/docs/whatsapp-cx-strategy-handoff.md new file mode 100644 index 00000000..fbe9ae2f --- /dev/null +++ b/docs/whatsapp-cx-strategy-handoff.md @@ -0,0 +1,194 @@ +# Handoff β€” WhatsApp Commerce Client Experience Strategy + +This doc hands off the next phase of the WhatsApp commerce work to a fresh +session. **Section 1 is the prompt to paste.** Section 2 is the context brief +behind it β€” paste it too if the session can't read this repo, otherwise the +prompt tells the agent where everything lives. + +--- + +## 1. The prompt (copy-paste this) + +``` +You are my product strategist and CX architect for Fluid, a commerce platform +for direct-selling / MLM companies. I need a client-experience strategy and a +repeatable deployment playbook for WhatsApp commerce. + +## What is already true (do not re-litigate or re-design this) + +- Mike Tingey is building Fluid's integration with the Meta Commerce Manager + API. Assume it ships complete: product sync, pricing, images, inventory, + and every core Commerce API capability. Do NOT spend effort on integration + engineering, API mechanics, or webhook plumbing β€” that layer is Mike's. +- Your scope is everything AFTER the integration exists: the experience we + put in front of clients, their reps, and their customers β€” and the + step-by-step process WE follow to deploy it for each client. + +## Read these first (in this repo, under docs/) + +- whatsapp-commerce-competitive-playbook.md β€” our prior market map (four + layers), deep-dives on Yalo / Haptik / charles, conversion benchmarks, + ease-of-deployment bar, Fluid's moat (nobody models a salesforce), and a + capability gap table. Treat it as the starting hypothesis, not the answer. +- whatsapp-commerce-spec.md β€” what we already scaffolded (catalog β†’ cart β†’ + M-Pesa STK push β†’ receipt, template manager, cart recovery, rep + attribution via wa.me deep links). +- forever-living-kenya-teardown.md β€” the anchor client case: manual WhatsApp + ordering today, gray market, shadow commerce (reps DIY-ing Kyte + storefronts), and the TikTok finding: TikTok Shop is NOT available in + Kenya or anywhere in Africa, so social traffic must convert in chat. +- forever-living-kenya-competitive-landscape.md β€” Kenya market patterns + incl. "social-first, chat-close" and the rep tooling layer (Kyte). +- whatsapp-tech-provider-strategy.md β€” our 3-stage Meta path (demo β†’ BSP + pilot β†’ Tech Provider). Your plan must fit inside it, not replace it. +- whatsapp-demo-runbook.md β€” how we demo today, incl. Meta account-integrity + rules we must not violate. + +## Task 1 β€” Deep competitive research (fresh, not just the playbook) + +Research the best WhatsApp commerce providers in the world with live web +sources. Cover at minimum, and add anyone I'm missing: + +- Enterprise conversational commerce: Yalo, Haptik (Jio), Infobip, Gupshup, + Twilio (incl. Segment tie-ins), LivePerson +- Mid-market/SMB SaaS: charles, Wati, SleekFlow, Interakt, Zoko, Spur, + Rasayel, Trengo, respond.io, Chatfuel, DelightChat +- Rep/seller-layer tools our clients' distributors already reach for on + their own: Kyte, Take App, WhatsApp Business app catalogs +- Adjacent proof points: JioMart's WhatsApp ordering (including its + out-of-market failure mode β€” a US user hits a pincode wall and + dead-ends), Meta's own commerce features roadmap (Flows, in-chat + payments in India/Brazil/Singapore) + +For each: what their onboarding feels like (time-to-first-sale), what their +customer-facing chat experience does that converts (catalog UX, cart, +checkout, recovery, re-order, order status), what they charge, what their +clients complain about (read reviews/G2/Reddit β€” complaints are the product +roadmap), and what is genuinely best-of-breed vs. marketing claims. Cite +sources. Where a number can't be verified, label it as unverified rather +than asserting it. + +## Task 2 β€” The Fluid strategy (synthesis, not summary) + +Write the strategy for Fluid's client-facing WhatsApp commerce experience, +grounded in Task 1. It must answer: + +1. The customer journey we ship by default: entry points (rep link, + click-to-WhatsApp ads, QR at events, social bio links), browse β†’ cart β†’ + pay β†’ receipt β†’ order status β†’ replenishment/re-order β†’ win-back. Where + do we match best-of-breed, and where do we deliberately do better? +2. The rep experience β€” this is our moat; nobody else models a salesforce. + Rep link generation and sharing, attribution the rep can trust, what the + rep sees (their orders, their conversion), and what the rep must STOP + doing (hand-quoting prices, collecting ID numbers in chat, health/income + claims). Compliance guardrails are a feature we sell, not friction. +3. The client admin experience: connect WABA β†’ sync catalog (Mike's layer) + β†’ approve template pack β†’ set payment rails β†’ invite reps β†’ go live. + Benchmark: charles-level ease. Target a defensible "time to first live + order" number and defend it. +4. Messaging/automation defaults we ship on day one: template library, + cart-recovery cadence, order status, replenishment timing (e.g. 28-day + consumable cycles), opt-out handling, 24-hour-window compliance, and + quality-rating protection (what we auto-throttle when Meta's quality + score drops). +5. Payments by market: M-Pesa-first for East Africa, then the sequencing + for other rails (cards/PSPs, Pix, UPI, Meta native payments where they + exist). Payment rail availability should drive market sequencing. +6. Pricing/packaging POV: how competitors price (per-seat, per-conversation, + % of GMV, markup on Meta fees) and what fits Fluid's model. + +## Task 3 β€” The deployment playbook (skill-ready) + +Turn the strategy into a step-by-step playbook I can execute per client β€” +written so it can later become an automated skill. Structure it as: + +- Inputs collected from the client (a literal intake checklist) +- Phase-by-phase steps with owner (Fluid / client / Mike's integration), + duration, exit criteria, and verification ("how we know this step worked") +- The template pack to submit per vertical (supplements/wellness needs + claims-safe copy β€” no health claims, no income claims) +- Launch sequence: pilot rep cohort β†’ measure β†’ widen +- The metrics reviewed weekly (client-facing dashboard list + our internal + health metrics), with target ranges taken from Task 1 benchmarks +- Failure modes and their runbooks (template rejected, quality rating drops, + payment callback failures, rep link misuse) + +## Task 4 β€” Gap list + +Everything the strategy needs that doesn't exist yet, split into: (a) asks +for Mike's integration layer, (b) Fluid product/engineering asks, (c) ops +and content asks (template copy, training materials). Prioritized. + +## Hard constraints + +- No health claims, no income claims, anywhere β€” templates, site copy, + training examples. +- Respect Meta platform rules absolutely: approved templates outside the + 24-hour window, no workarounds, no new Meta accounts to sidestep the + restricted "Fluid Demo" portfolio (recovery = appeal, Fluid's verified + corporate portfolio, or a BSP). +- TikTok Shop does not exist in Africa β€” social traffic converts in chat or + not at all. Design for that. +- Handle out-of-market visitors gracefully (the JioMart pincode trap). + +## Output + +Four markdown deliverables matching the four tasks, in docs/: +1. whatsapp-cx-competitor-research.md +2. whatsapp-cx-strategy.md +3. whatsapp-deployment-playbook.md +4. whatsapp-cx-gap-list.md + +Research with live web sources and cite them inline. Depth over speed. +``` + +--- + +## 2. Context brief (paste only if the new session can't read this repo) + +**Fluid** is a droplet platform for direct-selling companies (Fastify + +Prisma backend, React frontend, DIT token auth). The anchor case study is +**Forever Living Kenya**: orders today happen by free-text WhatsApp message +(product names + national ID number), manual M-Pesa transfer, manual +reconciliation, office pickup, and rep commission only if the customer +remembers to name their rep. + +**What we already built** (PR #3 on `claude/forever-living-kenya-docs-ic4l7x`): +a working scaffold where a customer taps a rep's `wa.me` link, browses a +native WhatsApp catalog, submits a cart, gets an M-Pesa STK push, and +receives a receipt in chat β€” with rep attribution bound first-touch to the +session. Plus a template manager (six compliance-reviewed UTILITY templates, +submitted to Meta via API, status-synced) and an abandoned-cart recovery arc +(30 min / 24 h / 72 h) that uses plain text inside the 24-hour customer +service window and approved templates outside it, skipping sends rather than +violating policy. + +**Key prior findings:** +- Market has four layers: enterprise conversational commerce (Yalo, Haptik), + mid-market SaaS (charles, Wati, SleekFlow…), rep-layer DIY tools (Kyte), + and BSP infrastructure (Twilio, 360dialog, Gupshup). +- **Fluid's moat**: nobody in any layer models a salesforce β€” rep + attribution, comp-plan integration, mobile-money reconciliation, downline + governance, MLM compliance guardrails. +- **Shadow commerce validates demand**: FL reps already DIY WhatsApp + storefronts on Kyte (`forever-living-products-7.kyte.site` β€” the `-7` + means at least six other reps claimed the name first), with zero corporate + visibility. +- Benchmarks worth pressure-testing: in-chat checkout β‰ˆ 35% higher + conversion / β‰ˆ 23% fewer abandoned carts; WhatsApp cart recovery 18–23%; + automated flows drive 60–70% of WhatsApp revenue; CTWA CTR 15–25%. +- **TikTok Shop is unavailable in all of Africa** (Somalia bans TikTok; + South Sudan restricted) β€” social discovery must close in chat. +- Meta path: demo β†’ BSP pilot β†’ Meta Tech Provider (three-stage strategy + already written). The "Fluid Demo" Meta portfolio is restricted; recovery + must be compliant (appeal / corporate portfolio / BSP) β€” never a new + account. +- Meta's native in-chat payments exist only in India, Brazil, Singapore; in + Kenya the M-Pesa STK push delivers the same never-leave-the-chat + experience and is the established local pattern. + +**Who's who:** Mike Tingey owns the Meta Commerce Manager API integration +(product sync, pricing, images, inventory β€” assume complete). Mist is a +separate agent executing the FL Kenya site-clone work (two handoff docs +already delivered). This session's owner is building the client experience +and go-to-market on top. diff --git a/docs/whatsapp-demo-runbook.md b/docs/whatsapp-demo-runbook.md new file mode 100644 index 00000000..2bc9fc0f --- /dev/null +++ b/docs/whatsapp-demo-runbook.md @@ -0,0 +1,122 @@ +# WhatsApp Commerce β€” Demo Runbook + +Step-by-step guide to experiencing and demoing the WhatsApp commerce flow, +runnable by anyone at Fluid from a US phone. Two tracks: + +- **Track A (15 minutes, no code):** feel the native catalog/cart UX as a customer. +- **Track B (half a day):** run the full droplet flow β€” catalog β†’ cart β†’ order β†’ + M-Pesa sandbox payment β†’ in-chat receipt. + +Companion to [whatsapp-commerce-spec.md](./whatsapp-commerce-spec.md). + +> ⚠️ **Meta account integrity warning (learned the hard way):** create the demo +> assets under an **established, verified Meta Business portfolio** β€” ideally +> Fluid's real corporate one β€” using your real identity, and ramp API activity +> gradually. A brand-new business portfolio that immediately drives automated +> API traffic pattern-matches Meta's "automation abuse" detector and gets +> restricted, often permanently. Do **not** create a replacement account to get +> around a restriction β€” Meta links accounts by admin identity, payment method, +> and domain, and ban evasion escalates to those linked assets. If a portfolio +> gets restricted, appeal it via Business Support Home with business +> verification documents instead. + +--- + +## Track A β€” Feel the customer experience (15 minutes, no code) + +The catalog and cart are **native WhatsApp features** β€” no bot or API needed +to experience them. + +1. Install the free **WhatsApp Business** app on a spare number (second + phone, dual-SIM, or eSIM). +2. In the app: **Settings β†’ Business tools β†’ Catalog**, then add 4–5 + FL-style products (Aloe Vera Gel β€” KSh 3,291, Bee Pollen, Aloe Berry + Nectar…) with photos and prices. +3. From your personal WhatsApp, message the business number, tap the + storefront icon, browse the catalog, **add items to cart, send the cart**. + +What you see β€” catalog card, product pages, cart builder, sent-cart summary β€” +is pixel-identical to what FL Kenya customers would see, because it's the +same UI the Cloud API triggers. Screenshot each step; that's the UX spec. + +## Track B β€” Run the full droplet flow + +### B1. Meta setup (do this first; verification is the long pole) + +1. Under the **verified corporate business portfolio** (see warning above), + go to [developers.facebook.com](https://developers.facebook.com) β†’ + **Create App** β†’ type "Business" β†’ add the **WhatsApp** product. +2. The app comes with a **test number** (free, no approval) that can message + up to 5 verified recipients β€” add your own phone. +3. Note three values from the WhatsApp β†’ API Setup page: + - `WHATSAPP_ACCESS_TOKEN` (temporary 24h token, or create a system-user token) + - `WHATSAPP_PHONE_NUMBER_ID` + - `WHATSAPP_APP_SECRET` (App settings β†’ Basic) +4. In [Commerce Manager](https://business.facebook.com/commerce): create a + catalog, add the same 4–5 demo products, **set each item's Content ID / + retailer_id to the Fluid SKU**, and connect the catalog to the WhatsApp + account. Note the catalog ID β†’ `WHATSAPP_CATALOG_ID`. + +### B2. Daraja (M-Pesa) sandbox + +1. Sign up at [developer.safaricom.co.ke](https://developer.safaricom.co.ke), + create an app, and note `MPESA_CONSUMER_KEY` / `MPESA_CONSUMER_SECRET`. +2. Sandbox constants: `MPESA_ENV=sandbox`, `MPESA_SHORTCODE=174379`, and the + public Lipa na M-Pesa sandbox passkey (shown on the portal) β†’ `MPESA_PASSKEY`. +3. Sandbox test phone: `254708374149` (no real PIN prompt; callbacks are simulated). + +### B3. Deploy and wire up + +1. Deploy the backend (Render, same as the droplet template) with the env + vars from B1/B2 plus: + - `WHATSAPP_VERIFY_TOKEN` β€” any random string + - `MPESA_CALLBACK_URL` β€” `https://<backend>/api/webhook/mpesa/callback?secret=<random>` + - `MPESA_CALLBACK_SECRET` β€” the same `<random>` value +2. In the Meta app's WhatsApp β†’ Configuration: set the webhook URL to + `https://<backend>/api/webhook/whatsapp`, enter the verify token, and + subscribe to the **messages** field. Meta calls the GET endpoint; the + droplet echoes the challenge automatically. +3. Multi-tenant note: with a single tenant the env vars are enough. For more + than one company, insert a `whatsapp_configs` row per installation + (phoneNumberId, accessToken, catalogId) β€” inbound routing keys off the + receiving number's `phone_number_id`. + +### B4. Walk the flow as a customer + +1. Get a rep `shareGuid` from the reps table (synced by the rep webhook), and + open `https://wa.me/<test-number>?text=ref:<shareGuid>` on your phone. +2. Send the prefilled message β†’ the bot replies with the **catalog message**. +3. Browse, build a cart, send it β†’ watch the logs: local order created, + Fluid order attempted, STK push fired, confirmation message received. +4. Simulate the payment result: the Daraja sandbox POSTs the callback, or + replay one manually: + ```bash + curl -X POST "https://<backend>/api/webhook/mpesa/callback?secret=<random>" \ + -H "Content-Type: application/json" \ + -d '{"Body":{"stkCallback":{"MerchantRequestID":"demo","CheckoutRequestID":"<from logs>","ResultCode":0,"ResultDesc":"Success","CallbackMetadata":{"Item":[{"Name":"Amount","Value":3291},{"Name":"MpesaReceiptNumber","Value":"TEST123XYZ"},{"Name":"PhoneNumber","Value":254708374149}]}}}}' + ``` +5. The receipt message lands in the chat; the order flips to `paid`. + +### B5. Demo checklist (what to verify / show) + +- [ ] Rep link opens the chat and the session row carries the rep's ID +- [ ] Catalog message renders with products and KSh prices +- [ ] Sent cart creates an `orders` row with `pending_payment` and rep attribution in `orderData` +- [ ] STK push request logged; `mpesa_payments` row `pending` +- [ ] Success callback β†’ payment `success`, order `paid`, receipt in chat +- [ ] Failure callback (ResultCode β‰  0) β†’ "reply *pay* to try again" message +- [ ] Replayed duplicate callback is ignored (idempotency) +- [ ] Product update webhook from Fluid syncs the item into the Meta catalog +- [ ] Stopwatch the happy path: first message β†’ receipt (target < 3 minutes) + +### Known gaps (deliberate, phase 2) + +- Fluid order creation uses the v1 API shape from this template's order sync; + confirm the exact create/pay endpoints against current Fluid API docs + before production. +- No conversation state machine yet (quantity edits, delivery selection). +- STK timeout query (customer never enters PIN) not implemented β€” failures + rely on Daraja's callback. +- The real PIN-prompt experience requires a production shortcode and a + Safaricom line β€” one colleague in Nairobi gets you the true 3-minute demo + video for pitching FL corporate. diff --git a/docs/whatsapp-tech-provider-strategy.md b/docs/whatsapp-tech-provider-strategy.md new file mode 100644 index 00000000..42374cf9 --- /dev/null +++ b/docs/whatsapp-tech-provider-strategy.md @@ -0,0 +1,180 @@ +# Fluid Γ— WhatsApp: Tech Provider Strategy + +How Fluid turns the WhatsApp commerce droplet into a platform capability β€” +every Fluid client gets a WhatsApp storefront with rep attribution and local +payments, onboarded in minutes through Fluid's own UI, with Fluid holding the +direct Meta relationship as a **Tech Provider**. + +Companion docs: [spec](./whatsapp-commerce-spec.md) Β· +[runbook](./whatsapp-demo-runbook.md) Β· +[FL Kenya teardown](./forever-living-kenya-teardown.md) + +--- + +## 1. Why this is a Fluid-shaped opportunity + +Fluid's clients are direct-selling companies. Their commerce already happens +in chat β€” reps close sales on WhatsApp in every emerging market β€” but today +it's free-text messages, manual payments, and lost attribution. The FL Kenya +teardown documented the cost: hours-to-days order cycles, ~0% automated +reconciliation, commission by memory, and gray-market marketplaces winning on +checkout convenience. + +The droplet already solves this for one company. Tech Provider status is what +makes it **productizable for all of them**: + +- **Embedded signup inside Fluid onboarding** β€” a client connects WhatsApp + the way they connect a payment gateway: OAuth-style popup, done. No Meta + developer console, no tickets. +- **Clients pay Meta directly** for messaging β€” no BSP margin (typically + 5–20%) in the middle, which matters since Meta moved to per-message + billing in July 2025. +- **Fluid owns the platform relationship** β€” the same strategic position + Shopify holds with its commerce integrations, rather than reselling + someone else's. + +The architecture is already the right shape: `whatsapp_configs` maps each +installation to its own `phone_number_id`, access token, and catalog. Tech +Provider onboarding just fills those rows automatically instead of manually. + +## 2. The three-stage path + +Each stage ships value on its own and hands off to the next without a +rewrite. + +### Stage 1 β€” Prove it (now β†’ ~1 month) +**Goal:** working demo + one lighthouse conversation. + +- Resolve the Meta account problem: appeal the restricted "Fluid Demo" + portfolio AND build under Fluid's real, verified corporate portfolio (see + runbook warning β€” never a throwaway). +- Run the sandbox demo end-to-end (runbook Track B); record the 3-minute + happy-path video. +- Pitch FL Kenya (or the most WhatsApp-native client in the pipeline) as the + lighthouse: their Nairobi office + rep network is the perfect proving + ground, and the teardown/competitive docs are the pitch material. + +**Gate to Stage 2:** demo works; one client signed for pilot. + +### Stage 2 β€” Pilot on a BSP (~1–4 months) +**Goal:** first paying tenant(s) live in production without waiting on +Meta approvals. + +- Launch the pilot through **360dialog** (Cloud API-compatible payloads = + minimal code change; no per-message markup, flat per-number fee; human + support for number provisioning β€” insurance against the account-integrity + issues we already hit). +- Production M-Pesa: client's paybill + Daraja production app, or a PSP + (IntaSend/Pesapal) for Airtel Money + cards. +- Build the phase-2 product items from the spec: conversation state machine, + payment retries/timeout queries, delivery/pickup selection, rep dashboard. +- Instrument the success metrics (below) β€” pilot data is the Tech Provider + application's evidence and the sales deck's proof. + +**Gate to Stage 3:** 1–3 tenants live, metrics green, support load +understood. + +### Stage 3 β€” Become a Meta Tech Provider (~3–6 months, parallel start) +**Goal:** Fluid's own Meta app with embedded signup; clients onboard +self-serve and pay Meta directly. + +Meta's requirements ([official guide](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/get-started-for-tech-providers)): + +1. **Meta Business portfolio, business-verified** β€” Fluid's corporate + portfolio with registration documents. Start immediately; verification is + the long pole and it also unblocks Stages 1–2. +2. **Meta app configured for WhatsApp** under that portfolio. +3. **App Review** for `whatsapp_business_management` and + `whatsapp_business_messaging` permissions β€” requires **video evidence** + of sending messages and managing templates. The pilot deployment *is* + this evidence. +4. **Access Verification** (App Settings β†’ Basic) β€” attests the tech + provider relationship. +5. **Embedded Signup** integrated in Fluid's UI and the app toggled to + **Live mode** (embedded signup errors in Development mode). + +Engineering work in this stage: + +- **Embedded signup flow** in the droplet frontend: client clicks "Connect + WhatsApp" β†’ Meta popup β†’ callback returns the WABA + phone number β†’ we + exchange for a business token and write the `whatsapp_configs` row. + (This replaces manual config; the runtime code path is unchanged.) +- **Token lifecycle**: encrypt stored tenant tokens, refresh/expiry + handling, revocation on uninstall. +- **Template manager**: UI for clients to create/submit message templates + (receipts, delivery updates, re-engagement) through Fluid. +- **Number + catalog provisioning**: guided flows for registering the + client's display number and connecting their Commerce Manager catalog + (auto-synced from Fluid products, already built). +- **Migration path**: move Stage 2 BSP numbers to the Fluid app (Meta + supports number migration between providers; plan it, don't improvise). + +**Gate to "scaled":** a new client connects WhatsApp end-to-end with zero +human touch. + +## 3. Business model + +Options, not mutually exclusive: + +| Model | Mechanics | Fit | +|---|---|---| +| **Droplet subscription** | WhatsApp Commerce as a premium droplet, flat monthly per company | Simplest; matches existing droplet economics | +| **Per-order fee** | Small fee per WhatsApp-originated order | Aligns price with delivered value; needs volume metering (already have `orders.source`) | +| **Messaging pass-through** | Clients pay Meta directly (Tech Provider default) β€” Fluid charges zero margin on messages | A *selling point vs BSP-based competitors*, not a revenue line | + +Recommended: subscription + per-order fee, with "you pay Meta's rates with +no markup" as the competitive wedge. + +## 4. Go-to-market + +- **Lighthouse:** FL Kenya-type client in an M-Pesa market β€” highest pain, + clearest before/after story (hours β†’ <3 minutes; the before/after graphic + and teardown are the deck). +- **Vertical expansion:** the pitch generalizes to every direct-selling + client β€” rep attribution in chat is the feature no horizontal WhatsApp + tool (Wati/Zoko) offers. +- **Market sequencing by payment rail:** Kenya/East Africa (M-Pesa STK, + built) β†’ Brazil & Mexico (native WhatsApp Pay / Pix β€” huge direct-selling + markets, even simpler payments) β†’ SE Asia (gateway-based). +- **Positioning:** "Turn every rep's WhatsApp into an attributed + storefront." Not a chatbot; a commerce channel with commissions wired in. + +## 5. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| Account integrity flags (already bitten once) | Verified corporate portfolio only; gradual ramp; appeal β€” never replace β€” restricted assets; BSP as Stage 2 buffer | +| App Review rejection / delays | Pilot video evidence; scope permissions minimally; 24h resolution typical for solution approval, but budget weeks for App Review | +| MLM + supplement content policy | Catalog copy review per client (no health/income claims); commerce policy pre-check in onboarding | +| Per-client support burden | Embedded signup + provisioning automation is the product answer; BSP pilot measures the load first | +| Fluid API order-shape mismatch | Confirm create/pay endpoints against current Fluid API docs before Stage 2 (flagged in runbook) | +| Meta pricing/policy shifts | Tech Provider = direct relationship; pricing changes hit clients' Meta bill, not Fluid's margin | + +## 6. First 90 days + +| When | Milestone | +|---|---| +| Week 1 | Appeal restricted portfolio; start business verification on the corporate portfolio; assign an owner for the Meta relationship | +| Weeks 1–2 | Sandbox demo green end-to-end (runbook Track B); record demo video | +| Weeks 2–4 | Lighthouse pitch with teardown + video; confirm Fluid order API shapes; pick pilot market/client | +| Weeks 4–8 | 360dialog account + number provisioning; production M-Pesa; phase-2 product build; pilot goes live | +| Weeks 8–12 | Pilot metrics review; submit Meta App Review with pilot evidence; start embedded-signup build | +| Day 90 | Go/no-go on full Tech Provider rollout, backed by pilot data | + +## 7. Success metrics + +- **Pilot (per tenant):** order cycle time <3 min; >95% auto-reconciled + payments; >80% rep-attributed orders; STK abandonment <20%. +- **Platform:** time-to-connect for a new client (target <15 min via + embedded signup); # tenants live; WhatsApp-originated GMV; support + tickets per tenant per month (target near zero after onboarding). +- **Strategic:** Tech Provider approval; first client migrated off BSP; + first non-African market live. + +## 8. Sources + +- Meta β€” [Get started for Tech Providers](https://developers.facebook.com/documentation/business-messaging/whatsapp/solution-providers/get-started-for-tech-providers) +- 360dialog β€” [Become a Meta Tech Provider](https://docs.360dialog.com/partner/get-started/tech-provider-program/become-a-meta-tech-provider) +- Infobip β€” [Tech Provider Program integration guide](https://www.infobip.com/docs/whatsapp/tech-provider-program/setup-and-integration) +- Twilio β€” [Tech Provider Program guide](https://www.twilio.com/docs/whatsapp/isv/tech-provider-program/integration-guide) +- WhAutomate β€” [Tech Provider vs BSP economics](https://whautomate.com/whatsapp-tech-provider-vs-bsp)