08 — Payment Gateway & SMS/WhatsApp Integrations
Status: 📐 Target design — checkout currently records paymentMethod: "cash" | "card" | "wallet" as a label only; no real gateway is wired. This document specifies the integration contracts to implement.
🟢 বাংলা ভূমিকা: এই ডকুমেন্টে bKash/Nagad/Stripe পেমেন্ট গেটওয়ে ইন্টিগ্রেশন, SMS/WhatsApp নোটিফিকেশন, এবং থার্মাল প্রিন্টার KOT জেনারেশনের প্রযুক্তিগত স্পেসিফিকেশন দেওয়া হয়েছে।
1. Payment Gateway Architecture
1.1 Why webhook (IPN) driven, not client-confirmed
Bangladesh mobile-wallet gateways (bKash, Nagad) and card gateways (Stripe) all follow the same trust model: the client-side "success" callback is a UX hint only and must never itself mark an order paid — final payment truth comes from a signed server-to-server webhook (an "Instant Payment Notification", IPN) that the gateway calls back after settlement. Trusting the client redirect alone is a well-known fraud vector (a user can hit the success URL without actually paying).
Customer (/table checkout)
│ 1. POST /api/payments/bkash/create (amount, orderId)
▼
Next.js Route Handler ──2. calls bKash Checkout API──▶ bKash
│ │
│◀── 3. paymentID + bkashURL ─────────────────────┘
▼
Redirect customer to bkashURL (hosted checkout)
│
│ 4. customer completes payment on bKash's page
▼
bKash ──5. server-to-server IPN──▶ POST /api/webhooks/bkash (signed payload)
│
▼
Verify signature → mark Order.isPaid = true, Invoice/Order status update → emit Realtime event
│
▼
Customer's browser (polling or Realtime subscription) sees "Payment confirmed" without a page reload
🟢 বাংলা সারাংশ: পেমেন্ট নিশ্চিতকরণ কখনোই শুধু ক্লায়েন্ট-সাইড রিডাইরেক্টের উপর নির্ভর করা উচিত না — কারণ একজন ব্যবহারকারী আসলে টাকা না দিয়েও "success" URL-এ পৌঁছাতে পারে। তাই আসল নিশ্চয়তা আসে গেটওয়ে থেকে সরাসরি সার্ভারে পাঠানো একটা সাইন করা webhook (IPN) থেকে, যা যাচাই করার পর অর্ডার "paid" মার্ক করা হয়।
1.2 bKash Checkout (Tokenized) Integration
// app/api/payments/bkash/create/route.ts
export async function POST(req: Request) {
const { orderId, amount } = await req.json();
const token = await getBkashGrantToken(); // cached, refreshed via /token/grant + /token/refresh
const res = await fetch(`${BKASH_BASE_URL}/tokenized/checkout/create`, {
method: "POST",
headers: { Authorization: token, "X-App-Key": process.env.BKASH_APP_KEY!, "Content-Type": "application/json" },
body: JSON.stringify({
mode: "0011",
payerReference: orderId,
callbackURL: `${process.env.APP_URL}/api/payments/bkash/callback`,
amount: amount.toFixed(2),
currency: "BDT",
intent: "sale",
merchantInvoiceNumber: orderId,
}),
});
const data = await res.json();
return Response.json({ paymentID: data.paymentID, bkashURL: data.bkashURL });
}
// app/api/webhooks/bkash/route.ts — server-to-server IPN
export async function POST(req: Request) {
const payload = await req.json();
// 1. Verify signature/execute payment against bKash's /execute endpoint using the paymentID
const verified = await executeBkashPayment(payload.paymentID);
if (verified.transactionStatus !== "Completed") {
return Response.json({ ok: false }, { status: 400 });
}
// 2. Idempotency guard: bKash may retry the same IPN — check a `payment_events` table keyed by trxID first
await markOrderPaid(verified.merchantInvoiceNumber, {
method: "wallet", gatewayTrxId: verified.trxID, amount: verified.amount,
});
return Response.json({ ok: true });
}
1.3 Nagad Integration
Structurally identical to bKash (initiate → redirect to Nagad's hosted page → server IPN callback), but Nagad uses RSA-signed challenge/response for initiation instead of an OAuth-style bearer token — the initiate payload must be encrypted with Nagad's public key and the response decrypted/verified with the merchant's private key pair, issued during merchant onboarding.
1.4 Stripe (international cards)
// app/api/payments/stripe/create-intent/route.ts
export async function POST(req: Request) {
const { orderId, amount, currency } = await req.json();
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // smallest currency unit
currency,
metadata: { orderId },
automatic_payment_methods: { enabled: true },
});
return Response.json({ clientSecret: intent.client_secret });
}
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const sig = req.headers.get("stripe-signature")!;
const rawBody = await req.text();
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!); // throws if signature invalid
if (event.type === "payment_intent.succeeded") {
const intent = event.data.object as Stripe.PaymentIntent;
await markOrderPaid(intent.metadata.orderId, { method: "card", gatewayTrxId: intent.id, amount: intent.amount / 100 });
}
return Response.json({ received: true });
}
Stripe's constructEvent signature verification (using the raw, unparsed request body + a webhook signing secret) is the canonical pattern all three gateway webhook handlers should structurally mirror, even though bKash/Nagad use their own bespoke schemes.
🟢 বাংলা সারাংশ: bKash-এ OAuth-স্টাইল টোকেন দিয়ে পেমেন্ট শুরু করা হয়, Nagad-এ RSA সাইন করা চ্যালেঞ্জ/রেসপন্স ব্যবহার হয়, আর Stripe আন্তর্জাতিক কার্ড পেমেন্টের জন্য। তিনটির জন্যই মূল নিয়ম এক — webhook signature যাচাই করার পরই অর্ডার "paid" মার্ক করা।
2. SMS & WhatsApp Notification Dispatch
Type reference: src/types/notification.ts (NotificationTemplate, NotificationDispatchLog)
type NotificationEvent = "order-placed" | "kitchen-dispatch" | "out-for-delivery" | "delivered" | "bill-ready";
type NotificationChannel = "sms" | "whatsapp" | "push" | "email";
Each tenant configures a NotificationTemplate per (event, channel) pair (with {{customerName}}, {{orderNumber}}, {{trackingUrl}} placeholders) and an enabled toggle. Dispatch is a fire-and-forget job queued at the moment Order.status transitions (e.g. advanceStatus in production also enqueues a notification job, not just a UI timeline event).
Two languages per template. Every template carries message and
messageBn, and a guest is sent the one matching Order.locale — the
language they were reading when they ordered, recorded on the order at
checkout. Nothing is sent in both at once: a bilingual SMS costs double.
What a Bengali SMS costs. One Bengali letter (or emoji, or curly quote)
moves the whole message from GSM-7 to UCS-2, and a single SMS drops from 160
characters to 70 — 153 and 67 per part once it splits. So "order received"
can be one SMS in English and three in Bengali. Settings shows the count under
every SMS template in both languages (src/lib/sms-segments.ts), counted
before placeholders are filled — a restaurant name and a link add to it —
and turns amber past one SMS. Keep the default Bengali SMS templates short;
WhatsApp has no such limit.
// lib/notifications/dispatch.ts
async function dispatchNotification(event: NotificationEvent, order: Order, tenant: Tenant) {
const templates = await getEnabledTemplates(tenant.id, event);
for (const template of templates) {
const message = renderTemplate(template.message, { customerName: order.customerName, orderNumber: order.orderNumber });
switch (template.channel) {
case "sms": await sendSms(order.customerPhone!, message); break; // e.g. via a local aggregator (SSL Wireless / Alpha SMS) for BD numbers
case "whatsapp": await sendWhatsApp(order.customerPhone!, message); break; // via Meta WhatsApp Cloud API
case "email": await sendEmail(order.customerEmail, message); break;
case "push": await sendWebPush(order.customerId, message); break;
}
await logDispatch({ event, channel: template.channel, recipient: order.customerPhone!, message, status: "sent" });
}
}
WhatsApp Cloud API requires pre-approved message templates for any notification sent outside a customer-initiated 24-hour conversation window — so order-placed/out-for-delivery/delivered templates must be submitted to Meta for approval ahead of launch, not sent as arbitrary free text.
🟢 বাংলা সারাংশ: প্রতিটি রেস্টুরেন্ট নিজের নোটিফিকেশন টেমপ্লেট কনফিগার করতে পারবে (কোন ইভেন্টে কোন চ্যানেলে কী মেসেজ যাবে)। অর্ডার স্ট্যাটাস পরিবর্তনের সাথে সাথে একটা ব্যাকগ্রাউন্ড জব এই মেসেজ পাঠাবে। WhatsApp-এর ক্ষেত্রে Meta-এর অনুমোদিত টেমপ্লেট আগে থেকে জমা দিতে হবে, কারণ ২৪ ঘণ্টার কথোপকথন উইন্ডোর বাইরে শুধু অনুমোদিত টেমপ্লেটই পাঠানো যায়।
3. Thermal Printer KOT Generation
Target: ESC/POS command generation for kitchen order tickets, printed via a small local print-bridge service (a lightweight Node process on a kitchen-side PC/Raspberry Pi listening on the local network, since browsers cannot talk directly to USB/network thermal printers).
// lib/printing/kot.ts
import { ThermalPrinter, PrinterTypes } from "node-thermal-printer";
async function printKot(order: Order, stationPrinterIp: string) {
const printer = new ThermalPrinter({ type: PrinterTypes.EPSON, interface: `tcp://${stationPrinterIp}` });
printer.alignCenter();
printer.bold(true);
printer.println(`KOT #${order.orderNumber}`);
printer.bold(false);
printer.println(order.tableLabel ?? order.channel.toUpperCase());
printer.drawLine();
printer.alignLeft();
for (const item of order.items) {
printer.println(`${item.quantity}x ${item.name}${item.variantLabel ? ` (${item.variantLabel})` : ""}`);
if (item.notes) printer.println(` note: ${item.notes}`);
}
printer.drawLine();
printer.println(new Date(order.createdAt).toLocaleTimeString());
printer.cut();
await printer.execute();
}
Routing: each MenuItem's station (grill/fry/cold/bar/dessert/curry) determines which physical printer receives the ticket — a multi-station kitchen can print a split ticket per station from a single order, mirroring how the KDS board already groups tickets by station (see 06 §2). KOT printing runs alongside, not instead of, the KDS digital ticket — a physical backup for kitchens with unreliable displays or a preference for paper.
🟢 বাংলা সারাংশ: থার্মাল প্রিন্টার থেকে KOT (কিচেন অর্ডার টিকেট) প্রিন্ট করার জন্য একটা লোকাল প্রিন্ট-ব্রিজ সার্ভিস দরকার হবে (ব্রাউজার সরাসরি প্রিন্টারের সাথে কথা বলতে পারে না)। প্রতিটি মেনু আইটেমের স্টেশন অনুযায়ী সঠিক প্রিন্টারে টিকিট পাঠানো হবে — এটা KDS ডিজিটাল টিকিটের পরিবর্তে নয়, বরং পাশাপাশি একটা ফিজিক্যাল ব্যাকআপ হিসেবে কাজ করবে।
4. Automated PDF Invoicing (cross-reference)
The invoice PDF generation + email dispatch flow for subscription billing is documented in detail in 03-platform-admin-spec.md §7. At a glance: Invoice creation (registration/renewal/manual) triggers a PDF render job (e.g. @react-pdf/renderer or a headless-Chromium HTML→PDF service) and an email dispatch to the tenant's owner-role staff email, reusing the same sendEmail primitive described in §2 above.
🟢 বাংলা সারাংশ: সাবস্ক্রিপশন বিলিং-এর জন্য PDF ইনভয়েস জেনারেশন ও ইমেইল পাঠানোর সম্পূর্ণ ফ্লো 03-platform-admin-spec.md ডকুমেন্টে বিস্তারিত আছে — এটি এই ডকুমেন্টের §2-তে বর্ণিত একই ইমেইল পাঠানোর মেকানিজম পুনরায় ব্যবহার করবে।