Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/src/config/fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down
57 changes: 57 additions & 0 deletions backend/src/routes/jobs.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)?.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)`)
}
}
90 changes: 73 additions & 17 deletions backend/src/routes/mpesa.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)?.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')
Expand All @@ -20,23 +31,25 @@ 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'},
"receiptNumber" = ${result.receiptNumber || null},
"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`
Expand All @@ -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}`))
}

Expand All @@ -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<WhatsAppTenant | null> {
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
}
Loading